Skip to content

Observability

NimbleBrain emits vendor-neutral telemetry: OpenTelemetry traces over OTLP, structured JSON logs, and a Prometheus metrics endpoint. None of it requires infrastructure to run — with no collector configured, traces are simply not exported and trace ids still flow through logs for correlation. This page covers what to wire up when you run NimbleBrain in production.

The runtime wraps its hot paths in OpenTelemetry spans and exports them over OTLP/HTTP. It depends only on the open OTel and W3C tracecontext primitives, so you point it at any OTLP-compatible collector (the OpenTelemetry Collector, Grafana Tempo, Jaeger, Honeycomb, and so on).

The runtime produces these spans today:

SpanCovers
agent.turnOne engine run (a full agent turn)
llm.callA single model stream
tool.dispatchAn MCP tool dispatch
HTTP server spanThe inbound request; continues an upstream traceparent if present

Spans nest automatically and carry the verified request identity (user_id, workspace_id, conversation_id) plus the boot-time tenant_id. They never carry the user’s display name, email, secrets, prompts, tool arguments or results, or file contents.

Set OTEL_EXPORTER_OTLP_ENDPOINT to your collector’s base URL. When it is unset, nothing is exported (the default — local bun run dev and OSS checkouts need no infrastructure).

docker-compose.yml (platform service)
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
- NB_SERVICE_NAME=nimblebrain-runtime
- NB_TENANT_ID=acme
VariableDefaultPurpose
OTEL_EXPORTER_OTLP_ENDPOINTunsetCollector base URL. Unset = no export; trace ids still exist for log correlation
NB_SERVICE_NAMEnimblebrain-runtimeService name stamped on every span and log line
NB_TENANT_IDunsetThis deployment’s tenant. A boot-time constant stamped on every span and log line — never read from a request header

Outbound calls that should extend a trace — minting a service token, fetching an authenticated remote MCP server — inject the active W3C traceparent so the trace continues across the hop. Inbound, the HTTP middleware continues any upstream traceparent it receives. No configuration is required; this is on whenever a span is active.

By default the runtime logs pretty, colored lines to stderr for the local dev loop. Set NB_LOG_FORMAT=json to switch to one structured JSON object per line — this is what you want for deployed pods (the platform chart sets it for you).

docker-compose.yml (platform service)
environment:
- NB_LOG_FORMAT=json

Each JSON line is auto-enriched with:

FieldSource
serviceNB_SERVICE_NAME
tenant_idNB_TENANT_ID (boot-time, omitted in dev)
correlation_idThe active trace id — pivot a log straight to its trace
user_id / workspace_id / conversation_idThe verified request identity (omitted when not in a request)

Identity comes only from the verified request context, never the wire, and never includes the human display name. Logs stay on stderr, so Kubernetes captures them alongside stdout and stdout stays clean for JSON-RPC and piped output.

The OpenTelemetry pipeline above is the system of record for traces and logs. Sentry is an optional, additive error sink on top of it — turn it on when you want uncaught exceptions and unhandled promise rejections grouped, deduplicated, and alertable with full stack traces, without scraping logs for them.

It is off by default and switched explicitly — set NB_SENTRY_ENABLED=true (with NB_SENTRY_DSN) to turn it on. Enablement is never inferred from DSN presence, so a stray SENTRY_DSN in the environment can’t silently wire a process into Sentry. When off, the SDK is never even loaded (the OSS, local-dev, and test default). In multi-tenant deployments the chart sets NB_SENTRY_ENABLED per tenant from runtime.config.sentry.enabled, so one tenant can run with Sentry on and another off. These NB_SENTRY_* vars mirror the web client — our own knobs, not Sentry’s auto-read SENTRY_*.

docker-compose.yml (platform service)
environment:
- NB_SENTRY_ENABLED=true
- NB_SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project>
- NB_SENTRY_ENV=production
VariableDefaultPurpose
NB_SENTRY_ENABLEDfalseExplicit on/off switch. Must be true to enable — never inferred from the DSN
NB_SENTRY_DSNunsetThe project DSN (required when enabled)
NB_SENTRY_ENVunsetEnvironment tag (e.g. staging, production) — one project, discriminated by this tag
NB_SENTRY_TRACES_SAMPLE_RATE0Sentry performance tracing. 0 = errors only (recommended)

Release is the runtime’s existing NB_VERSIONNB_BUILD_SHA build identity (the same values /v1/health reports), so events group by deploy with no extra var.

The runtime exposes a Prometheus exposition endpoint at the bare path GET /metrics:

Terminal window
curl http://platform:27247/metrics

A few properties worth knowing:

  • It is served at the bare /metrics path, not under /v1. The bundled nimblebrain-web Caddy proxy only forwards /v1/*, so /metrics is not reachable through the public web container — it is scraped in-cluster.
  • Like /v1/health, it is unauthenticated. It carries no per-user data and is meant to be reachable only inside the cluster.

In Kubernetes, point a ServiceMonitor (or your scrape config) at the platform service’s /metrics path. Do not expose it to the internet.

The platform serves a liveness/readiness endpoint at GET /v1/health. It is unauthenticated and returns the build identity plus the state of each installed bundle:

Terminal window
curl http://platform:27247/v1/health
{
"status": "ok",
"version": "1.2.3",
"buildSha": "abc1234",
"bundles": [
{ "name": "@nimblebraininc/ipinfo", "state": "running" }
]
}
FieldMeaning
status"ok" once the server is serving
versionThe platform version
buildShaThe build commit SHA (null if not stamped)
bundlesEach installed bundle and its lifecycle state

The Docker image already wires this into its HEALTHCHECK (curl -f http://localhost:27247/v1/health), and Compose gates the web service on it via depends_on: condition: service_healthy. In Kubernetes, use the same path for both livenessProbe and readinessProbe.

The /mcp endpoint allocates a stateful session per client (Mcp-Session-Id). At a single replica these live in process memory. To run more than one platform replica, the session metadata must be shared across pods through a registry, and requests must be routed back to the pod holding a session’s transport.

Select the session store with the sessionStore config block (or the env knobs below):

StoreWhen
memorySingle replica (default). Sessions live in process memory only
redisMultiple replicas. Session metadata is shared via Redis so any pod can recognize a session id
nimblebrain.json
{
"sessionStore": {
"type": "redis",
"url": "redis://nb-redis:6379"
}
}

The registry stores only session metadata (sessionId, identity, workspace, timestamps) — never the live transport, which is process-bound. Routing a request to the pod that owns a session’s transport is the load balancer’s job (sticky routing on Mcp-Session-Id), not the registry’s.

These apply regardless of store and tune the per-process transport map:

VariableDefaultPurpose
MCP_MAX_SESSIONS100Maximum concurrent MCP sessions per process. At the cap, a new initialize evicts the least-recently-used session rather than returning an error
MCP_SESSION_TTL_SECONDS28800 (8h)Idle session TTL. Drives both the per-process idle sweep and the registry TTL