Skip to main content
Version: 1.16.0

Observability

pkg/observe is Nucleus's logging and tracing layer. app.New(cfg) wires it by default, so there is nothing to set up before you get structured logs.

It is built on two choices:

  • log/slog for structured logging.
  • OpenTelemetry for distributed traces and metrics.

This page covers logging, tracing, the health and metrics endpoints, the live SQL feed, and the circuit breaker for external dependencies.

Logging​

Every request gets a logger pre-bound with:

  • request_id
  • method, path, status, latency
  • the resolved tenant / site (when multi-tenant is enabled)
  • the trace and span IDs (when OTel is on)
slog.InfoContext(ctx, "article.created",
"article_id", id,
"author_id", authorID,
)

The format is configurable:

log_level: info # debug | info | warn | error
log_format: json # text | json

json is the default. Override per-environment.

Tracing​

OpenTelemetry export is opt-in: set otlp_endpoint to an OTLP-HTTP collector and the MeterProvider/TracerProvider start pushing to it.

otlp_endpoint: http://otel-collector:4318

When set, every HTTP request is wrapped in a span and every SQL query emits a child span.

Framework-mounted runtime endpoints​

The runtime mounts these endpoints automatically. Neither needs application code to register it.

  • GET /healthz — public, no authentication. Reports liveness plus a probe per configured dependency.
  • GET /metrics — the Prometheus scrape endpoint, described under Metrics below.

/healthz​

The response is a deterministic JSON shape suitable for Kubernetes probes and external uptime monitors:

{
"status": "healthy",
"checked_at": "2026-05-13T00:00:00Z",
"checks": [
{"name": "db:default", "status": "healthy", "latency_ms": 1},
{"name": "redis", "status": "healthy", "latency_ms": 3},
{"name": "storage", "status": "healthy", "latency_ms": 12}
]
}

status is healthy or unhealthy. The HTTP status is 200 when every probed dependency is healthy and 503 otherwise — external probes only need to consume the status code.

The set of probes is derived from current app state on every request:

ProbeRegistered whenUnderlying call
db:<alias>one per entry in databases:db.DB.Health → sql.DB.PingContext
redisredis_url is non-emptyredis.Client.Ping against a short-lived client
storagea storage.Store is attached (default subsystems)storage.Store.List with _nucleus_healthz/ prefix, limit 1
mailthe configured mail.Sender implements HealthCheckermail.HealthChecker.Healthy (TCP dial + HELO + QUIT for SMTP)

Each probe runs concurrently with a 2-second per-probe budget; total wall time is bounded by the slowest probe.

The mail probe is opt-in by provider: a Sender is probed only if it implements the optional mail.HealthChecker interface. SMTP implements it natively — no auth and no message sent, just a dial, HELO and QUIT.

The noop provider and external plugin senders do not implement it, so deployments on those drivers see no mail row in the /healthz response. Probing external plugins needs a new call on the plugin protocol; until that lands, each plugin owns its own health surface.

Metrics​

The runtime exports the following metrics through OpenTelemetry:

  • HTTP request count and latency histograms,
  • SQL pool stats (in use, idle, wait time),
  • session store hit / miss / eviction counters,
  • background-task queue depth and latency (when pkg/tasks is wired).
EndpointWhen mountedFormat
GET /metricsmetrics_path is non-empty (default /metrics)OpenMetrics / Prometheus exposition
OTLP pushotlp_endpoint is setOTLP-HTTP

The two paths coexist: the MeterProvider attaches both readers when both are configured, so a deployment can scrape locally and push to an OTel collector without double-instrumenting code.

To disable the /metrics endpoint, set metrics_path: "" in nucleus.yml.

No built-in auth on /metrics by default

By default the scrape endpoint answers without authentication (metrics_public: true, the historical behaviour): the bootstrap RBAC allow-list grants anonymous access so Prometheus can scrape out of the box. If you keep that default, restrict access at the network or reverse-proxy layer (allow-list your Prometheus scraper). To gate it in-process instead, set metrics_public: false — /metrics then falls under the default-deny RBAC enforcer like any user route, and your scraper needs an explicit policy grant (e.g. p, metrics-scraper, /metrics, * plus JWT auth).

Seeing every SQL statement, not just the ORM's​

The live SQL feed is fed by the CRUD layer, so by default it shows only the statements that went through models. Anything talking to the database directly never appears: db.QueryContext / db.ExecContext, raw SQL, migrations, the transactional outbox dispatcher, SQL-backed session stores.

On a busy app that is exactly the traffic you most want to see when something is slow. Set sql_driver_instrumentation to wrap the database/sql driver itself, so those statements land on the same feed:

# nucleus.yml
sql_driver_instrumentation: true

What changes when you turn it on:

  • Direct statements appear on the feed, with their operation (select, insert, …), the SQL text, the duration, and any error.
  • Writes report rows_affected — the row count the driver itself reports. Queries report 0, as do drivers that do not supply a count.
  • The model column is empty for these statements. The driver only sees SQL text, so it cannot know which model produced it — that is the honest answer, not a gap. ORM traffic keeps its model name because CRUD keeps emitting it.
  • CRUD statements are not recorded twice. CRUD marks the context it hands to database/sql, and the driver wrapper skips anything carrying that mark. What is left is, by definition, the bypass traffic.

Argument values​

Statement arguments are never published verbatim. Before an event is emitted, each argument is replaced by a redacted, type-tagged form:

Argument typeWhat the feed shows
string, []bytestring(12):*** — type and length only, never the content
numbersthe value
time.TimeRFC 3339 timestamp
anything else<redacted>

Only the first 16 arguments are recorded; the rest collapse into a ...(+N more) marker. So the feed will tell you a query bound a 24-byte string, never which string — passwords, tokens and personal data do not leak into it.

What it costs​

Off (the default), the driver is not wrapped at all: the hot path is the stock database/sql path, byte for byte, and this feature costs nothing.

On, each direct statement pays a small fixed cost (two clock reads, an operation classification, a copy of the argument slice). The expensive part — redaction and building the event — still only runs when someone is actually watching the feed. Leaving it on in production is reasonable; leaving it off costs you nothing.

Circuit breakers for external dependencies​

pkg/circuit provides a small standalone breaker primitive for wrapping calls to mail, object storage, plugin bridges, and other third-party APIs whose unavailability should not cascade into a full outage. The breaker follows the standard closed → open → half-open state machine.

import "github.com/jcsvwinston/nucleus/pkg/circuit"

cb := circuit.New(circuit.Config{
FailureThreshold: 5,
Cooldown: 30 * time.Second,
HalfOpenMaxConcurrent: 1,
})

err := cb.Do(ctx, func(ctx context.Context) error {
return mailer.Send(ctx, msg)
})
if errors.Is(err, circuit.ErrOpen) {
// dependency is in cooldown — fall back to a queue, return
// 503, log and move on, etc.
}

The package is intentionally minimal: no event bus, no metrics surface, no per-call timeout. Compose those with pkg/observe for logging and the /metrics MeterProvider for counters.

What you do not have to do​

  • No "logger" object to thread through every constructor — the request logger lives on the request context.Context.
  • No bespoke tracing API — you use go.opentelemetry.io/otel/trace directly.
  • No opinionated metrics SDK — you emit via OTel and pick your backend at deploy time.