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/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 ( : ). ». Counted ending: errored.
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 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/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 one workflow.run span per run.
  • 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 no signal handler: each entry point flushes through flushOtel() after its stop() (see Shutdown).
  • flushOtel() calls sdk.shutdown() and clears the module-level sdk reference; 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 logged fermeture en échec with its component; the other closes still run, then stop() throws an AggregateError.
  • 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 for start() to settle, then follows the same sequence. A start() 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:

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

97 documents29 sectionssource : /docs · généré au build