Skip to content

Log what a provider does

Providers take an optional *slog.Logger. Unset, output is discarded.

Wiring one up

Through the registry, where there is no settings struct:

factory, err := forge.Lookup(sourceType)

provider, err := factory(ctx, ep, cfg, forge.WithLogger(logger))

Or directly, where there is:

settings.Logger = logger

provider, err := gitlab.NewReleaseProvider(ctx, settings)

Both converge on the same normalisation, so there is one code path and one place redaction is applied.

Unset means discard, not default

forge.NormaliseLogger(nil)   // slog.New(slog.DiscardHandler)

This module never writes to slog.Default. A library that does has taken a position on your output stream, and you may not find out until it appears in production logs you did not configure.

Because a discarding logger reports itself disabled, guarded call sites cost nothing:

if logger.Enabled(ctx, slog.LevelDebug) {
    logger.Debug("page fetched", "page", n, "items", len(items))
}

What gets logged, and at what level

Level Used for
Debug Tracing: which credential source won, capability probe hits and misses, pagination page and item counts, early termination, sanitisation firing
Warn Degraded but proceeding: stale auth.env/auth.keychain, a credential source that errored while a later one succeeded, a capped enumeration
Error Reserved. See below

This module does not log an error it also returns. Reporting one fault twice makes logs harder to read and takes a decision that belongs to the caller. The single exception is a recovered panic (forge.ErrProviderPanic), which signals a defect in a dependency's response handling that an operator should see even if the caller swallows the error.

The conformance harness checks this, if you let it. Supply NewProviderWithLogger alongside NewProvider and it builds your provider with a capturing logger, drives the failing lookups your fixtures describe, and reports any ERROR-level record:

newProvider := func(logger *slog.Logger) forge.Provider {
    p, err := myforge.NewReleaseProvider(ctx, myforge.Settings{Logger: logger, /* … */})
    require.NoError(t, err)

    return p
}

forgetest.RunProviderConformance(t, forgetest.ConformanceConfig{
    NewProvider:           func() forge.Provider { return newProvider(nil) },
    NewProviderWithLogger: newProvider,
    // …
})

Leave it nil and the check is skipped, so the rule is unverified rather than assumed satisfied. forgetest.CaptureLogger is exported for your own tests too: it wraps exactly as NormaliseLogger does, so what you assert on is what a consumer would see, redaction included.

Credentials never reach the logger

Two mechanisms, and the order matters.

The guarantee: no credential value is passed to a logger at all. What is logged is which source produced one and whether one was found:

logger.Debug("credential resolved", "provider", "gitlab", "present", true)

The backstop: whatever logger you supply is wrapped so every attribute value, including bound attributes, nested groups, errors and Stringers, passes through redact before reaching your handler.

The backstop is not the guarantee because redaction recognises well-known token shapes (ghp_, sk-, AKIA, Slack xox…) and long opaque runs. A self-hosted GitLab or Gitea token may match none of them. Relying on redaction alone would be relying on a pattern list to have anticipated your forge.

You can use the wrapper on its own:

handler := forge.RedactingHandler(myHandler)

It is idempotent (re-wrapping returns the same handler rather than nesting) and never panics, both fuzz-verified.

Sending it to OpenTelemetry

observability already produces an slog.Handler, so the two compose with no coupling in either direction:

provider, err := logs.NewProvider(ctx, settings)

logger := slog.New(logs.Handler(provider, "forge"))

forgeProvider, err := factory(ctx, ep, cfg, forge.WithLogger(logger))

This module depends on neither OpenTelemetry nor observability. The root package's dependency guard forbids an instrumented client stack, so log/slog from the standard library is both the cheapest option and the only one that keeps that guard passing.