Observability signals
Source: apps/api/src/observability/logger.ts, apps/api/src/observability/metrics.ts,
apps/api/src/observability/otel.ts, apps/api/src/observability/otel-logs.ts,
probe handlers in apps/api/src/server/api.ts. Metric-recording and span call
sites also live in libs/engine-core/src/engine/interpreter.ts and
apps/api/src/restate/services.ts, cited below where an instrument or span is
actually emitted. For setup steps, see
how-to/enable-observability.md.
Processes and service identity
| Process | Entrypoint | initLogger/startOtel argument |
service field / service.name |
Own HTTP probes |
|---|---|---|---|---|
| Proxy | apps/api/src/server/api.ts |
"meridian-proxy" |
meridian-proxy (or SERVICE_NAME / OTEL_SERVICE_NAME override) |
GET /healthz, GET /readyz |
| Engine | apps/api/src/restate/server.ts |
"meridian-endpoint" |
meridian-endpoint (or SERVICE_NAME / OTEL_SERVICE_NAME override) |
none |
Both processes call initLogger(...) and startOtel(...) once, at the top of
their entrypoint, before any other module reads process.env.
Structured logs
Log record envelope
Every log line is one JSON object (pino), or pino-pretty formatted text when
pretty is active. Fields present on every line:
| Field | Origin | Notes |
|---|---|---|
level |
pino | numeric pino level (10 trace … 60 fatal) |
time |
pino | epoch ms |
msg |
pino | message string, literal (French in current call sites) |
pid, hostname |
pino | process identity (hidden by pino-pretty when pretty) |
v |
pino | pino format-version marker |
service |
logger.ts base option |
set once per process by makeRoot(service) |
component |
componentLogger(component) |
present only on child loggers (see below); absent on the root logger |
trace_id, span_id, trace_flags |
logger.ts mixin() |
present only while a span is active (trace.getActiveSpan()); omitted otherwise |
Level and format control
| Variable | Values | Default | Effect |
|---|---|---|---|
LOG_LEVEL |
trace|debug|info|warn|error |
info |
pino root level |
LOG_PRETTY |
1|0 |
unset → pretty iff process.stdout.isTTY and NODE_ENV !== "production" |
1 forces pino-pretty; 0 forces JSON |
SERVICE_NAME |
string | value passed by the entrypoint (meridian-proxy/meridian-endpoint) |
overrides the service field |
Full variable catalogue: reference/environment-variables.md.
Redaction
baseOptions.redact in logger.ts censors these paths on every logger
(root and children), replacing the value with "[redacted]":
*.authorization
*.apiKey
*.api_key
*.password
*.token
req.headers.authorization
config.apiKey
No event or patient payload is ever passed to the logger by design (events
are logged by type/eventId/patientId only) — this redaction list is
defense in depth, not the primary control. See
explanation/security-posture.md.
Component loggers
componentLogger(component) (logger.ts) returns a lazily-derived child of
the current root (root.child({ component })), re-derived on first use after
any root change. Components instantiated in the current code:
component |
Process(es) | Source |
|---|---|---|
http |
proxy | apps/api/src/server/api.ts |
sources |
proxy | apps/api/src/server/api.ts |
otel |
proxy & engine | apps/api/src/observability/otel.ts |
instance |
proxy & engine | apps/api/src/instance.ts |
plugins |
proxy & engine | apps/api/src/plugins.ts |
registry |
proxy & engine | apps/api/src/registry.ts |
engine |
engine | apps/api/src/restate/services.ts |
restate-register |
engine | apps/api/src/restate/register.ts |
Log call sites (selected)
Message strings are reproduced verbatim (as emitted, untranslated).
| Component | Level | msg |
Fields |
|---|---|---|---|
| (root) | info | run démarré |
runId, workflow, patientId, eventType |
| (root) | info | validation tranchée |
runId, decision, by |
| (root) | info | simulation exécutée |
workflow, status, effects, ms |
| (root) | info | API runtime (proxy) démarrée |
port, restate |
sources |
info | source démarrée |
source, feeds |
sources |
warn | mécanisme de source introuvable |
source |
sources |
warn | projection (map) échouée / projection (resolve) échouée |
source, feed, error |
sources |
warn | événement rejeté |
source, feed, error |
sources |
warn | résolution rejetée |
source, feed, runId, error |
sources |
warn | démarrage échoué |
source, error |
http |
debug | requête servie |
method, route, status, ms |
http |
error | erreur de traitement |
method, route, err |
otel |
info | export OTel démarré (traces + métriques + logs) |
endpoint |
engine |
info | run terminé |
workflow, runId |
engine |
error | run échoué |
workflow, runId, error |
route above is normalized before logging (/api/runs/{id},
/api/records/{kind}/{id}) — no run or subject identifier appears as a route
label; the identifier itself is logged separately as runId where relevant.
Metrics
Instruments are created lazily on first use (metrics.ts, instruments()
cache) so that an instrument created before startOtel registers the global
MeterProvider never freezes as a permanent no-op; without startOtel
running at all, every recorder call is a zero-cost no-op (OTel API default).
All instruments share one meter: metrics.getMeter("meridian").
Instrument catalogue
| Name | Kind | Unit | Attributes | Recorder | Call site |
|---|---|---|---|---|---|
clinical.events.generated |
Counter | — | source, feed, event, outcome: accepted|duplicate|rejected |
recordEventGenerated |
apps/api/src/server/api.ts (startEventSources → emit) |
clinical.events.ingested |
Counter | — | outcome: accepted|duplicate|rejected |
recordEventIngested |
apps/api/src/server/api.ts (ingestEvent) |
clinical.runs.started |
Counter | — | workflow |
recordRunStarted |
apps/api/src/server/api.ts (opSendEvent) |
clinical.runs.executed |
Counter | — | workflow, status: completed|failed |
recordRunExecuted |
apps/api/src/restate/services.ts (run handler) |
clinical.validations.resolved |
Counter | — | decision |
recordValidationResolved |
apps/api/src/server/api.ts (opResolve) |
clinical.simulations |
Counter | — | workflow, status: completed|failed |
recordSimulation |
apps/api/src/server/api.ts (POST /api/simulate) |
clinical.nodes.executed |
Counter | — | node_type, kind, status: done|skipped |
recordNodeExecuted |
libs/engine-core/src/engine/interpreter.ts |
clinical.restate.ingress.duration |
Histogram | ms |
path |
recordIngressDuration |
apps/api/src/server/api.ts (ingress) |
clinical.http.server.duration |
Histogram | ms |
http.method, http.route, http.status_code |
recordHttpDuration |
apps/api/src/server/api.ts (server finish handler) |
clinical.nodes.duration |
Histogram | ms |
node_type, kind |
recordNodeDuration |
libs/engine-core/src/engine/interpreter.ts |
Durations are computed as performance.now() deltas in milliseconds, matching
the declared unit: "ms".
clinical.nodes.executed's status attribute type allows done or
skipped, but every current call site (libs/engine-core/src/engine/interpreter.ts,
standard node and map-body node execution) passes "done" only; skipped
nodes are recorded via the observation/log trace (node-skipped-after,
node-skipped-missing-input), not via this counter.
Human-task nodes still call recordNodeExecuted({ ..., status: "done" })
after resolution, but are not wrapped in a duration span or histogram (see
Span inventory).
Shared tracer re-export
metrics.ts re-exports tracer, withSpan, injectTraceContext, and
runWithRemoteContext from @meridian/shared/observability/trace.ts so that
proxy, engine, and plugin call sites can import them from
../observability/metrics.js without a second dependency.
Traces
Tracer and span helper
libs/shared/src/observability/trace.ts defines the tracer shared by every
process and plugin: trace.getTracer("meridian"). withSpan(name, attributes, fn) runs fn inside tracer.startActiveSpan(...): on throw it calls
span.recordException(e) and sets SpanStatusCode.ERROR, then always calls
span.end(); the active span becomes the implicit parent of any span created
inside fn.
Span inventory
| Span name | Attributes | Emitted by |
|---|---|---|
`${method} ${route}` (e.g. GET /api/runs) |
http.method, http.route |
apps/api/src/server/api.ts (wraps every request in createServer's handler) |
restate.ingress |
restate.path (normalized, e.g. /{key}/run/send) |
apps/api/src/server/api.ts (ingress) |
workflow.run |
workflow, event.type |
apps/api/src/restate/services.ts (run handler), wrapped by runWithRemoteContext(input.trace, …) |
node <id> |
node.id, node.type, node.kind |
libs/engine-core/src/engine/interpreter.ts (standard node execution) |
node <id>#<i> |
node.id, node.type, node.kind, map.id, map.iteration |
libs/engine-core/src/engine/interpreter.ts (map-body node execution, one span per iteration) |
Human-task nodes are not wrapped in a span: a human-task suspends the
Restate invocation, and a span left open across that suspension would either
span the entire wait or be recreated on replay; only the
clinical.nodes.executed counter is recorded for them.
Plugin adapters emit further child spans under the node span that calls them, using the same shared tracer, outside the files this document otherwise sources from:
| Span name | Attributes | Emitted by |
|---|---|---|
fhir GET / fhir POST / fhir PUT |
fhir.resource, http.request.method |
external-plugins/fhir/adapters/client.ts |
posos.graphql |
posos.query |
external-plugins/posos/lib/client.ts |
posos.autocomplete |
posos.query |
external-plugins/posos/lib/client.ts |
Trace propagation across the Restate boundary
injectTraceContext()(trace.ts): W3C carrier viapropagation.inject. Used twice for the same run, belt-and-suspenders: as HTTP headers on the ingressfetchcall, and as thetracefield of therun/sendpayload (apps/api/src/server/api.ts,opSendEvent) — the payload field is the one the engine actually consumes, since it is journaled by Restate.runWithRemoteContext(carrier, fn)(trace.ts):propagation.extract+context.with; empty/undefined carrier runsfnunder the current context unchanged. Used once, aroundworkflow.runinapps/api/src/restate/services.ts, so engine-side spans become children of the proxy's request trace.
See contributing/explanation/restate-integration.md for how the Restate invocation itself is structured.
OTel bootstrap (otel.ts)
Activation
startOtel(serviceName) is a no-op if OTEL_EXPORTER_OTLP_ENDPOINT is unset,
or if already called once (module-level sdk guard — idempotent).
Resource and exporters
| Setting | Source |
|---|---|
service.name |
OTEL_SERVICE_NAME ?? the serviceName argument passed by the entrypoint |
service.version |
npm_package_version ?? "0.0.0" |
| Trace exporter | OTLPTraceExporter() (OTLP/HTTP, no explicit endpoint argument) |
| Metric reader | PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }) |
| Log record processor | BatchLogRecordProcessor(new OTLPLogExporter()) |
All three exporters are constructed with no arguments; endpoint resolution
beyond OTEL_EXPORTER_OTLP_ENDPOINT follows the OTel JS SDK's own defaults.
Lifecycle
- On successful start, logs
export OTel démarré (traces + métriques + logs)with fieldendpoint(componentotel). - Registers
SIGINT/SIGTERMhandlers that callsdk.shutdown(). flushOtel()callssdk.shutdown()and clears the module-levelsdkreference — for short-lived scripts that need buffers flushed before exit.
Logs → OTel bridge (otel-logs.ts)
Activation condition
shipLogs in logger.ts: !pretty && Boolean(OTEL_EXPORTER_OTLP_ENDPOINT).
When true, makeOtelLogStream() is added as a second stream in pino's
multistream, alongside process.stdout (stdout is always written to,
regardless of shipping).
Mechanism
makeOtelLogStream() returns a Writable that, per pino JSON line:
Parses the line; on parse failure, silently drops it (never throws).
Maps the pino numeric
levelto an OTelSeverityNumber/text via the first matching threshold, highest first:pino level≥SeverityNumberseverityText60 FATALFATAL50 ERRORERROR40 WARNWARN30 INFOINFO20 DEBUGDEBUG0 TRACETRACE(defaults to level
30/INFOifrec.levelisn't a number)Builds OTel log
attributesfrom every JSON field except the pino-meta setlevel, time, msg, pid, hostname, v, trace_id, span_id, trace_flags; object/nullvalues areJSON.stringify-ed, everything else is passed through as string/number/boolean.If
rec.trace_idandrec.span_idare both strings, reconstructs aSpanContext(trace_flags??TraceFlags.SAMPLED,isRemote: false) and attaches it as the emitted record'scontext, so the log correlates to its trace even though the write happens outside the originating span's active context.Emits via
logs.getLogger("clinical").emit({ severityNumber, severityText, body: rec.msg ?? "", attributes, context?, timestamp: rec.time }). The OTel logger is resolved on every write (not cached), since the stream is constructed beforestartOtelruns and must not freeze a no-op logger.
HTTP probes (apps/api/src/server/api.ts)
| Route | Method | Purpose | Checks | Success | Failure |
|---|---|---|---|---|---|
/healthz |
GET | liveness | none (process is up) | 200 { ok: true } |
— |
/readyz |
GET | readiness | checks.restate: GET {RESTATE_URL}/restate/health resolves ok; checks.runStore: runStore.list(1) doesn't throw |
200 { ready: true, checks } when every check is true |
503 { ready: false, checks } otherwise, with a per-key boolean in checks |
Both checks run on every /readyz call (no caching); either failing
independently flips ready to false while leaving the other's boolean
observable in checks. The engine process (apps/api/src/restate/server.ts)
exposes neither route.
Related
- how-to/enable-observability.md — wiring steps, the LGTM stack, and probe configuration.
- reference/environment-variables.md — full variable catalogue.
- reference/topology.md — where the proxy and engine sit, and what probes them.
- reference/run-store.md — the run index checked by
/readyz'schecks.runStore. - explanation/why-durable-execution.md — why spans can be re-emitted across a suspend/resume replay.
- explanation/security-posture.md — what is and isn't allowed to reach logs and traces.
- contributing/explanation/restate-integration.md — the Restate invocation
workflow.runand its span sit inside. - contributing/explanation/interpreter-internals.md — node execution and map-body iteration that
node <id>/node <id>#<i>spans wrap.