Documentation / Exploitation / Référence / Observability signals

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 (startEventSourcesemit)
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 via propagation.inject. Used twice for the same run, belt-and-suspenders: as HTTP headers on the ingress fetch call, and as the trace field of the run/send payload (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 runs fn under the current context unchanged. Used once, around workflow.run in apps/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 field endpoint (component otel).
  • Registers SIGINT/SIGTERM handlers that call sdk.shutdown().
  • flushOtel() calls sdk.shutdown() and clears the module-level sdk reference — 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:

  1. Parses the line; on parse failure, silently drops it (never throws).

  2. Maps the pino numeric level to an OTel SeverityNumber/text via the first matching threshold, highest first:

    pino level SeverityNumber severityText
    60 FATAL FATAL
    50 ERROR ERROR
    40 WARN WARN
    30 INFO INFO
    20 DEBUG DEBUG
    0 TRACE TRACE

    (defaults to level 30/INFO if rec.level isn't a number)

  3. Builds OTel log attributes from every JSON field except the pino-meta set level, time, msg, pid, hostname, v, trace_id, span_id, trace_flags; object/null values are JSON.stringify-ed, everything else is passed through as string/number/boolean.

  4. If rec.trace_id and rec.span_id are both strings, reconstructs a SpanContext (trace_flags ?? TraceFlags.SAMPLED, isRemote: false) and attaches it as the emitted record's context, so the log correlates to its trace even though the write happens outside the originating span's active context.

  5. 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 before startOtel runs 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.

75 documents23 sectionssource : /docs · généré au build