Observability Model¶
In an event-driven, composable system a single request fans out across decoupled
functions, flows, and graph nodes that never call each other directly. Observability is
therefore not optional — it is the only way to see the causal path a request actually took.
mercury answers this with a built-in distributed-tracing engine whose trace and span ids
follow the W3C Trace Context / OpenTelemetry format, a real-time telemetry stream, and
a default-on log context that joins application logs to their spans. Everything on this
page is verified against this repository's source and the running hello-world example.
Two ids, two concerns¶
The platform keeps two identifiers deliberately separate:
business correlation-id- Identifies the transaction to the business. It is always ensured at the HTTP
edge, independent of tracing: captured from the caller's
X-Correlation-Idheader (configurable), or generated when absent. It rides the envelope'scidfield, is handed to the target function inside the request'smy_correlation_idheader, and — inside a traced execution — is available aspo.my_correlation_id(). trace id- Identifies the causal path to the platform: a 32-hex W3C-shaped id that drives the distributed tracing below. Trace ids exist only for traced requests.
Not every caller names its headers the way this framework does, so the header names are configuration — the platform matches the impedance at the edge while everything downstream works with the same two ids:
| Key | Default | Where it applies |
|---|---|---|
http.trace.id.header |
X-Trace-Id |
inbound (when no traceparent) and the async HTTP client outbound |
http.correlation.id.header |
X-Correlation-Id |
edge capture inbound; async HTTP client outbound |
http.traceparent.header |
traceparent |
The header carrying the full W3C trace context; REST automation inbound (standard traceparent first, custom name only when the standard is absent) and HTTP client / Event-over-HTTP outbound (stamped under both names) |
A rest.yaml endpoint entry may override all three per endpoint (trace.id.header,
correlation.id.header, traceparent.header); precedence is per-entry > global > built-in
default. Legacy conflation — pointing the trace-id and correlation-id names at one shared
header — is supported: the edge then resolves one id for both rather than minting two.
The standard W3C traceparent is our position — use it
It is the header OpenTelemetry and the wider observability ecosystem interoperate on,
and the framework implements it as the default with zero configuration. The optional
traceparent.header family exists for backward compatibility with legacy systems
only; departure from the standard is discouraged, because a renamed carrier is
invisible to OpenTelemetry SDKs, service meshes and APM agents, and every participant
must be configured alike. Treat a custom name as a temporary bridge and plan the
migration back to the standard header.
Within that constraint: a renamed traceparent beats conflation for the gateway case
The conflation above carries only the trace id, so spans in different applications
can be stitched by id but not parented across the hop. Renaming the traceparent
carrier (http.traceparent.header=X-Trace-Context) moves the full W3C context —
trace-id, parent span-id and flags — through the gateway under an allow-listed name, so
cross-application span parenting survives. Outbound calls stamp the same value under
both the custom and the standard name; inbound, the standard traceparent always
wins and the custom name is read only when the standard is absent — a well-formed
standard traceparent means the caller already speaks W3C/OTel, so a residual
proprietary header alongside it is safely ignored. The durable fix is always the
gateway allow-list — retire the custom name once it lands.
Rust port
The Java guide also documents kafka.trace.id.header / kafka.correlation.id.header /
kafka.traceparent.header (plus the secondary.kafka.* twins) for its Kafka flow
adapters. The Kafka service mesh is not ported, so those keys do not exist here — HTTP
is the only protocol boundary.
Turning tracing on¶
Tracing is opt-in per entry point:
- HTTP endpoints — set
tracing: trueon therest.yamlentry. The edge starts the trace, with the trace path set toMETHOD /path. - Programmatically — stamp an event with
set_trace(trace_id, trace_path)and every downstream hop is traced end to end (thehello-worldmain application does exactly this).
Two controls tune what is recorded: an RPC to a route listed in skip.rpc.tracing
(default async.http.request) produces no caller-side round_trip record — the HTTP
client's RPC leg folds into its caller — and the telemetry plumbing itself
(distributed.tracing and its forwarder routes) never traces its own executions. A
callback-mode execution of a listed route — the Event-over-HTTP stream relay's client
leg — still records its own span, parented onto the sender.
Edge-started traces and W3C traceparent¶
At a traced endpoint the edge resolves the trace in strict order:
- a well-formed
traceparentheader wins — the upstream trace continues, and the caller's span id becomes this hop's parent span (the standard name always wins; a customhttp.traceparent.headername is read only when the standard is absent); - otherwise the trace-id header (
X-Trace-Idby default) is adopted; - otherwise a new 32-hex trace id is generated.
Outbound, the async HTTP client injects traceparent (current trace id + current span)
alongside the configured trace-id and correlation-id headers on every call made inside a
trace — so a chain of mercury applications, or a mercury application behind any
OpenTelemetry-compliant caller, produces one continuous distributed trace.
The edge's round-trip span¶
A traced endpoint records one more span than its functions: the round trip itself.
REST automation mints a span id when the request arrives, makes it the first function's
parent, and emits its record when the response completes — the buffered response, the
end of a streamed response, an edge error or the edge timeout. The record's service is
http.request, the same marker the first function carries as from, so the vocabulary
stays one word: the request came from the edge; the edge's own span is the root.
pathisMETHOD /path,startis the receipt time andexec_timeis the whole round trip — for a streamed response, until the terminal is rendered.parent_span_idis the inboundtraceparentspan when the caller sent one; otherwise the record is the trace's root.statusis the HTTP status sent, except that a stream failing in-band after its head was committed reports the failure's own status and message.
An OpenTelemetry backend sees this record as the SERVER span of the request (see the forwarder), which is what makes a service's response time in a trace the real one — not the first function's own execution time.
Streamed responses are traced at their head and their tail, never per token. The
EventStreamWriter stamps the producer's trace and span on the first segment (it carries
the head control) and on the terminal (eof or exception); the data segments in between
carry no trace, because one span per token would flood a tracing backend. The reply lane
that renders the stream therefore records two spans, both parented onto the producer, and
annotates the terminal's record with frames — the number of data segments it rendered.
The Event-over-HTTP stream relay follows the same rule on the consuming side: decoded
envelope frames keep the remote producer's span, synthesized control frames parent onto the
relay's client leg (async.http.request, itself parented onto the sender), and raw token
frames are forwarded untraced.
A span per function execution¶
Every traced function execution mints its own 16-hex span id. The caller's span rides
the envelope's span_id field and becomes the callee's parent_span_id — producing a
causal span tree without any coupling between functions. In the hello-world example one
HTTP request yields a two-span tree: greeting.api (parented to the edge) →
greeting.demo (parented to greeting.api).
The layers above produce the same picture, because they ride the Layer-1 engine: every
Event Script task and every knowledge-graph node skill executes as a function invocation
and gets its own span in the same tree. Event Script adds one synthetic flow-summary
span (service task.executor) per flow, annotated with the flow id and a per-task
timing breakdown.
Rust port
Java threads the span context through a per-worker registry, deliberately avoiding
ThreadLocal/MDC (an anti-pattern on a virtual-thread runtime). The Rust analog is a
tokio task_local! scoped around the function invocation. The boundary is the
same in both engines: work you tokio::spawn from inside a function does not inherit
the trace context, just as Java's post-return Mono/Flux completions do not.
What a trace records¶
When a traced function finishes, its worker sends a performance-metrics dataset to the
built-in distributed.tracing service. The dataset carries a trace block and, when
present, your annotations:
id,span_id,parent_span_id- The W3C/OpenTelemetry-compatible ids (32-hex trace, 16-hex spans).
parent_span_idis omitted on a root span — absent keys are never rendered as null. service,from,path,origin- The executed route, the calling route, the trace path (e.g.
GET /api/greeting/eric), and the application instance id. start,exec_time- ISO-8601 UTC start time, and the function's execution time in milliseconds (rounded to 3 decimal places at the source, so every consumer reports the same value).
status,success,exception- HTTP-style status code, a boolean verdict, and — only on failure — the error message.
Exactly one record per span. For an RPC-served execution the worker suppresses
its own record when the reply reaches the caller, and the caller emits the span's single
record when the reply arrives — same shape, plus a round_trip metric (the full
request/response cycle in milliseconds; the reply envelope carries the same value) and
the callee's annotations, which ride the reply envelope. Its span lineage chains like
a worker-emitted record: parent_span_id is the caller's span, and span_id is the
callee's own span — adopted only from a direct responder (the reply's from equals
the requested route); a relayed reply (e.g. a flow answering on behalf of the
flow-adapter route) keeps the parent but omits span_id, because the relaying
function's span is reported by its own record. This works across an Event-over-HTTP hop
too, which is what stitches a remote function's record into the calling application's
span tree. Callback-style invocations (a reply_to that is a route, not an RPC
inbox — e.g. Event Script task dispatch) keep self-recording from the worker. For a route
listed in skip.rpc.tracing (default async.http.request) the caller emits no
round_trip record — the HTTP client's RPC leg folds into its caller (Java parity).
By default the dataset is logged — that is the real-time telemetry stream. This is a
real record from a hello-world run (log.format: json), emitted by distributed.tracing
for the greeting.demo span of one HTTP request:
{
"context": {
"environment": "dev",
"hello": "world",
"timestamp": "2026-07-20T01:33:54.763Z"
},
"level": "INFO",
"message": {
"annotations": {
"greeting.for": "eric"
},
"trace": {
"exec_time": 0.101,
"from": "greeting.api",
"id": "18529fd5dd4445ff8cd5bfe100f0ced3",
"origin": "26f1710d906d481d9ccb6226ee05e89f",
"parent_span_id": "8bb32a631ca4beef",
"path": "GET /api/greeting/eric",
"service": "greeting.demo",
"span_id": "bd75e3cb9cb462a1",
"start": "2026-07-20T01:33:54.763Z",
"status": 200,
"success": true
}
},
"source": "platform_core::telemetry(crates/platform-core/src/telemetry.rs:113)",
"time": "2026-07-20T01:33:54.763Z"
}
(The telemetry dataset embeds as a structured message object, not an escaped string;
JSON object keys render in alphabetical order.)
To ship the datasets elsewhere, register a function at the reserved route
distributed.trace.forwarder — the telemetry service detects it and forwards every
dataset to it. A companion hook, transaction.journal.recorder, receives
request/response journals when journaling is enabled (journals may contain PII — handle
per your organization's security policy).
The OpenTelemetry forwarder (ready-made)¶
You do not have to write the forwarder for OpenTelemetry. The
mercury-opentelemetry-forwarder crate (extensions/opentelemetry-forwarder, the twin of
the Java opentelemetry-forwarder extension) ships a distributed.trace.forwarder that maps
each dataset to an OpenTelemetry span — preserving the exact W3C trace, span and parent-span
ids — and exports it over OTLP/HTTP (binary protobuf) to a collector, and on to Dynatrace,
Splunk, Jaeger, Tempo, …. Add the dependency and one link line; no code:
// nothing in the application names the crate - this line is what links it
use opentelemetry_forwarder as _;
Note:
x.y.zdenotes the current Mercury version shown in the rootCargo.toml.
Configure it in application.yml (values support ${ENV_VAR:default} substitution):
# Master switch - DEFAULT OFF. Linking the crate registers nothing.
otel.forwarding: true
otel.exporter.otlp.endpoint: '${OTLP_API_ENDPOINT:http://localhost:4318/v1/traces}'
otel.service.name: '${OTLP_SERVICE_NAME:my-app}'
# Backend credentials come from the environment - no secret hard-coded:
otel.exporter.otlp.headers: '${OTLP_AUTH_HEADER} ${OTLP_TOKEN}'
Linking the crate does not turn forwarding on. The forwarder is gated by
#[optional_service("otel.forwarding")]with a default of false: linking the crate registers nothing, and the route exists only when otel.forwarding is true — in the environment's configuration, or-Dotel.forwarding=trueat launch with no rebuild. That separates two decisions that belong to different people: a developer adds the dependency, and DevOps decides per environment whether traces leave the process. Thehello-flowexample carries the crate with the switch off and pins that shape with a test.
Point the endpoint at an OpenTelemetry Collector, or directly at a SaaS backend with its API token in the headers. Backends differ in the header name and in whether the value carries an auth scheme, so keep the vendor-specific part and the bare secret in separate variables:
# Dynatrace
export OTLP_API_ENDPOINT="https://{env-id}.live.dynatrace.com/api/v2/otlp/v1/traces"
export OTLP_AUTH_HEADER="Authorization: Api-Token" # the header NAME and scheme - no token here
export OTLP_TOKEN="<your-api-token>"
# Splunk Observability Cloud
export OTLP_API_ENDPOINT="https://ingest.{realm}.signalfx.com/v2/trace/otlp"
export OTLP_AUTH_HEADER="X-SF-Token:"
export OTLP_TOKEN="<your-access-token>"
A caution on
OTEL_*variable names. Referencing the OpenTelemetry standard variables (OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINT, …) is convenient and works, but those names are often exported machine-wide on instrumented hosts and CI agents — so an application can silently inherit a service name or, worse, an endpoint that redirects its telemetry somewhere unintended. When that matters, reference your own prefixed variables instead;hello-flowusesOTLP_SERVICE_NAME/OTLP_API_ENDPOINT/OTLP_AUTH_HEADER/OTLP_TOKENfor exactly this reason, and the forwarder's own tests avoid${OTEL_*}references so a leaked endpoint cannot redirect a hermetic test.The credential is re-read on every export.
otel.exporter.otlp.headersis resolved per export, so a credential a bootstrap publishes after start-up as a runtime override (platform_core::overrides::set, the-D/System.setPropertyanalog) takes effect with no restart, and the log records a singleOTLP credential header resolvedline when it first appears.${ENV_VAR}references themselves are resolved when the configuration loads — the environment of a running process does not change underneath it.
Each span carries the route name (route), path, from, origin, status, timing, and your
annotation.* values as span attributes, with service.name on the resource and the
instrumentation scope mercury-opentelemetry-forwarder at the running version. A rejected
export is diagnosed on the forwarder's own warning line (the HTTP status, the backend's
explanation, and a hint for a missing signal path, a rejected credential or a token without
the ingest scope). Certified live against Dynatrace, with the A-B-A credential experiment that
proves the exports are real:
Test Report — OpenTelemetry forwarder against Dynatrace.
Full key reference: Configuration Reference.
Rust port
The Java module exports through the OpenTelemetry SDK; this crate writes the OTLP protobuf
itself and sends it through the platform's async.http.request client, so it adds no
dependency to an application. Three keys differ: otel.exporter.otlp.compression honours
only none (a gzip setting warns and exports uncompressed — one span per request),
otel.exporter.otlp.connect.timeout has no effect (the platform's
http.client.connection.timeout governs the connect phase), and the instrumentation scope
is named mercury-opentelemetry-forwarder so a backend can tell the engines apart. The
retry policy (5 attempts, 1 s growing by 1.5× on transport failures and 408/429/502/503/504)
and the failure diagnostics are the Java module's. A late credential arrives as a runtime
override rather than a system property. See the crate README for the full table.
The forwarder exports traces, not logs — deliberately. Application logs reach your backend through your platform's log forwarder, not through the engine; the
trace_id/span_idkeys of the log context are what join them to these spans.
A custom forwarder¶
To target a system without an OTLP path, implement your own function at
distributed.trace.forwarder and consume the dataset shown above — for example, write it to
a metrics database or a proprietary APM API.
Trace annotations vs. log context¶
Two distinct sinks accept business context, and neither leaks into the other. From the
hello-world greeting function:
let po = PostOffice::new(&Platform::get_instance());
// business context for the APPLICATION LOG stream (context block)
po.update_context("user", &input.user)?;
// business context for the DISTRIBUTED-TRACE dataset (span annotation)
po.annotate_trace("greeting.for", &input.user);
log::info!("processing greeting request");
annotate_trace(key, value) attaches data to the telemetry dataset (the
annotations block in the record above). update_context(key, value) attaches data to
the application log stream only — it appears in the context block of every
subsequent structured log line of the same request; a null value removes the key, the
reserved keys are rejected with an error, and outside a trace both calls are no-ops.
Log formats¶
The process logger has three output formats, selected by log.format in the application
configuration (default text) or at launch by the -D runtime override
(cargo run -p hello-world -- -Dlog.format=compact):
text- A plain console line —
time LEVEL [module] message. Like Java's plainConsoleappender, it is unaffected by the log context. json- Pretty-printed JSON, one record over multiple lines (the
log4j2-json.xmlanalog). compact- Single-line JSON records — jsonl, no CR/LF within a record (the
log4j2-compact.xmlanalog).
Both JSON forms emit time, level, source, message, and the context block. The
log level comes from log.level (default info); a RUST_LOG environment variable wins.
Rust port
Two deliberate simplifications over the log4j2 appenders: timestamps are always UTC,
and there is no thread id field. Format selection is a configuration key plus the
-Dlog.format= runtime argument (the JVM -D analog) instead of log4j2 XML files.
The application log context¶
Spans tell you the causal path; application logs tell you what happened inside each step.
The log-context feature closes the gap: every structured log line carries a context
block — correlation id, trace/span ids, service name, and your own key-values — so you
can pivot from a span in your backend straight to the log lines that belong to it.
The feature is on by default: platform-core ships a built-in
default-log-context.yaml (embedded at compile time) that emits the standard trace
context (cid, trace_id, trace_path, span_id, parent_span_id, service,
timestamp) on every structured log line — snake_case, matching the distributed-trace
block on the same record, so a collector maps one vocabulary. You can adjust it in two ways:
- Customize — provide your own
app-log-context.yamlon the resource path (resources/); it replaces the built-in template entirely. - Opt out — set
app.log.context: falsein the application configuration.
A custom template looks like this — from
examples/hello-world/resources/app-log-context.yaml, which extends the standard set
with two custom keys:
context:
cid: $cid
trace_id: $traceId
trace_path: $tracePath
span_id: $spanId
parent_span_id: $parentSpanId
service: $service
timestamp: $utc
environment: '${ENV_NAME:dev}'
hello: world
The left side is the output key (your choice). The right side is one of three forms:
| Form | Example | Resolved |
|---|---|---|
Reserved $token |
service: $service |
live, per log line, from the trace context |
${ENV:default} substitution |
environment: '${ENV_NAME:dev}' |
once at load, from the environment |
| Literal | hello: world |
emitted verbatim |
The reserved tokens are $cid, $traceId, $tracePath, $spanId, $parentSpanId,
$service (the current function's route), and $utc (the log line's UTC timestamp). The
token names stay camelCase; the output keys are yours — the default template writes them
in snake_case. A key that resolves to nothing is omitted, never printed as null — a root
span simply has no parent_span_id key. The context block always carries a UTC
timestamp: when your template maps $utc to no key, the engine adds it as timestamp
(as utc if timestamp is taken) — the record's top-level time is a local rendering,
and log-to-trace correlation resolves on a time window, so a line parsed in the wrong zone
would correlate and still be invisible on its trace. update_context refuses the reserved
names in both spellings (traceId and trace_id, …) with a 400, and a business key can
never shadow a template key — developer keys render first, the template wins. This is the log
line the greeting function emitted for the same request as the telemetry record above — same
trace_id and span_id, so the log line and the span join up in the backend, with the user
key added by update_context:
{
"context": {
"cid": "39ecf9f00aec4f4cb5dd6b4362cda0b2",
"environment": "dev",
"hello": "world",
"parent_span_id": "8bb32a631ca4beef",
"service": "greeting.demo",
"span_id": "bd75e3cb9cb462a1",
"timestamp": "2026-07-20T01:33:54.763Z",
"trace_id": "18529fd5dd4445ff8cd5bfe100f0ced3",
"trace_path": "GET /api/greeting/eric",
"user": "eric"
},
"level": "INFO",
"message": "processing greeting request",
"source": "hello_world(examples/hello-world/src/main.rs:96)",
"time": "2026-07-20T01:33:54.763Z"
}
Rust port
One deliberate divergence from the Java feature: an invalid $token is
advisory — reported and skipped — where Java throws at load.
Scope and boundaries¶
- The
contextblock appears only on lines emitted inside a traced function execution with a real request trace (Java parity: the log context registers per worker execution in lockstep with the trace bracket). Framework boot logs, telemetry records, and worktokio::spawned from inside a function carry no context block at all — constants never leak onto context-less lines. - Feature off (
app.log.context: false) costs one boolean check per log line. - The
textformat never renders a context block, whatever the configuration.
See also¶
- Architecture Overview — the three layers the span tree mirrors.
- Getting Started — run
hello-worldand watch these records live. - Function agent guide — the PostOffice API surface.
Adapted from the mercury-composable guide observability.md; behavior verified against this repository's source.