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/routes/health.ts. Metric-recording and span call
sites also live in libs/engine-core/src/engine/interpreter.ts,
apps/api/src/restate/services.ts and apps/api/src/restate/run-outcome.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, then install the
shutdown path (exitOnSignals, apps/api/src/shutdown.ts) described in
Shutdown.
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 eventType, eventId and the context ids of subjects only.
The contract messages of a declared event name the field and its expected
type, never the value received (libs/shared/src/engine/event-contract.ts).
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/http-server.ts |
sources |
proxy | apps/api/src/server/source-records.ts, apps/api/src/server/inbound-host.ts (log lines of the sources) |
runs |
proxy | apps/api/src/server/runs.ts |
assist |
proxy | apps/api/src/server/routes/assist.ts |
otel |
proxy & engine | apps/api/src/observability/otel.ts |
instance |
proxy & engine | apps/api/src/instance.ts, apps/api/src/restate/endpoint.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, apps/api/src/restate/run-outcome.ts, apps/api/src/restate/sdk-logger.ts |
restate-register |
engine | apps/api/src/restate/register.ts |
scheduler |
engine | apps/api/src/restate/scheduler.ts |
Log call sites (selected)
Message strings are reproduced verbatim (as emitted, untranslated).
| Component | Level | msg |
Fields |
|---|---|---|---|
| (root) | info | run démarré |
runId, workflow, subjects, eventType, sourceEventId, match, journalFormat |
| (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 |
| (root) | info | format de journal des runs démarrés : <n> |
journalFormat — the RUN_JOURNAL_FORMAT of the proxy, at boot |
| (root) | info | arrêt demandé |
signal — proxy and engine, on SIGINT/SIGTERM |
| (root) | info | API runtime (proxy) arrêtée / endpoint Restate arrêté |
— end of stop() |
| (root) | warn | fermeture en échec |
component, error — one close of the proxy's stop() failed (inbound, http, or a store: definitionStore, runStore, credentialStore, connectionStore, inboundStore, memoryStore); the other closes still run |
| (root) | error | arrêt en échec |
signal, error — stop() threw (for the proxy: arrêt incomplet : <n> fermeture(s) en échec); OpenTelemetry is still flushed, exit code 1 |
| (root) | error | arrêt interrompu : délai dépassé |
signal, timeoutMs — the shutdown exceeded SHUTDOWN_TIMEOUT_MS, exit code 1 |
| (root) | info | contrats d'événement : <mode> |
mode |
| (root) | error | « <file> » ne peut pas tourner : type(s) de nœud absent(s) du catalogue (<types>). Vérifier les plugins résolus. |
file, missing, plugins |
| (root) | error | « <file> » ne peut pas tourner : événement « <type> » non déclaré par les plugins chargés. Vérifier les plugins résolus. |
file, event, plugins |
| (root) | debug | enregistrement sans champ requis par le contrat, émis (observe) |
binding, event, missing — EAI binding |
| (root) | warn | règlement des runs impossible |
error — a settlement pass failed (run index unreadable); the next pass starts again |
| (root) | warn | clés sources et eventBindings du manifeste d'instance dépréciées : déclarer une connexion du type de la source sous connections, avec ses bindings. |
sources — at boot, when the instance manifest still sets sources or eventBindings (see Instance manifest) |
runs |
info | run arrêté dans Restate : statut figé en échec |
runId, workflow, failure (the invocation's failure, killed) — see Run settlement |
runs |
warn | run en échec dans Restate : statut figé en échec |
runId, workflow, code, failure |
runs |
warn | run absent de Restate : statut figé en échec |
runId, workflow |
runs |
warn | <n> run(s) d'un autre Restate ou sans Restate d'origine, non réglé(s) |
count, restate — once per settlement pass |
runs |
debug | statut terminal figé |
runId, workflow, status, source (read or sweep) |
runs |
debug | demande de validation retirée |
runId, workflow, kind — see Run settlement |
runs |
warn | retrait de la demande de validation impossible : run non figé, repris au règlement suivant |
runId, workflow, kind, error |
sources |
info | the source's own messages (see Event source fhir-poll) |
connection — the identifier of the pulled inbound connection |
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 | résolution non appliquée |
source, feed, runId, error |
sources |
warn | résolution indisponible, nouvelle tentative automatique |
source, feed, runId, error — the ingress answered a 5xx when reading the run; the reception delivers the record again |
sources |
warn | livraison indisponible, nouvelle tentative automatique |
source, feed, recordId, error; eventId, failed when a run could not be submitted — the reception delivers the record again |
sources |
debug | enregistrement filtré (champs exigés vides) / enregistrement sans champ requis par le contrat, émis (observe) |
source, feed, recordId, missing |
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 |
instance |
warn | clé inconnue du manifeste d'instance, ignorée |
path (the instance manifest file), key (the key's path, such as ports.patient.confg) — a key the instance manifest's schema does not know is dropped; the boot goes on |
registry |
info | plugin requis : chemin local (surcharge) |
plugin, dir — a path: entry of the instance manifest |
registry |
info | plugin requis : copie locale |
plugin, version, range, dir — a plugins: entry also found locally, in the instance range: the local copy loads, the registry is not consulted for it |
registry |
warn | « <plugin> » : copie locale <version> chargée, hors de la plage de l'instance « <range> ». Retirer la copie locale ou modifier la plage. |
plugin, version, range, dir — the same, outside the instance range; the local copy still loads and its dependents are resolved against it |
registry |
warn | « <plugin> » : copie locale <version> prioritaire sur le registre, refusée au chargement. Retirer ou corriger la copie locale. |
plugin, version, range, dir — the same, for a local copy the host refuses at load (contract, dependsOn entry) |
registry |
info | plugin téléchargé du registre / plugin présent en cache |
plugin, version, downloaded, dir — the version selected for a plugins: entry resolved on the registry |
registry |
warn | plugin requis : versions plus hautes écartées |
plugin, version (the version selected; absent when the resolution fails, the warning then comes before the boot error), skipped (each version of the instance range set aside and what rules it out, such as 0.2.3 : « @posos/clinical » 0.2.4 hors de « ^0.3.0 », 0.2.3 : « @posos/geriatrie » 0.1.1 (requis) exige « ^0.2.0 », 0.2.3 : « @l/future » 1.0.0 (refusé) hors de « ^2.0.0 », 0.2.3 : « @posos/demo » se cite dans dependsOn, 0.2.3 : contrat plugin 0.1.6 hors de la plage sdk « ^0.2.0 », 0.2.3 : plage « latest » de « @posos/clinical » invalide, or 0.2.3 : manifeste illisible) — a version whose dependsOn does not accept a local plugin, a local plugin the host refuses (« (refusé) ») or the version chosen for another required plugin, that the dependsOn of a loaded local plugin or of a chosen required plugin does not accept, that cites itself, or whose sdk range does not admit the host's plugin contract, is set aside for the next one; « (requis) » marks a version the resolver chose, not a loaded plugin. The ranges on the version number are checked first: a version they set aside is not read, and its manifest's reasons are listed only when it was read |
assist |
warn | assistant : condensé du catalogue au-delà du budget, envoyé tel quel. Raccourcir les descriptions des plugins chargés ou relever DIGEST_MAX_CHARS. |
chars, maxChars |
The engine messages that end a run's attempt are listed in Run outcome.
route above is normalized before logging (normalizeRoute,
apps/api/src/server/http-server.ts): /api/runs/{id}, /api/internal/runs/{id}/progress,
/api/resume/{runId}/{nodeId}, /api/agents/{id}/runs/{runId}, /api/agents/{id}/invoke,
/api/agents/{id}/schema, /api/records/{kind}/{id}, /api/memory/{kind}/{id},
/api/hooks/{id}, /api/credentials/{id}, /api/connections/{id}, /api/ingress/{id} — no run or
subject identifier appears as a route label; the identifier itself is logged
separately as runId where relevant. The same label is the http.route of the
HTTP span and of meridian.http.server.duration.
The two ne peut pas tourner errors are logged each time the proxy loads the workflows,
for each active workflow that cannot run: a node type is missing from the catalogue, or
the event of its trigger is declared by no loaded plugin (Événement reçu configured on
an event whose plugin is gone or that was renamed). plugins lists the loaded plugins;
the workflow's new runs fail. Any other validation error of an active workflow is logged
at warn (« <file> » invalide (<n> erreur(s)) — cf. GET /api/workflows.).
sourceEventId is the id of the event as received; runId is <eventId>:<slug>
for a run started by a domain event. match is the reason the workflow received
the event: selector, unfiltered, targeted or pinned.
Event source fhir-poll
Messages of the fhir-poll source of @posos/fhir
(external-plugins/fhir/sources/poll.ts), logged by the sources component at
info with the field connection (the identifier of the pulled inbound connection,
such as fhir-poll). <instant> is the cursor of the feed:
the time of its last processed record, or, before any record is processed, the
initial cursor (start time minus startOverlapSeconds, or the epoch for a
since: epoch feed).
msg |
Meaning |
|---|---|
polling <baseUrl> toutes les <n>s, <count> ressources par page, <maxPages> pages au plus par passage, recouvrement au démarrage <n>s — feeds: <feeds> |
The source started with these connection options. |
<feed>: <n> <resource>(s) / <feed>: <n> <resource>(s) sur <k> pages |
A poll emitted n records, over k pages when more than one. |
<feed>: limite de <maxPages> pages atteinte, suite au prochain passage (curseur <instant>) |
The poll read maxPages pages; the next poll starts right away from the page where it stopped. |
<feed>: lien de page suivante hors de la baseUrl (<origin>), suite au prochain passage (curseur <instant>) |
The next link of the result maps onto neither the base the server advertises nor baseUrl: it is not followed, and the next poll searches again from the cursor. |
<feed>: lien de page suivante hors de la baseUrl (<origin>) et page déjà traitée, le feed n'avance pas (curseur <instant>). Corriger la base annoncée par le serveur ou relever count. |
Same, and the first page held only records already processed: the feed does not advance until the advertised base is fixed or count exceeds the records of the feed that share one second. |
<feed>: lastUpdated non croissant dans la réponse, curseur ramené à <instant>. Vérifier l'horloge du serveur FHIR. |
A lastUpdated older than the previous one (the server's clock stepped back, or the result is not sorted): the record is emitted and the cursor moves back to the oldest time read since. |
<feed> échoué, reprise au prochain passage (curseur <instant>) : <error> |
A page could not be read (server unreachable, HTTP error, délai de <n> s dépassé past requestTimeoutSeconds) or an emission was rejected (record not stored in the reception inbox): the poll of the feed stops, the other feeds are polled, and the next poll of the feed starts again from the cursor. |
<feed> échoué : <error> |
The poll of the feed failed before reading a page, for instance because its fhir-r4 connection does not resolve (Aucune connexion par défaut pour le type « fhir-r4 ». …, Connexion « <id> » introuvable.); the next poll tries again. |
Event ingestion
Logged by the proxy's root logger (apps/api/src/server/ingest.ts) while it
routes an event to the workflows that receive it.
| Level | msg |
Fields | Meaning |
|---|---|---|---|
| warn | événement non conforme à son contrat |
eventType, eventId, issues (number of issues), mode |
An event of a declared type does not conform to its contract (EVENT_CONTRACTS observe or enforce); under enforce it is refused. The issue messages are in the recent events, never in the log. |
| warn | Filtre en erreur — « <file> » ne reçoit pas l'événement <type>. |
file, eventType, eventId, error |
The workflow's trigger filter failed on the event (an empty filter included); that workflow does not start, the others are not affected. |
| error | livraison non soumise à Restate |
eventId, eventType, file, runId, error |
One run could not be submitted; the other deliveries were tried. POST /api/events answers 503; a message of an inbound connection, the record of a source included, is delivered again. |
| debug | livraison écartée : workflow publié après la première réception de l'événement |
eventId, eventType, file, publishedAt |
A redelivered event does not reach a workflow published after its first reception. |
| warn | événement désigné sur le BROUILLON de « <slug> » — run lancé hors de toute version publiée |
slug, eventType, eventId |
An event designated a draft (__workflowFile with __allowDraft). |
| warn | état du run illisible, vue tirée de l'index |
eventId, runId, error |
A duplicate's live state could not be read; the response uses the run index. |
| info | Événement <type> : <n> workflows démarrent (<files>). followed by Effets du même type : … when they share sink or human-task node types |
eventType, files, sharedEffectTypes |
Logged each time the proxy loads the workflows, once per event type received by at least two published workflows (logSharedEvents, apps/api/src/server/active-definitions.ts). |
Run outcome
Logged by the engine component at the end of each attempt of the run handler
(apps/api/src/restate/run-outcome.ts, called by apps/api/src/restate/services.ts),
together with meridian.runs.executed, and by the endpoint's SDK logger transport
(apps/api/src/restate/sdk-logger.ts) for a replay that diverges without an error.
| Level | msg |
Fields | Meaning |
|---|---|---|---|
| info | run terminé |
workflow, runId |
The run completed. Counted completed. |
| error | run échoué |
workflow, runId, error |
The run failed. Counted failed. The node that failed it, the trigger included, carries the error in the run's observations (cause: "deployment" for a missing plugin). |
| warn | rejeu interrompu : non-concordance avec le journal du run. Restate retente l'invocation, le run garde son dernier état. Rétablir les versions de l'endpoint et des plugins qui ont démarré le run, ou tuer l'invocation (kill). |
workflow, runId, invocationId, error; command for a replay that diverges without an error |
The replay of a run in flight no longer matches its journal. Logged once per invocation and per endpoint process, whatever the number of attempts. Not counted. |
| debug | rejeu interrompu, nouvelle tentative |
workflow, runId, invocationId; error when the replay throws |
A later attempt of an invocation already reported by this process, or an attempt whose replay diverged and whose handler still reached its end. Not counted. |
| debug | run échoué (échec journalisé par une tentative précédente) |
workflow, runId, error |
The replay reached a failed status written by an earlier attempt that did not end; the run ends failed now. Counted by that earlier attempt only. |
| debug | run terminé (fin journalisée par une tentative précédente) |
workflow, runId |
The replay reached a completed status written by an earlier attempt that did not end; the run ends now. Counted by that earlier attempt only. |
Restate replays the journal of a run in flight at each resumption: a webhook
resume, a decided validation, a restart of the endpoint. When the endpoint no
longer runs the code or the plugins that wrote the journal (the plugin that
declares the event of the trigger unloaded, a node type gone), the replay throws
before the end of the journal. The write of the failed status that follows does
not match the journal, so the SDK abandons the attempt (Restate error RT0016,
Journal mismatch) and Restate retries the invocation with backoff. Nothing is
persisted: the run keeps its last status (waiting, suspended…) in the run
index and on its page, and goes on at the first attempt that matches the journal
again. The endpoint tells this case from a failure of the run by whether the
attempt was still replaying the journal when the error was raised
(isProcessing() of the SDK's restate.internal.ContextInternal, read before the
status is written). error is then the error that stopped the replay.
A replay can also diverge without an error: for a run of journal format 1, a plugin
version that projects the trigger's event differently, or that changes the title of a
task the run waits on, writes a different state where the journal holds another (a run
of format 2 reads them from its journal, see
Journal formats). The SDK abandons the
attempt with the same RT0016 error. The endpoint's logger transport recognizes the
SDK's error (a RestateError of code 570 on the run invocation of
meridianWorkflow) and logs the same warning, with error = the first line of the
SDK's message ((570) Found a mismatch between the code paths taken during the previous execution and the paths taken during this execution.) and command = the
journal command that differs (set state [1]). The compared values, which carry the
run's state, are not logged. The end of an attempt and the transport share the
registry of the reported invocations: an invocation is reported once, whichever of
the two sees it first, and stays in the registry (bounded at 1000 invocations).
After the mismatch the handler can still reach its end: the SDK serves the durable
operations that follow as pending, and a bounded wait, such as a validation with a
deadline, expires at once. That end is not the run's: the attempt is abandoned and
Restate retries it. The transport marks the attempt, and the end of an attempt that
is marked, or after which isProcessing() stays false, is neither logged
run terminé nor counted.
To unblock such a run, restore the endpoint and plugin versions that started it.
To end it, kill the invocation invocationId in Restate: a cancel is not processed
while the replay fails, since Restate delivers it to the next attempt, whose replay
fails before reading it; only a kill ends the invocation. Restate lists the
invocation as backing-off, with RT0016 as its last failure, and the SDK writes
its own [restate] lines (Error when processing a Restate context operation.,
then RestateError: (570) Found a mismatch between the code paths…) to the
endpoint's standard error at each attempt. See
Recover runs blocked by a missing plugin.
A record port that fails at the publication or the closing of a validation request
does not change the run: the endpoint writes publication de la demande de validation échouée : <error> or clôture de la demande de validation échouée : <kind> : <error> to
the console as a Restate SDK line, at warn, once (the steps are journaled). The
closing exists for the runs of journal format 2.
When the settlement withdraws the request of a killed run and the record port keeps
failing, the record service retries for up to one hour, then logs clôture de la demande de validation abandonnée après les relances : la clore dans le dossier
(component engine, warn, with runId, pendingId, kind and error) and the
invocation fails.
The transport writes every line of the SDK to the console as the SDK's default
transport does, which the SDK does not export: nothing while replaying, nothing
below RESTATE_LOGGING (info by default), console.info and console.debug
(standard output) for info and debug, the standard error for warn, error
and trace, each prefixed [restate][<time>][<target>][<invocationId>].
Run settlement
Logged by the runs component of the proxy when it freezes the terminal status of
a run whose invocation ended in Restate without a terminal snapshot
(apps/api/src/server/runs.ts, run-settlement.ts; see
Run index — Final status), together with
meridian.runs.settled.
| Level | msg |
Fields | Meaning |
|---|---|---|---|
| info | run arrêté dans Restate : statut figé en échec |
runId, workflow, failure |
The invocation was killed (/output answers 409 killed): the run is frozen failed with the error « Invocation arrêtée dans Restate (killed). », its last state archived. Counted ending: killed. |
| warn | run en échec dans Restate : statut figé en échec |
runId, workflow, code, failure |
The invocation ended in another failure returned by Restate (/output answers a failure whose code equals the status, not 409): the run is frozen failed with the error « Invocation en échec dans Restate ( : |
| warn | run absent de Restate : statut figé en échec |
runId, workflow |
The Restate of the run (its restate_url) no longer knows the invocation (/output answers 404, getRun the empty placeholder): the proxy was down for more than a day after the run ended, the invocation was purged, or the Restate volume was lost. The run is frozen failed with the error « Invocation absente de Restate : issue du run inconnue. », without its state (stateLost). Counted ending: lost. |
| warn | <n> run(s) d'un autre Restate ou sans Restate d'origine, non réglé(s) |
count, restate |
Once per pass: live runs that the proxy's Restate does not know (404) and whose restate_url is another URL, or none (runs started before the column). They are left as they are. A lasting count means a wrong RESTATE_URL, an index shared with another Restate, or old rows to check (see Upgrade the platform). |
| debug | statut terminal figé |
runId, workflow, status, source |
A completed or failed status frozen by a read or by the settlement pass. |
| debug | demande de validation retirée |
runId, workflow, kind |
The invocation of the run was killed or ended in failure while the run waited on a validation: before freezing it, the proxy sent the withdrawal of its request to the endpoint's record service (closeValidationRequest, outcome withdrawn, one way, idempotency key withdraw:<runId>:<pendingId>:<kind>), for the subject of that context kind. |
| warn | retrait de la demande de validation impossible : run non figé, repris au règlement suivant |
runId, workflow, kind, error |
The ingress refused that call, or could not be reached. The run is not frozen; the next pass sends the withdrawal again (see Recover runs blocked by a missing plugin). |
| warn | règlement des runs impossible |
error |
A settlement pass failed (root logger); the next pass starts again. |
Plugin load warnings
Logged by the plugins component when the API loads the plugins
(apps/api/src/plugins.ts), in both processes. The load problems share the fields
plugin, kind, name (event type, node id, binding name, module path for module,
key path for key, dependency name for dependency, or plugin name for contract and
sdk) and issue (the verbatim problem, ending with the fix). They are
logged at warn, except the filter note, logged at info, and appended to
LoadedPlugins.issues:
msg |
kind |
Effect |
|---|---|---|
contrat plugin incompatible — plugin ignoré |
contract |
The plugin's sdk range does not accept the host's plugin contract; the plugin is not loaded and its pinned triggers reserve no address. |
dépendance signalée |
dependency |
A dependsOn entry of the plugin, named by name. The version of the dependency found, whether that copy loads or is refused, is outside the range, the range is invalid, or the entry names the plugin itself: the plugin is not loaded, as for contract. Or the dependency is not found, or was refused with a version in the range: the plugin loads, and its contributions that cite the dependency are dropped with their own warnings (« inconnu dans la fermeture dependsOn »). A dependency's warnings come before those of the plugins that cite it. |
événement signalé |
event |
The declared event is dropped: a type already declared (earlier in the load or by another plugin, reported alone), an invalid declaration, or a type equal to the address of a pinned trigger. Or its invalid default filter is removed and the event stays declared; the message says which. |
exemple d'événement retiré |
example |
The event stays declared; its non-conforming example is removed from the registry. |
déclencheur signalé |
trigger |
A trigger is dropped, by the projection of its contribution (an event trigger that cannot be derived from its event, a trigger without pinnedRouting and without event, a pinned trigger without eventType, receive on a trigger that is not pinned, a stray from) or by the host's own node checks (an unknown type or context kind, an invalid config schema). Or it stays loaded and is reported: a defaultSelector on it is ignored, a host transport (http, directory) in its receive.transports is ignored, or another plugin declares its address as an event, which is refused. |
nœud signalé |
node |
A node that is not a trigger is dropped, by the projection of its contribution (a trigger key or receive on it, a stray from) or by the host's own node checks. Or a defaultSelector on it is ignored and the node stays loaded. |
filtre par défaut hors contrat |
filter |
A declared event's defaultSelector reads a field outside the event's contract. The event and its filter stay loaded. |
binding hors contrat |
binding |
An event binding's map or require, or a message binding's require, does not match the declared event's contract. The binding stays active. |
plage sdk insuffisante |
sdk |
The plugin uses declared events with an sdk range below ^0.1.1, declares an event default filter with a range below ^0.1.3, describes the reception of a protocol (media types, example, a transport's default port or hint) with a range below ^0.1.4, declares a conditional port rule with a range below ^0.1.5, contributes or requires connections with a range below ^0.1.6, requires a node connection by port or declares a connection type's urlSchemes or exposeCredentials with a range below ^0.1.7, declares the outbound connection of an inbound connection type with a range below ^0.1.8, or has no range. One problem, for the highest floor. The plugin loads. |
module non chargé |
module |
A module the manifest references does not import: a file missing from dist/, a syntax error, a dependency that does not resolve, an error thrown while the module evaluates. issue gives the cause and the contribution ignored; the rest of the plugin loads. A node whose behavior module does not import is dropped. A node whose assist or receive module does not import stays loaded, without its configuration assistance or its reception protocol. An adapter, event source, message binding or port sandbox is not loaded. |
clé ignorée |
key |
The manifest carries a key its schema does not know, at the path name (contributes.nodes[3].config[0].option): a typo, a key of a newer plugin contract, or the rest of a value cut by an unquoted comma in a { … } mapping (issue then names the cut key). Or an agent's input/output shorthand is written next to its list or without a type name (issue says which). The key is dropped; the plugin and its contributions load without it. Reported for every plugin whose contract the host accepts. |
The Plugins page marks dropped nodes, receptions, bindings and events ignored at
load. An adapter, event source or port sandbox left out by module non chargé, and
the warnings that drop nothing, appear only here (see
Inspect loaded plugins). The plugins of the
repository load without any of these warnings or notes
(apps/api/tests/unit/declared-events-load.test.ts).
Other load messages:
| Level | msg |
Fields | Effect |
|---|---|---|---|
| warn | binding ignoré : événement « <type> » non déclaré par les plugins chargés. Charger le plugin qui le déclare. |
plugin, binding, event, plus source for an event binding |
No loaded plugin declares the binding's event type and no trigger claims it (every declared event is claimed by Événement reçu): an event it emits would be refused. The binding is dropped; an event binding's feed never starts. |
| warn | binding ignoré : « <type> » est l'adresse du déclencheur épinglé « <trigger> ». Viser un événement déclaré. |
plugin, binding, event, plus source for an event binding |
The binding's event type is the address of a trigger with its own address (pinnedRouting), which an event reaches only designated (__workflowFile); a binding emits without designation, so an event it emits would be refused. The binding is dropped; an event binding's feed never starts. |
| warn | manifeste invalide |
dir, error |
The plugin.yaml does not parse under the host's schema: a malformed value, or a form of a later contract that this schema does not read (a transport written as an object, for a host on contract 0.1.3). |
| warn | plugin en double — copie ignorée |
plugin, version, dir (the copy ignored), kept (the directory of the copy loaded) |
A plugin of the same name was found earlier in the roots; only the first copy is checked and loaded, and the registry resolution sees that copy only. |
| warn | chargement échoué |
plugin, error |
A registry refused a name already registered, by another plugin or earlier in the same manifest (value set, context kind, type or node id). The plugin is left out of the loaded plugins, of the Plugins page and of the i18n bundles; the contributions applied before the refusal stay registered. |
| info | plugins chargés |
plugins |
The name@version of each loaded plugin. |
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"), and their names
carry the meridian. prefix.
Instrument catalogue
| Name | Kind | Unit | Attributes | Recorder | Call site |
|---|---|---|---|---|---|
meridian.events.generated |
Counter | — | source (inbound for a message binding, else the source mechanism), feed, event, outcome: accepted|duplicate|filtered|rejected|unavailable |
recordEventGenerated |
apps/api/src/server/source-records.ts (records of pulled inbound connections), apps/api/src/server/inbound-host.ts (message bindings) |
meridian.events.ingested |
Counter | — | outcome: accepted|duplicate|filtered|rejected|unavailable — once per event, whatever the number of workflows that receive it |
recordEventIngested |
apps/api/src/server/ingest.ts |
meridian.events.deliveries |
Histogram | — | eventType |
recordEventDeliveries |
apps/api/src/server/ingest.ts — runs created per accepted event |
meridian.events.contract |
Counter | — | event, outcome: valid|invalid, mode: observe|enforce |
recordEventContract |
apps/api/src/server/ingest.ts — once per event of a declared type checked against its contract (EVENT_CONTRACTS not off) |
meridian.events.contract_missing |
Counter | — | source (inbound for a message binding, else the source mechanism), feed, event, mode: observe|enforce |
recordContractMissing |
apps/api/src/server/source-records.ts, apps/api/src/server/inbound-host.ts (message bindings) — a projected record that the binding's declared require keeps, without a field that only the contract requires: filtered in enforce, emitted in observe |
meridian.selector.errors |
Counter | — | workflow |
recordSelectorError |
apps/api/src/server/ingest.ts, ingestion.ts (EAI delivery) — a trigger filter that failed to evaluate |
meridian.runs.started |
Counter | — | workflow |
recordRunStarted |
apps/api/src/server/ingestion.ts (startRun) — once per run created |
meridian.runs.executed |
Counter | — | workflow, status: completed|failed |
recordRunExecuted |
apps/api/src/restate/run-outcome.ts (end of an attempt of the run handler of services.ts) — by the attempt that completes or fails the run; an attempt interrupted by a journal mismatch is not counted (see Run outcome) |
meridian.runs.settled |
Counter | — | workflow, ending: completed|failed|killed|errored|lost, source: read|sweep |
recordRunSettled |
apps/api/src/server/runs.ts (freeze) — once per run whose terminal status the proxy freezes in the run index, whatever the number of replicas (see Run settlement) |
meridian.validations.resolved |
Counter | — | decision |
recordValidationResolved |
apps/api/src/server/runs.ts (opResolve) |
meridian.simulations |
Counter | — | workflow, status: completed|failed |
recordSimulation |
apps/api/src/server/routes/simulate.ts (POST /api/simulate) |
meridian.nodes.executed |
Counter | — | node_type, kind, status: done|skipped |
recordNodeExecuted |
libs/engine-core/src/engine/interpreter.ts |
meridian.restate.ingress.duration |
Histogram | ms |
path |
recordIngressDuration |
apps/api/src/server/ingress.ts (createIngressClient) |
meridian.http.server.duration |
Histogram | ms |
http.method, http.route, http.status_code |
recordHttpDuration |
apps/api/src/server/http-server.ts (instrumentedListener, finish handler) |
meridian.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".
Contract outcomes: valid — no issue; invalid — at least one issue, and the event
was refused (mode: enforce, also counted rejected in meridian.events.ingested) or
let through (mode: observe). An event of a type without a contract is not counted, nor any
event when EVENT_CONTRACTS=off. The warning log carries the number of issues, never
the event's content; the issue messages are in the recent events
(GET /api/events/recent, contract).
Settled endings: completed and failed — the status the run wrote at its end, or the
result its run handler returned; killed — the invocation was killed (409) without
a terminal snapshot, frozen failed; errored — the invocation ended in another
failure returned by Restate, frozen failed; lost — the Restate of the run no longer
knows the invocation, frozen failed. Sources: read — the runs list, a run's detail,
the settling of an ingestion, of a decision or of a resume; sweep — the settlement
pass (RUN_SETTLE_INTERVAL_MS). workflow is the run's workflow name, as for
meridian.runs.started. A lasting lost rate means the proxy freezes runs whose trace
is gone; a completed or failed rate from sweep counts the runs that ended while
nobody read them.
Ingestion outcomes: accepted — at least one run created; duplicate — every
workflow that receives the event already had it; filtered — workflows receive
the event type but no trigger filter keeps the event; rejected — refused
(400); unavailable — at least one run could not be submitted to Restate
(503, the other deliveries were tried).
meridian.events.generated counts one outcome per binding at each delivery attempt of
a message or of a source record, with the same values. unavailable: a run could not be
submitted to Restate; the reception delivers the message or record again, and the retry
starts only the missing runs. For a source record, rejected also counts a projection
that failed and a record whose required fields are empty; source is the name of the
source (fhir-poll), not of the connection.
meridian.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/http-server.ts (instrumentedListener, wraps every request) |
restate.ingress |
restate.path (normalized by ingressOpPath, the run id replaced by {key} for getRun, run/send, resolveValidation, resumeSignal and the output of an invocation, e.g. /meridianWorkflow/{key}/run/send, /restate/workflow/meridianWorkflow/{key}/output) |
apps/api/src/server/ingress.ts (createIngressClient, POST call and GET get); each request bounded by 30 s (INGRESS_TIMEOUT_MS) |
runs.settle |
runs.examined, runs.settled, runs.unowned (set at the end of the pass) |
apps/api/src/server/runs.ts (settleLive): one per settlement pass, the parent of the pass's restate.ingress spans |
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
meridian.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/ingestion.ts,startRun) — the payload field is the one the engine actually consumes, since it is journaled by Restate. An event received by several workflows starts several runs: the proxy's trace holds oneworkflow.runspan per run.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 no signal handler: each entry point flushes through
flushOtel()after itsstop()(see Shutdown). flushOtel()callssdk.shutdown()and clears the module-levelsdkreference; a call during a flush in progress awaits that flush.
Shutdown
On SIGINT/SIGTERM, each entry point (apps/api/src/server/api.ts,
apps/api/src/restate/server.ts) runs one sequence (exitOnSignals,
apps/api/src/shutdown.ts): log arrêt demandé, the application's stop(),
flushOtel(), then exit with code 0.
- Proxy
stop(): memory sweep timer, run settlement timer, schedule campaign (its wait between two attempts is interrupted), then, together, the inbound reception (pulled sources included) and a settlement pass in progress (it stops between two runs, its ingress request interrupted), HTTP listener, then the six stores. A close that fails is loggedfermeture en échecwith itscomponent; the other closes still run, thenstop()throws anAggregateError. - Endpoint
stop(): agent refresh timer and the registration retries. The HTTP/2 listener is the Restate SDK's (listen()), which has no close: it closes when the process exits. An invocation cut by the exit is retried by Restate, which replays it from its journal on the next endpoint.
A stop() that throws is logged arrêt en échec and the process exits with code 1
after the flush. The whole sequence is bounded by SHUTDOWN_TIMEOUT_MS (default
10 000): past it, arrêt interrompu : délai dépassé and exit code 1 — an OTLP
collector that accepts connections without answering cannot keep the process, or
its port, alive.
A signal received during boot:
- before
start()is called (configuration, instance and plugin loading, store opening): the process exits right away after the OpenTelemetry flush, with code 0; - once
start()runs: the stop waits forstart()to settle, then follows the same sequence. Astart()that fails first stops what it had started (timers, schedule campaign, inbound reception, listener).
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("meridian").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/routes/health.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({ limit: 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.