Skip to content

Observability

Every paper-board service shares an observability stack wired through paper-board/sdk: sdk/obs for OTel setup, sdk/log for structured JSON logging, sdk/httpmw for HTTP instrumentation. No per-service setup is required beyond calling obs.SetupOTel and log.New in cmd/server/main.go.

flowchart LR
subgraph services["paper-board services"]
agents["agents"]
identity["identity"]
runtime["runtime"]
compute["compute"]
end
subgraph sdk["paper-board/sdk"]
obs["sdk/obs\nSetupOTel()"]
log["sdk/log\nlog.New()"]
mw["sdk/httpmw\nOTel + Logger"]
end
collector[("OTel Collector\n:4318 OTLP/HTTP")]
loki[("Loki\nlog storage")]
mimir[("Mimir/Grafana\nmetrics + traces")]
services --> sdk
obs -->|"OTLP/HTTP traces + metrics"| collector
log -->|"JSON → stdout → Promtail"| loki
collector --> mimir
classDef controlPlane fill:#10b981,stroke:#047857,color:#fff
classDef dataPlane fill:#3b82f6,stroke:#1d4ed8,color:#fff
classDef sandbox fill:#f97316,stroke:#c2410c,color:#fff
classDef external fill:#ef4444,stroke:#b91c1c,color:#fff
classDef persistence fill:#6b7280,stroke:#374151,color:#fff
class agents,identity,runtime,compute dataPlane
class obs,log,mw controlPlane
class collector,loki,mimir persistence

Call obs.SetupOTel once in cmd/server/main.go. Returns a shutdown function; defer it.

// from paper-board/sdk/obs/otel.go:1-10
type Shutdown func(context.Context) error
func SetupOTel(serviceName, version string) (Shutdown, error)

Exporter: OTLP/HTTP (not gRPC). Empty OTEL_EXPORTER_OTLP_ENDPOINT produces a no-op exporter — the service starts and runs without a collector present.

Env varDefaultNotes
OTEL_EXPORTER_OTLP_ENDPOINT""Full URL: http://otel-collector.observability:4318. Empty = no-op.
OTEL_SERVICE_VERSIONdevsemver or git SHA
OTEL_TRACES_SAMPLER_ARG1.0Head sampling fraction
OTEL_RESOURCE_ATTRIBUTES""Extra resource attrs e.g. deployment.environment=staging

All log output is JSON via sdk/log. Use log.Pkg(ctx) inside handlers to emit the full canonical key set automatically.

KeyTypeRequired when
servicestringalways
request_idstringinside HTTP handler
trace_idstringOTel active
span_idstringOTel active
org_iduuidauth middleware ran
user_iduuidauth ran in JWT mode
api_key_iduuidauth ran in API-key mode
session_iduuidrequest bound to a session
agent_iduuidrequest bound to an agent
event_typestringsession_event lifecycle logs
errorstringwarn/error log
error.kindstringsentinel-typed errors
duration_msintrequest-scoped final log

Service-specific keys MUST carry a <service>. prefix (e.g. billing.invoice_id). Redefining a canonical key with different semantics is forbidden.

// from paper-board/sdk/log/pkg.go:1-6
func WithSpan(ctx context.Context, span trace.Span) context.Context
func WithSession(ctx context.Context, id uuid.UUID) context.Context
func WithAgent(ctx context.Context, id uuid.UUID) context.Context
LevelDefault?Notes
INFOyesdefault in all environments including docker-compose
DEBUGnolocal docker-compose only; MUST NOT be default deployed
WARNemits slog.Source (file:line)
ERRORemits slog.Source (file:line)

Override: LOG_LEVEL=debug|info|warn|error. Validated at startup.

go test runs mute INFO/DEBUG noise via sdk/testfixture.MuteLogger(t).

sdk/httpmw.OTel extracts traceparent / tracestate (W3C TraceContext) and starts the server span. Span name: <METHOD> <route_pattern> (e.g. POST /v1/sessions/{id}/prompt).

All outbound HTTP clients MUST wrap the transport:

// from paper-board/agents/internal/llm/anthropic/client.go:1-5
httpClient := &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
Timeout: 30 * time.Second,
}

Direct http.DefaultClient is forbidden in production code paths. This applies to LLM clients, Stripe, and any other external HTTP.

Servers install otelgrpc.NewServerHandler() (stats-handler API). Clients use otelgrpc.NewClientHandler(). ADR-0005 x-trace-id metadata is populated automatically by the OTel handler.

  • One server span covers the entire SSE response lifecycle. No per-event spans (floods Grafana at ~100 events per high-token stream).
  • Pass r.Context() to llm.Stream(ctx, ...). Each Anthropic round-trip becomes a child span via otelhttp.NewTransport.
  • SSE-emitting services MUST persist trace_id on every event row in the DB to enable cross-system event ↔ trace correlation.

Services emit via the OTel SDK (go.opentelemetry.io/otel/metric). Services MUST NOT expose a /metrics endpoint. The OTel Collector converts OTLP metrics to Prometheus-compatible scrape targets consumed by Grafana/Mimir.

MetricTypeLabelsEmitted by
http.server.request.durationhistogramroute, method, status_classsdk/httpmw.Logger
http.server.requests.in_flightgaugeroutesdk/httpmw.Logger
db.client.query.durationhistogramoperation, tablesdk/store query hooks
agents.llm.tokens_incountermodelagents service
agents.llm.tokens_outcountermodelagents service
agents.llm.stream.durationhistogrammodel, statusagents service

Service-specific metrics MUST be prefixed <service>.. Route labels MUST use the chi route pattern (/v1/sessions/{id}), never the actual path. Metric labels MUST NOT carry user_id or org_id — use OTel exemplars instead.

sdk/log/redact.go wraps the slog.Handler and is default-on in sdk/log.New. It scrubs:

PatternReplaced with
sk-[A-Za-z0-9_-]{20,}sk-***
Bearer [A-Za-z0-9._-]{20,}Bearer ***
email addresses***@***
16-digit card-shaped numbers**** **** **** ****
postgres://user:pass@postgres://***:***@

Attribute keys named password, secret, api_key, token, authorization, x_api_key, cookie are always replaced with *** regardless of value content.

Redaction is a defense layer, not a guarantee. The primary control: never pass secret-bearing struct fields directly to slog.

Sentry is not used. OTel + Loki captures every error with full context. When error triage becomes a bottleneck, the integration path is a slog.Handler wrapper in sdk/log mirroring ERROR-level records — no per-service change needed.