fp-go-logging
IBM/fp-go/skills/fp-go-logging/SKILL.md
Use this skill when adding logging to fp-go functional pipelines (github.com/IBM/fp-go/v2). Trigger on logging inside Pipe/Flow, Tap/TapIOK/TapReaderIOK/TapLeft used for logging, TapSLog, SLogLeft/SLogRight, entry/exit logging (LogEntryExit, LogEntryExitWithCallback), correlation IDs, adding context to errors before they are logged (MapLeft with errors.OnError), request-scoped slog loggers in context.Context (logging.WithLogger, GetLoggerFromContext), or IO.Logf/Printf in IO/IOResult/ReaderIOResult.
What's in it
- fp-go Logging
- Before You Generate
- Quick Reference
- 1. A Simple Log Statement with Tap
- Default: TapSLog
- Printf-Style: TapIOK with IO.Logf
- Custom Attributes: TapReaderIOK
- Success Only / Error Only
- Common Mistakes
- 2. Entry and Exit Logs
- What Gets Wrapped
- Why Not TapSLog("entering")?
- Level and Logger
- 3. Adding Context to the Error Channel
- MapLeft + errors.OnError
- Log Once at the Boundary
- Combine with LogEntryExit
- Rules
- Choosing the Logger
- Package Overview
- Complete Example
---
name: fp-go-logging
description: >-
Use this skill when adding logging to fp-go functional pipelines
(github.com/IBM/fp-go/v2). Trigger on logging inside Pipe/Flow,
Tap/TapIOK/TapReaderIOK/TapLeft used for logging, TapSLog,
SLogLeft/SLogRight, entry/exit logging (LogEntryExit,
LogEntryExitWithCallback), correlation IDs, adding context to errors before
they are logged (MapLeft with errors.OnError), request-scoped slog loggers
in context.Context (logging.WithLogger, GetLoggerFromContext), or
IO.Logf/Printf in IO/IOResult/ReaderIOResult.
---
# fp-go Logging
Logging is a **side effect**: it must never change the value or the error flowing through a
pipeline. fp-go expresses this with the `Tap*` family of operators. A `Tap` runs an effect on
the current value (or error) and hands the **original** computation downstream unchanged.
This skill covers three tasks:
1. [Add a simple log statement](#1-a-simple-log-statement-with-tap): `TapSLog`, `TapIOK`, `TapReaderIOK`, `TapLeft`
2. [Add entry and exit logs](#2-entry-and-exit-logs): `LogEntryExit`
3. [Add context to the error channel](#3-adding-context-to-the-error-channel): `MapLeft` + `errors.OnError`, then log once
Examples assume these imports; all live under `github.com/IBM/fp-go/v2`:
```go
import (
"log/slog"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
ER "github.com/IBM/fp-go/v2/errors"
F "github.com/IBM/fp-go/v2/function"
IO "github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/logging"
R "github.com/IBM/fp-go/v2/result"
)
```
Runnable versions of the snippets are in `v2/context/readerioresult/logging_example_test.go`
(`ExampleTapSLog`, `ExampleTapIOK_logging`, `ExampleTapReaderIOK_logging`, `ExampleSLogRight`,
`ExampleSLogLeft`, `ExampleLogEntryExit`, `ExampleLogEntryExit_nested`,
`ExampleLogEntryExitWithCallback`, `ExampleMapLeft`, `ExampleMapLeft_logging`).
## Before You Generate
fp-go is low-frequency in training data, so signatures are easy to misremember.
For any combinator not shown below, look it up via the fp-go MCP server's
`search_examples` / `get_example` tools (see the **fp-go-mcp** skill) instead of
guessing. After writing code, run `go build ./...` and `go vet ./...` and fix any
type-parameter or argument-order errors before presenting it.
## Quick Reference
| Goal | Operator (in `F.Pipe` on a `ReaderIOResult[A]`) |
|---|---|
| Log value **or** error, structured | `RIO.TapSLog[A]("msg")` |
| Same at DEBUG level | `RIO.TapSLogDebug[A]("msg")` |
| Log success only | `RIO.Tap(RIO.SLogRight[A]("msg"))` |
| Log error only (ERROR level) | `RIO.TapLeft[A](RIO.SLogLeft("msg"))` |
| printf-style log of the value | `RIO.TapIOK(IO.Logf[A]("msg: %v"))` |
| Custom attributes / custom message | `RIO.TapReaderIOK(logX)` with `logX: func(A) ReaderIO[Void]` |
| Entry + exit logs with timing and correlation ID | `RIO.LogEntryExit[A]("name")` |
| Entry + exit at another level | `RIO.LogEntryExitWithCallback[A](slog.LevelDebug, logging.GetLoggerFromContext, "name")` |
| Add context to an error | `RIO.MapLeft[A](ER.OnError("loading user %d", id))` |
| Choose the logger for a pipeline | `RIO.Local[A](logging.WithLogger(l))` |
`Tap*` and `ChainFirst*` are aliases (`TapIOK` = `ChainFirstIOK`, `TapLeft` = `ChainFirstLeft`, …).
Prefer the `Tap` names for logging; they state the intent.
## 1. A Simple Log Statement with `Tap`
### Default: `TapSLog`
`TapSLog[A](msg)` is an `Operator[A, A]`. Put it in the pipe **after** the step you want to
observe. It logs `value=<A>` on success or `error=<err>` on failure at INFO level, using the
logger from the context, and returns the computation unchanged.
```go
func greeting(id int) RIO.ReaderIOResult[string] {
return F.Pipe2(
fetchUser(id),
RIO.TapSLog[User]("fetched user"),
RIO.Map(getName),
)
}
// level=INFO msg="fetched user" value="{ID:42 Name:Alice}"
// level=INFO msg="fetched user" error="not found"
```
- Logs happen when the pipeline **runs**, not when it is built.
- The type parameter is the type at that point in the pipe (`User` here, not `string`).
- `TapSLogInfo` / `TapSLogDebug` choose the level explicitly. Debug logs cost nothing when the
logger's level filters them out.
- `context/readerresult` and `context/readerio` have their own `TapSLog[A]` with the same usage.
### Printf-Style: `TapIOK` with `IO.Logf`
Any `io.Kleisli[A, B]` (`func(A) IO[B]`) can be tapped with `TapIOK`. The `io` package
has ready-made ones:
```go
RIO.TapIOK(IO.Logf[User]("fetched user %+v")) // log.Printf
RIO.TapIOK(IO.LogGo[User]("user {{.Name}}")) // text/template, log.Println
RIO.TapIOK(IO.Printf[User]("fetched user %+v\n")) // fmt.Printf (stdout, no timestamp)
RIO.TapIOK(IO.Logger[User](myStdLogger)("user")) // custom *log.Logger
```
These use the standard `log` package, not `slog`, and log **only on success**; an error skips
the tap. Prefer `TapSLog` when the application uses structured logging.
The same operator exists for the other monads: `IOR.TapIOK` (ioresult), `RR.TapIOK`
(context/readerresult), `IO.ChainFirst` (plain IO).
### Custom Attributes: `TapReaderIOK`
When you want a specific message and specific attributes, write a small named
`func(A) ReaderIO[Void]` that reads the logger from the context. Reading from the context means
the log uses the request-scoped logger and inherits the correlation ID of an enclosing
`LogEntryExit`.
```go
import RIOC "github.com/IBM/fp-go/v2/context/readerio"
func logUserLoaded(u User) RIOC.ReaderIO[F.Void] {
return func(ctx context.Context) IO.IO[F.Void] {
return func() F.Void {
logging.GetLoggerFromContext(ctx).InfoContext(ctx, "user loaded", "id", u.ID, "name", u.Name)
return F.VOID
}
}
}
pipeline := F.Pipe2(
fetchUser(42),
RIO.TapReaderIOK(logUserLoaded),
RIO.Map(getName),
)
// level=INFO msg="user loaded" id=42 name=Alice
```
Do not call `slog.Info(...)` from inside `RIO.Map`: `Map` is for pure functions, and the global
logger ignores the logger scoped to the context.
### Success Only / Error Only
`SLogRight` and `SLogLeft` are Kleisli arrows, so they go through `Tap` / `TapLeft`:
```go
RIO.Tap(RIO.SLogRight[User]("fetched user")) // INFO, success only
RIO.TapLeft[User](RIO.SLogLeft("fetching user failed")) // ERROR, error only; the error still propagates
```
`TapLeft` needs the explicit `[A]` because `A` cannot be inferred from the error handler.
### Common Mistakes
```go
// ❌ SLog is Kleisli[Result[A], Void]: its input is the Result, not A, so this does not compile
RIO.Chain(RIO.SLog[User]("fetched"))
// ✅
RIO.TapSLog[User]("fetched")
// ❌ Chain replaces the value with Void
RIO.Chain(RIO.SLogRight[User]("fetched"))
// ✅
RIO.Tap(RIO.SLogRight[User]("fetched"))
// ❌ side effect inside a pure Map; bypasses the context logger
RIO.Map(func(u User) User { slog.Info("fetched", "user", u); return u })
// ✅
RIO.TapSLog[User]("fetched")
```
## 2. Entry and Exit Logs
`LogEntryExit[A](name)` is an `Operator[A, A]` that **wraps the computation it is applied to**.
It logs `[entering]` before that computation starts and `[exiting ]` (success) or `[throwing]`
(failure, with the error) after it ends. Both records carry the same correlation `ID`, and the
exit record carries the `duration`.
```go
func loadUser(id int) RIO.ReaderIOResult[User] {
return F.Pipe1(
fetchUser(id),
RIO.LogEntryExit[User]("loadUser"),
)
}
// level=INFO msg=[entering] ID=7 name=loadUser
// level=INFO msg="[exiting ]" ID=7 name=loadUser duration=1.2ms
//
// on failure:
// level=INFO msg=[entering] ID=8 name=loadUser
// level=INFO msg=[throwing] ID=8 name=loadUser duration=0.4ms error="not found"
```
### What Gets Wrapped
In a `F.Pipe`, `LogEntryExit` wraps **everything above it**. Put it last to time a whole
function, or apply it to a single step to time just that step:
```go
// Whole pipeline: the TapSLog runs between entry and exit and is logged with the same ID
loadName := F.Pipe3(
fetchUser(42),
RIO.TapSLog[User]("fetched user"),
RIO.Map(getName),
RIO.LogEntryExit[string]("loadName"),
)
// level=INFO msg=[entering] ID=3 name=loadName
// level=INFO msg="fetched user" ID=3 value="{ID:42 Name:Alice}"
// level=INFO msg="[exiting ]" ID=3 name=loadName duration=…
// One step inside a larger pipeline: wrap the Kleisli, not the pipeline
F.Pipe2(
fetchUser(42),
RIO.Chain(F.Flow2(fetchOrders, RIO.LogEntryExit[[]Order]("fetchOrders"))),
RIO.Map(summarize),
)
```
Nested `LogEntryExit` calls each get their own ID. The ID is installed on the context logger
for the wrapped computation, so every context-aware log inside it (`TapSLog`, `SLogLeft`,
`TapReaderIOK` using `GetLoggerFromContext`) is correlated automatically.
### Why Not `TapSLog("entering")`?
A `Tap` placed before a step runs after the **previous** step. It can mark "about to start",
but it cannot log the end of a failed step, measure the duration or correlate the two records.
Use `LogEntryExit` for entry/exit logging.
### Level and Logger
```go
RIO.LogEntryExitWithCallback[User](slog.LevelDebug, logging.GetLoggerFromContext, "loadUser")
```
If the logger does not have the level enabled, the whole instrumentation (ID, timer, both
records) is skipped. The second argument is any `func(context.Context) *slog.Logger`.
## 3. Adding Context to the Error Channel
An error usually surfaces far from where it happened. Logging it at every layer produces
duplicate records that lack context. The idiomatic approach:
1. **Enrich** the error where the context is known, with `MapLeft` and `errors.OnError`.
2. **Log once**, at the boundary (handler, command, job), with `TapLeft(SLogLeft(...))`,
`TapSLog`, or the `[throwing]` record of `LogEntryExit`.
### `MapLeft` + `errors.OnError`
`RIO.MapLeft[A](f Endomorphism[error])` rewrites the error and leaves successes untouched.
`ER.OnError(format, args...)` builds such an endomorphism. It wraps the original error with
`%w`, so `errors.Is` / `errors.As` keep working.
```go
func loadGreeting(id int) RIO.ReaderIOResult[string] {
return F.Pipe2(
fetchUser(id),
RIO.Map(getName),
RIO.MapLeft[string](ER.OnError("loading greeting for user %d", id)),
)
}
// err.Error() == "loading greeting for user 7, Caused By: not found"
// errors.Is(err, ErrNotFound) == true
```
Capture the inputs that explain the failure (`id` above) from the enclosing Kleisli's
parameters. The error then says *which* call failed, not just *that* one failed.
### Log Once at the Boundary
```go
func handle(id int) RIO.ReaderIOResult[string] {
return F.Pipe2(
loadGreeting(id),
RIO.MapLeft[string](ER.OnError("handling request")),
RIO.TapLeft[string](RIO.SLogLeft("request failed")),
)
}
// level=ERROR msg="request failed" error="handling request, Caused By: loading greeting for user 7, Caused By: not found"
```
### Combine with `LogEntryExit`
`[throwing]` logs the error as it leaves the wrapped computation. Put `MapLeft` **above**
`LogEntryExit` so the exit record includes the context:
```go
F.Pipe2(
fetchUser(id),
RIO.MapLeft[User](ER.OnError("loading user %d", id)), // enrich first …
RIO.LogEntryExit[User]("loadUser"), // … then [throwing] logs the enriched error
)
```
### Rules
- Use `MapLeft` to change the error. Do not use `OrElse`/`ChainLeft` with `RIO.Left` just to
rewrap; those are for recovery.
- Always wrap with `%w` (which `ER.OnError` does). `fmt.Errorf("…: %v", err)` breaks `errors.Is`.
- Enrich where the context is; log where the error is handled. A `TapLeft` log in every layer
duplicates the record.
- `MapLeft` is available in `context/readerioresult`, `context/readerresult`,
`context/readerreaderioresult`, `readerresult`, `result` and `ioresult`. In `result`/`ioresult`
the function has type `func(error) E`; `ER.OnError(...)` fits with `E = error`.
## Choosing the Logger
All context-aware operators (`TapSLog*`, `SLogLeft/Right`, `LogEntryExit*`) read the logger with
`logging.GetLoggerFromContext`, which falls back to the global logger.
```go
// Global logger (defaults to slog.Default())
old := logging.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
defer logging.SetLogger(old)
// Request-scoped logger: install it around the whole pipeline with Local.
// logging.WithLogger(l) is the ctx → Pair[CancelFunc, ctx] arrow that Local expects.
reqLogger := logging.GetLogger().With("requestID", requestID)
value, err := R.Unwrap(F.Pipe1(
pipeline,
RIO.Local[Data](logging.WithLogger(reqLogger)),
)(ctx)())
```
`Local` exists for `context/readerio`, `context/readerresult` and `context/statereaderioresult`
as well. To get a plain enriched context outside a pipeline, use
`_, ctx2 := pair.Unpack(logging.WithLogger(reqLogger)(ctx))`. For a logger stored under your
own key, build the callback with
`F.Flow2(CR.AskValue[*slog.Logger](myKey), O.GetOrElse(slog.Default))`. For general context
handling see the `fp-go-context` skill.
## Package Overview
| Package | Logging API |
|---|---|
| `context/readerioresult` | `TapSLog`, `TapSLogInfo`, `TapSLogDebug`, `SLog*`, `SLogLeft`, `SLogRight`, `SLogWithCallback`, `LogEntryExit`, `LogEntryExitWithCallback`, `MapLeft`, `Tap*`, `Local` |
| `context/readerresult` | `TapSLog`, `SLog` (`Kleisli[Result[A], A]`), `TapIOK`, `MapLeft`, `Local` |
| `context/readerio` | `TapSLog`, `SLog`, `Tap`, `TapIOK`, `Local` |
| `io` | `Logf`, `LogGo`, `Printf`, `PrintGo`, `Logger`: `func(A) IO[A]`, used with `TapIOK` |
| `readerio` | `Logf`, `LogGo`, `Printf`, `PrintGo` for the generic ReaderIO |
| `logging` | `GetLogger`, `SetLogger`, `WithLogger`, `GetLoggerFromContext`, `LoggingCallbacks` |
| `errors` | `OnError` (wrap with context), `OnNone`, `OnSome` (build errors) |
`SLogWithCallback[A](level, cb, msg)` has the shape of `SLog`. Turn it into an operator with
`RIOC.Tap(...)` (`context/readerio`) when you need `TapSLog` with a custom level and logger.
## Complete Example
```go
func loadUser(id int) RIO.ReaderIOResult[User] {
return F.Pipe3(
fetchUser(id),
RIO.TapSLogDebug[User]("fetched user"), // simple log statement
RIO.MapLeft[User](ER.OnError("loading user %d", id)), // add context to the error
RIO.LogEntryExit[User]("loadUser"), // entry/exit around the three steps
)
}
func handleGreeting(reqLogger *slog.Logger, id int) RIO.ReaderIOResult[string] {
return F.Pipe3(
loadUser(id),
RIO.Map(getName),
RIO.TapLeft[string](RIO.SLogLeft("greeting failed")), // log the error once, at the boundary
RIO.Local[string](logging.WithLogger(reqLogger)), // every log above uses reqLogger
)
}
name, err := R.Unwrap(handleGreeting(reqLogger, 7)(ctx)())
```
More agent context in IBM/fp-go
11 other files this repository gives its agents.
AGENTS.md
llms.txt
Skill
- fp-go-contextskills/fp-go-context/SKILL.md
- fp-go-effectskills/fp-go-effect/SKILL.md
- fp-go-httpskills/fp-go-http/SKILL.md
- fp-go-lensskills/fp-go-lens/SKILL.md
- fp-go-mcpskills/fp-go-mcp/SKILL.md
- fp-go-pattern-matchingskills/fp-go-pattern-matching/SKILL.md
- fp-go-pipe-flowskills/fp-go-pipe-flow/SKILL.md
- fp-go-pr-reviewskills/fp-go-pr-review/SKILL.md
- fp-goskills/fp-go/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
No reports yet. Be the first to say whether it worked.
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

