| name | fp-go-logging |
| description | Use this skill when working with logging in fp-go functional pipelines (github.com/IBM/fp-go/v2/logging). Trigger on mentions of logging in monadic contexts, ChainFirstIOK, ChainFirst with logging, side-effect logging, structured logging (SLog), context-embedded loggers, LoggingCallbacks, TapSLog, LogEntryExit, Printf/Logf in IO/ReaderIO/ReaderIOResult, or when the user needs to add logging to functional pipelines without breaking the data flow. |
fp-go Logging
Overview
fp-go provides logging utilities that integrate naturally with functional pipelines. Logging is always a side effect — it should not change the value being processed. The library achieves this through ChainFirst-style combinators that thread the original value through unchanged while performing the log.
Packages
| Package | Purpose |
|---|
github.com/IBM/fp-go/v2/logging | Global logger, context-embedded logger, LoggingCallbacks |
github.com/IBM/fp-go/v2/io | Logf, Logger, LogGo, Printf, PrintGo — IO-level logging helpers |
github.com/IBM/fp-go/v2/readerio | Logf, Printf, LogGo, PrintGo — printf/template logging for ReaderIO (no SLog here) |
github.com/IBM/fp-go/v2/context/readerio | SLog, TapSLog, SLogWithCallback — structured logging for context ReaderIO |
github.com/IBM/fp-go/v2/context/readerresult | SLog, TapSLog, SLogWithCallback — structured logging for ReaderResult |
github.com/IBM/fp-go/v2/context/readerioresult | TapSLog/TapSLogInfo/TapSLogDebug, SLog/SLogInfo/SLogDebug, SLogLeft, SLogRight, SLogWithCallback, LogEntryExit, LogEntryExitWithCallback — full suite for ReaderIOResult |
Logging Inside Pipelines
The idiomatic way to log inside a monadic pipeline is ChainFirstIOK (or ChainFirst where the monad is already IO). These combinators execute a side-effecting function and pass the original value downstream unchanged.
With IOResult / ReaderIOResult — printf-style
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
fetchUser(42),
RIO.ChainResultK(result.Eitherize1(validateUser)),
RIO.ChainFirstIOK(IO.Logf[User]("Validated user: %v")),
RIO.Map(enrichUser),
)
IO.Logf[A](format string) func(A) IO[A] logs using log.Printf and returns the value unchanged. It's a Kleisli arrow suitable for ChainFirst and ChainFirstIOK.
With IOResult / plain IO
import (
IOR "github.com/IBM/fp-go/v2/ioresult"
IOF "github.com/IBM/fp-go/v2/ioresult/file"
IO "github.com/IBM/fp-go/v2/io"
J "github.com/IBM/fp-go/v2/json"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
IOF.ReadFile("config.json"),
IOR.ChainResultK(J.Unmarshal[Config]),
IOR.ChainFirstIOK(IO.Logf[Config]("Loaded config: %v")),
IOR.Map(processConfig),
)
(ioeither/file and ioeither are the Either[E, A] equivalents; reach for them only when
the error type is not error. IOR.Map needs no leading type parameter — IOE.Map[error] does.)
Logging Arrays in TraverseArray
import (
A "github.com/IBM/fp-go/v2/array"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
IO "github.com/IBM/fp-go/v2/io"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe2(
A.MakeBy(3, idxToFilename),
RIO.TraverseArray(F.Flow3(
RIO.Eitherize1(readFileCtx),
RIO.ChainResultK(J.Unmarshal[Record]),
RIO.ChainFirstIOK(IO.Logf[Record]("Parsed record: %v")),
)),
RIO.ChainFirstIOK(IO.Logf[[]Record]("All records: %v")),
)
IO Logging Functions
All live in github.com/IBM/fp-go/v2/io:
Logf — printf-style
IO.Logf[A any](format string) func(A) IO[A]
Uses log.Printf. The format string works like fmt.Sprintf.
IO.Logf[User]("Processing user: %+v")
IO.Logf[int]("Count: %d")
Logger — with custom *log.Logger
IO.Logger[A any](loggers ...*log.Logger) func(prefix string) func(A) IO[A]
Uses logger.Printf(prefix+": %v", value). Pass your own *log.Logger instance.
customLog := log.New(os.Stderr, "APP ", log.LstdFlags)
logUser := IO.Logger[User](customLog)("user")
LogGo — Go template syntax
IO.LogGo[A any](tmpl string) func(A) IO[A]
Uses Go's text/template. The template receives the value as ..
type User struct{ Name string; Age int }
IO.LogGo[User]("User {{.Name}} is {{.Age}} years old")
Printf / PrintGo — stdout instead of log
Same signatures as Logf / LogGo but use fmt.Printf/fmt.Println (no log prefix, no timestamp).
IO.Printf[Result]("Result: %v\n")
IO.PrintGo[User]("Name: {{.Name}}")
Structured Logging in the context Package
The context/readerioresult, context/readerresult, and context/readerio packages provide structured slog-based logging functions that are context-aware: they retrieve the logger from the context (via logging.GetLoggerFromContext) rather than using a fixed logger instance.
TapSLog — inline structured logging in a ReaderIOResult pipeline
TapSLog is an Operator (func(ReaderIOResult[A]) ReaderIOResult[A]). It sits directly in a F.Pipe call on a ReaderIOResult, logs the current value or error using slog, and passes the result through unchanged.
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe4(
fetchOrder(orderID),
RIO.TapSLog[Order]("Order fetched"),
RIO.Chain(validateOrder),
RIO.TapSLog[Order]("Order validated"),
RIO.Chain(processPayment),
)
order, err := result.Unwrap(pipeline(ctx)())
- Logs both success values (
value=<A>) and errors (error=<err>) using slog structured attributes.
- Respects the logger level — if the logger is configured to discard Info-level logs, nothing is written.
- Available in both
context/readerioresult and context/readerresult.
SLog, SLogRight, SLogLeft — the underlying Kleisli arrows
In context/readerioresult, SLog[A](msg) is Kleisli[Result[A], Void] — it takes the raw Result[A] and returns Void. It is the building block TapSLog is made from (TapSLog = readerio.Tap ∘ SLog), and it cannot be dropped into RIO.Chain or RIO.ChainFirst on a ReaderIOResult[A]: those expect a Kleisli over A, not over Result[A].
RIO.Chain(RIO.SLog[Data]("Data fetched"))
RIO.TapSLog[Data]("Data fetched")
RIO.ChainFirst(RIO.SLogRight[Data]("Data fetched"))
| Function | Shape | Use |
|---|
TapSLog[A](msg) | Operator[A, A] | drop directly into F.Pipe — logs value or error, value flows through |
TapSLogInfo[A] / TapSLogDebug[A] | Operator[A, A] | same, at an explicit level |
SLogRight[A](msg) | Kleisli[A, Void] | usable with RIO.ChainFirst — success branch only |
SLogLeft(msg) | Kleisli[error, Void] | usable with RIO.ChainFirstLeft[A] — error branch only (annotate A) |
SLog[A](msg) / SLogInfo / SLogDebug | Kleisli[Result[A], Void] | low-level; prefer TapSLog |
In context/readerresult the shape differs: there SLog[A](msg) is Kleisli[Result[A], A] (it passes the value on rather than returning Void). TapSLog[A](msg) Operator[A, A] is identical in both packages — prefer it and the difference never bites.
SLogWithCallback — custom log level and logger source
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
"log/slog"
)
debugLog := RIO.SLogWithCallback[User](
slog.LevelDebug,
logging.GetLoggerFromContext,
"Fetched user",
)
pipeline := F.Pipe2(
fetchUser(123),
RIO.Chain(debugLog),
RIO.Map(func(u User) string { return u.Name }),
)
LogEntryExit — automatic entry/exit timing with correlation IDs
LogEntryExit wraps a ReaderIOResult computation with structured entry and exit log messages. It assigns a unique correlation ID (ID=<n>) to each invocation so concurrent or nested operations can be correlated in logs.
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
)
pipeline := F.Pipe3(
fetchUser(123),
RIO.LogEntryExit[User]("fetchUser"),
RIO.Chain(func(user User) RIO.ReaderIOResult[[]Order] {
return F.Pipe1(
fetchOrders(user.ID),
RIO.LogEntryExit[[]Order]("fetchOrders"),
)
}),
)
orders, err := result.Unwrap(pipeline(ctx)())
On error, the exit log changes to [throwing] and includes the error:
level=INFO msg="[throwing]" name=fetchUser ID=3 duration=5ms error="user not found"
Key properties:
- Correlation ID (
ID=) is unique per operation, monotonically increasing, and stored in the context so nested operations can access the parent's ID.
- Duration (
duration=) is measured from entry to exit.
- Logger is taken from the context — embed a request-scoped logger with
logging.WithLogger before executing the pipeline and LogEntryExit picks it up automatically.
- Level-aware — if the logger does not have the log level enabled, the entire entry/exit instrumentation is skipped (zero overhead).
- The original
ReaderIOResult[A] value flows through unchanged.
cancelFn, ctxWithLogger := pair.Unpack(
logging.WithLogger(
slog.Default().With("requestID", r.Header.Get("X-Request-ID")),
)(r.Context()),
)
defer cancelFn()
value, err := result.Unwrap(pipeline(ctxWithLogger)())
LogEntryExitWithCallback — custom log level
import (
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
"log/slog"
)
debugPipeline := F.Pipe1(
expensiveComputation(),
RIO.LogEntryExitWithCallback[Result](
slog.LevelDebug,
logging.GetLoggerFromContext,
"expensiveComputation",
),
)
SLog / SLogWithCallback in context/readerresult
The same SLog and TapSLog functions are also available in context/readerresult for use with the synchronous ReaderResult[A] = func(context.Context) (A, error) monad:
import RR "github.com/IBM/fp-go/v2/context/readerresult"
pipeline := F.Pipe3(
queryDB(id),
RR.TapSLog[Row]("Row fetched"),
RR.Chain(parseRow),
RR.TapSLog[Record]("Record parsed"),
)
Global Logger (logging package)
The logging package manages a global *slog.Logger (structured logging, Go 1.21+).
import "github.com/IBM/fp-go/v2/logging"
logger := logging.GetLogger()
logger.Info("application started", "version", "1.0")
old := logging.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
defer logging.SetLogger(old)
Context-Embedded Logger
Embed a *slog.Logger in a context.Context to carry request-scoped loggers across the call stack. All context-package logging functions (TapSLog, SLog, LogEntryExit) pick up this logger automatically.
import (
"github.com/IBM/fp-go/v2/logging"
"github.com/IBM/fp-go/v2/pair"
"log/slog"
)
reqLogger := slog.Default().With("requestID", "abc-123")
cancelFn, ctxWithLogger := pair.Unpack(logging.WithLogger(reqLogger)(ctx))
defer cancelFn()
value, err := result.Unwrap(pipeline(ctxWithLogger)())
WithLogger returns a ContextCancel = Pair[context.CancelFunc, context.Context]. The cancel function is a no-op — the context is only enriched, not made cancellable.
GetLoggerFromContext falls back to the global logger if no logger is found in the context.
LoggingCallbacks — Dual-Logger Pattern
import "github.com/IBM/fp-go/v2/logging"
infoLog, errLog := logging.LoggingCallbacks()
infoLog, errLog := logging.LoggingCallbacks(myLogger)
infoLog, errLog := logging.LoggingCallbacks(infoLog, errorLog)
Used internally by io.Logger and by packages that need separate info/error sinks.
Choosing the Right Logging Function
| Situation | Use |
|---|
| Quick printf logging mid-pipeline | IO.Logf[A]("fmt") with ChainFirstIOK |
| Go template formatting mid-pipeline | IO.LogGo[A]("tmpl") with ChainFirstIOK |
| Print to stdout (no log prefix) | IO.Printf[A]("fmt") with ChainFirstIOK |
| Structured slog — log value or error inline | RIO.TapSLog[A]("msg") (Operator, used in Pipe) |
| Structured slog — success branch only | RIO.ChainFirst(RIO.SLogRight[A]("msg")) |
| Structured slog — error branch only | RIO.ChainFirstLeft(RIO.SLogLeft("msg")) |
| Structured slog — custom log level | RIO.SLogWithCallback[A](level, cb, "msg") |
| Entry/exit timing + correlation IDs | RIO.LogEntryExit[A]("name") |
| Entry/exit at custom log level | RIO.LogEntryExitWithCallback[A](level, cb, "name") |
| Structured logging globally | logging.GetLogger() / logging.SetLogger() |
| Request-scoped logger in context | logging.WithLogger(logger) + logging.GetLoggerFromContext(ctx) |
Custom *log.Logger in pipeline | IO.Logger[A](logger)("prefix") with ChainFirstIOK |
Complete Example
package main
import (
"context"
"log/slog"
"os"
F "github.com/IBM/fp-go/v2/function"
IO "github.com/IBM/fp-go/v2/io"
L "github.com/IBM/fp-go/v2/logging"
P "github.com/IBM/fp-go/v2/pair"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/result"
)
func main() {
L.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
_, ctx := P.Unpack(L.WithLogger(
L.GetLogger().With("requestID", "req-001"),
)(context.Background()))
pipeline := F.Pipe5(
fetchData(42),
RIO.LogEntryExit[Data]("fetchData"),
RIO.TapSLog[Data]("raw data"),
RIO.ChainResultK(result.Eitherize1(transformData)),
RIO.LogEntryExit[Out]("transformData"),
RIO.ChainFirstIOK(IO.LogGo[Out]("result: {{.Value}}")),
)
value, err := result.Unwrap(pipeline(ctx)())
if err != nil {
L.GetLogger().Error("pipeline failed", "error", err)
}
_ = value
}