Files
probo/contrib/claude/logging.md
Bryan Frimin e84094e62c Add logging and encryption rules
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-04-19 11:59:22 +02:00

7.1 KiB

Logging — Structured, PII-free Observability

Library: go.gearno.de/kit/log — named, context-aware structured logger with typed fields.

Golden rule

Only log opaque identifiers (IDs, correlation IDs, request IDs). Never log PII, PHI, credentials, or any data that can identify or harm a natural person.

What must never appear in logs

Category Examples Why
PII (Personally Identifiable Information) Email addresses, full names, phone numbers, IP addresses of end-users, postal addresses, dates of birth GDPR / privacy — log the entity ID instead
PHI (Protected Health Information) Medical records, health status, insurance IDs HIPAA / privacy
Credentials & secrets Passwords, API keys, tokens (access, refresh, bearer, SCIM), signing secrets, private keys, client secrets Security — a leaked log line becomes a breach
User-generated content Form input, document text, chat messages, file contents May embed PII/PHI; can also be weaponized for log injection
Financial data Credit card numbers, bank accounts, billing addresses PCI-DSS

What is safe to log

Safe Example field
Entity IDs (GIDs, UUIDs) log.String("identity_id", identity.ID.String())
Correlation / request IDs log.String("correlation_id", correlationID)
Operation names and types log.String("graphql_operation_name", operationName)
URL paths (without query strings containing secrets) log.String("path", r.URL.Path)
Domain names (public, not user-chosen) log.String("domain", domain)
Counts, sizes, durations log.Int("count", n), log.Duration("elapsed", d)
Error messages from internal code log.Error(err)
State transitions / enum values log.String("state", string(newState))
Timestamps log.Time("expires_at", cert.ExpiresAt)

Logger wiring and progressive enrichment

Loggers are constructor-injected and progressively enriched with .Named() / .With() as they flow deeper into the call stack. Every layer that has meaningful context must derive a child logger and attach attributes — this builds up a rich, filterable log trail without repeating fields on every call site.

Deriving loggers

  • .Named(subsystem) — adds a dot-separated name prefix to every log line. Use at service/component boundaries.
  • .With(fields...) — returns a new logger with permanent structured fields. Use when an identifier or attribute is known for the lifetime of that scope (a request, a job iteration, a connection).

Always assign the derived logger to a new variable or field — never mutate the parent:

// Service constructor — name the subsystem
func NewRenewer(logger *log.Logger, ...) *Renewer {
	return &Renewer{
		logger: logger.Named("renewer"),
	}
}

// Worker iteration — attach the entity being processed
func (r *Renewer) renewDomain(ctx context.Context, domain CustomDomain) {
	logger := r.logger.With(
		log.String("domain", domain.Domain),
		log.String("custom_domain_id", domain.ID.String()),
	)

	logger.InfoCtx(ctx, "starting certificate renewal")
	// ... all subsequent logs in this function carry domain + custom_domain_id
	logger.InfoCtx(ctx, "certificate renewed successfully")
}

Enrichment chain

The root logger created in probod flows through the system, gaining context at each layer:

probod (root)
  → .Named("http.server")
    → .Named("api")
      → .With(log.String("correlation_id", id))
        → .Named("certmanager")
          → .With(log.String("domain", d))

A log line emitted at the leaf carries all ancestor attributes automatically. This means:

  • You never need to repeat correlation_id or organization_id at inner call sites
  • Filtering by any attribute in the chain works across the entire request span
  • Adding a new attribute at one layer enriches every log line below it

Where to derive

Boundary Derive with Typical attributes
Service/component constructor .Named("subsystem") —
HTTP middleware / request entry .With(...) correlation_id, identity_id, path
Worker job iteration .With(...) entity ID being processed
Agent / tool execution .Named("agent").With(...) agent, tool name
Retry / loop body .With(...) attempt, iteration key

Fallback

In HTTP handlers where no constructor-injected logger is available (e.g. panic recovery), use httpserver.LoggerFromContext(ctx) — it returns the request-scoped logger with all middleware-attached attributes.

Structured field helpers

Use typed field constructors — never fmt.Sprintf into a log message for structured data:

Helper Use for
log.String(key, val) String values (IDs, domains, operation names)
log.Int(key, val) Integer counts
log.Int64(key, val) Large integers (byte sizes, database counts)
log.Bool(key, val) Flags
log.Float64(key, val) Floating-point metrics
log.Duration(key, val) time.Duration values
log.Time(key, val) time.Time values
log.Error(err) Error values (key is automatically "error")
log.Any(key, val) Last resort — prefer a typed helper when one exists

Context-aware methods

Always prefer the *Ctx variants to propagate trace/request context:

logger.InfoCtx(ctx, "message", log.String("key", "value"))
logger.WarnCtx(ctx, "message", log.String("key", "value"))
logger.ErrorCtx(ctx, "message", log.Error(err))

Use the non-context Info / Error only at process startup/shutdown where no request context exists.

Rules

  • Always derive, never repeat. When you enter a new scope that has a meaningful identifier (a request, a job, an entity), derive a child logger with .Named() or .With() and use it for all subsequent calls in that scope. Never pass the same attribute as an inline field on every log call — attach it once on the derived logger.
  • Log IDs, not values. Instead of log.String("email", user.Email), write log.String("identity_id", user.ID.String()).
  • Treat error descriptions from external sources as untrusted. OAuth error_description, OIDC provider messages, and SAML responses may contain user data or be provider-controlled. Log a sanitized error code, not the full description.
  • Guard log.Any. The log.Any helper serializes arbitrary values. Never pass structs that may contain sensitive fields. Prefer explicit field selection.
  • Never log raw HTTP bodies or query strings. Query strings may carry code, token, or state parameters. Log r.URL.Path only.
  • Never log fmt.Errorf messages that embed secrets. If an error wraps sensitive context (e.g. a decryption failure message that echoes input), strip it before logging.
  • GraphQL errors are semi-public. log.Any("errors", resp.Errors) is acceptable because GraphQL errors are already filtered for the client, but never add raw input variables to log context.
  • Keep log messages static. The message string should be a fixed human-readable sentence. Dynamic data goes in structured fields, not in fmt.Sprintf message templates.