Skip to content

Redact at the boundary

Goal. Route the free-form strings your process ships to telemetry, shipped logs, and third-party error surfaces through redact.String / redact.Error at a single choke point — so a leaked credential never crosses the boundary in the clear.

When to apply it

Apply redaction on the way out, at the last hop before data leaves your control:

  • a telemetry / tracing exporter (OTLP, a vendor SDK);
  • a log handler whose output is shipped to an aggregator;
  • an error report sent to a crash service or issue tracker;
  • any managed observability ingest (metrics labels, log fields).

Do not apply it to host-only debug logs that never leave the machine — those are inside your trust boundary and may need raw content to be useful.

Redact an error before you report it

The most common leak is an error message that quotes a URL, a flag, or a header. redact.Error is nil-safe, so you can call it unconditionally:

import "gitlab.com/phpboyscout/go/redact"

func reportFailure(reporter Reporter, err error) {
    // redact.Error == redact.String(err.Error()), and returns "" for nil.
    reporter.CaptureMessage(redact.Error(err))
}

Redact fields before you ship a log line

Wrap the values you attach to a shipped log entry. A thin helper keeps the call site honest:

import (
    "log/slog"

    "gitlab.com/phpboyscout/go/redact"
)

// safeArgs redacts every string value before it reaches a shipped handler.
func safeArgs(args ...any) []any {
    out := make([]any, len(args))
    for i, a := range args {
        if s, ok := a.(string); ok {
            out[i] = redact.String(s)
            continue
        }
        out[i] = a
    }
    return out
}

func logExportError(logger *slog.Logger, target string, err error) {
    logger.Error("export failed",
        safeArgs("target", target, "cause", err.Error())...,
    )
}

Make it one choke point

The discipline is redact once, at the boundary — not scattered through your call sites, where it is easy to forget. Centralise it in the component that owns the outbound hop, so every string is sanitised whether or not the caller remembered:

import "gitlab.com/phpboyscout/go/redact"

// exportSpan is the single place spans leave the process. Every free-form
// attribute is redacted here, so callers upstream never have to remember.
func (e *Exporter) exportSpan(name string, attrs map[string]string) {
    safe := make(map[string]string, len(attrs))
    for k, v := range attrs {
        safe[k] = redact.String(v)
    }
    e.sink.Send(name, safe)
}

redact.String is idempotentString(String(s)) == String(s) — so a string that was already redacted upstream is unharmed if it passes through the boundary a second time. You never pay a penalty for redacting defensively.

Keep redacting upstream too

Boundary redaction is a safety net, not a guarantee. The pattern catalogue catches the common shapes (see the rule catalogue), but it cannot recognise a credential in a format it has never seen — a bespoke token, an internal ID scheme, a short opaque secret below the fallback threshold.

So where you know a string carries a secret in a non-standard format, strip or avoid it at the source as well. Treat redact.String as the last line of defence, not the only one:

  • never build log/telemetry strings out of raw secrets you already hold;
  • prefer passing an identifier over the credential itself;
  • let boundary redaction catch the accidents you did not anticipate.