Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@
the engines' agent loop (RFC-0011) live in the language packs and not in the engines. Nothing starts one yet: the stdio
MCP binding is a later step. The README's Scope also says the LLM helper is an application on the package, whose
`llm.chat` is a stateless chat or a loop turn with a configured transcript store. **Upgrade note:** none.
4. **Tracing: the host leaves the records a Java or Rust callee leaves, so a span lineage is continuous from an engine
into the pack.** A live drive of the engines' agent loop through both packs (2026-11-14) found the pack legs absent
from the trace: the calling engine's HTTP client leg is in `skip.rpc.tracing` by default and the host suppressed its
own record for every RPC-served execution, so `llm.chat` and an MCP binding's route appeared only as time inside the
engine caller's span. Now a traced call arriving at `/api/event` leaves `http.request` (`POST /api/event`, the edge's
round trip, parented onto the caller's span - the envelope's span, else the `traceparent` header's; the backend's
SERVER span), `event.api.service` (the ingress, parented onto the edge) and, for an RPC, the function's round-trip
record with the responder's span and `round_trip`, parented onto the service - the records REST automation,
`EventApiService` and `InboxBase` leave in the Java engine; a drop-n-forget or streaming function's own record
parents onto the service; a refusal or a timeout leaves the edge record with its status. A reply now carries the
responder's span (`WorkerHandler` parity), and the `PostOffice` records a local RPC round trip the way an engine
inbox does. An untraced call records nothing. **Upgrade note:** a backend shows three more spans per traced call into
the pack and one per traced local RPC; a dashboard that read the pack's response time off a function's span moves to
the `http.request` edge record, as for the engines.

## Version 4.12.21, 10/7/2026

Expand Down
26 changes: 21 additions & 5 deletions docs/guides/config-logging-actuators.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,27 @@ comes from the `LOG_LEVEL` environment variable when set, else `log.level`.

## Distributed tracing — the OpenTelemetry forwarder (opt-in)

Every traced, non-RPC execution emits the engines' distributed-trace dataset on the
`distributed.tracing` log stream — `{"trace": {...}, "annotations": {...}}`, the record the
Java engine logs — so a log aggregation already stitches the span tree across all four
runtimes. The forwarder is the second sink, for backends that take spans directly:
Dynatrace, Splunk, an OpenTelemetry Collector.
Every traced execution leaves one distributed-trace dataset on the `distributed.tracing`
log stream — `{"trace": {...}, "annotations": {...}}`, the record the Java engine logs — so
a log aggregation already stitches the span tree across all four runtimes. Who emits it
follows the engines' rules, so a span lineage stays continuous from an engine into this host:

- a function execution that is not an RPC emits its own record when it finishes, parented
onto the caller's span;
- an RPC round trip is recorded by the caller's side, never by the responder (the engines'
`InboxBase`): the `PostOffice` records a local call with the responder's span, which rides
the reply, the caller's span as parent, the responder's `exec_time` and the `round_trip`
it measured;
- a traced call arriving at `/api/event` leaves what a Java or Rust callee leaves for it:
`http.request` (`POST /api/event`, the edge's round trip, parented onto the caller's span —
the envelope's span, else the `traceparent` header's; the backend's SERVER span),
`event.api.service` (the ingress, parented onto the edge) and, for an RPC, the function's
round-trip record parented onto the service; a drop-n-forget or streaming function emits
its own record under the service. A refused call (403, 404) and a timeout (408) leave the
edge record with that status. An untraced call records nothing.

The forwarder is the second sink, for backends that take spans directly: Dynatrace, Splunk,
an OpenTelemetry Collector.

```yaml
otel.forwarding: true
Expand Down
10 changes: 10 additions & 0 deletions extensions/mcp-binding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ is a service of its own, with its own mailbox and workers. A call learns the rou
context (`get_trace().route`, the pack's counterpart of the engines' read-only `my_route` header), which names the server
and the remote tool; a call to `mcp.tool` by its own name is a 404.

**Tracing.** A trace names the bound route, never `mcp.tool`: every trace field comes from the service entry the event
was addressed to, so the route's own record, the execution context's route, the reply's sender and the host's `Handled`
log line all read `diagram.render.mcp`. A traced call from an engine leaves the records a Java or Rust callee leaves (the
pack's guide `docs/guides/config-logging-actuators.md`): the edge's round trip `http.request` (`POST /api/event`),
parented onto the engine function that called; `event.api.service` under it; and the route's round-trip record under
that, carrying the execution's span, its `exec_time` and the `round_trip` - the MCP server's `tools/call` is inside that
`exec_time`, not a span of its own. Driven live through the Java Playground's `diagram-assistant` graph on 2026-11-14,
the lineage is continuous from the engine's `diagram.render` into this route; the record is the engines'
`docs/test-reports/agent-loop-trace-lineage.md`.

## Security

- **No engine runs a child process.** The MCP client, the subprocess and the credentials live here, in the pack, behind
Expand Down
20 changes: 20 additions & 0 deletions memory/continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,26 @@
credential is a reference in the server file, resolved from this process's environment.
<!-- id: mcp-binding-route-per-tool | created: 2026-11-14 | last_used: 2026-11-14 | uses: 1 | tier: working | origin: 2026-11-14-073523 -->

- **A traced call into the pack leaves the records a Java or Rust callee leaves, so a span lineage is continuous from an
engine into the pack (2026-11-14; Eric's criterion: for OpenTelemetry to be certified, the span lineage ought to be
continuous end to end).** The live drive of the engines' agent loop through both packs found the pack legs absent from the
trace: the calling engine's `async.http.request` leg is in `skip.rpc.tracing` by default, and the host suppressed the
worker-side record of every RPC-served execution with no callee-side record to replace it, so `llm.chat` and
`diagram.render.mcp` showed only as time inside the engine's `llm.run` and `diagram.render`. Now `server.py` records, for a
traced call at `/api/event`, `http.request` (`POST /api/event`, the edge's round trip; parent the caller's span from the
envelope, else the `traceparent` header; the backend's SERVER span), `event.api.service` (the ingress; parent the edge)
and, for an RPC, the function's round-trip record (parent the service; the responder's span from the reply, `exec_time`
and `round_trip`); the relayed envelope carries the service's span, so a drop-n-forget or streaming function parents onto
it; a refusal or a timeout leaves the edge record with its status; an untraced call records nothing. `bus.py` puts the
execution's span on the reply (`WorkerHandler` parity) and `client.py` records a local RPC round trip as `InboxBase` does;
the worker-side record of an RPC-served execution stays suppressed. Re-driven the same day on the `diagram-assistant`
graph: 20 records per run, every parent owned by a record, in both packs (the engines'
`docs/test-reports/agent-loop-trace-lineage.md`; the Node twin in lockstep). **How to apply:** the rule is who emits a
record, never whether one exists - every traced execution leaves exactly one; before changing a host or client path, ask
which side records it in the Java engine and mirror that side; a lineage check parses every record of the trace from every
process and verifies each parent is a span some record owns.
<!-- id: trace-records-engine-parity | created: 2026-11-14 | last_used: 2026-11-14 | uses: 1 | tier: working | origin: 2026-11-14-115310 -->

## Conventions

- **Quality gates (adopted 2026-08-23, Eric's IDE review round): ruff + basedpyright +
Expand Down
58 changes: 58 additions & 0 deletions memory/sessions/2026-11-14-115310.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Session (2026-11-14T11:53:10.000Z)

**Agent:** Claude Code
**Title:** Trace records mirror the engines' callee and caller - a span lineage continuous from an engine into the pack

## Context

Follows 2026-11-14-104712 (the `@preload` shape of the MCP binding, #52). Eric asked how the alias shape appears in a
trace, then for a live end-to-end drive from the Java engine to both language packs with the telemetry recorded, and set
the criterion: for OpenTelemetry to be certified, the span lineage ought to be continuous end to end.

## What the drive found (the baseline, negative control first)

The Playground's `diagram-assistant` graph on Claude Haiku 4.5, the engine calling this pack's LLM helper (`llm.chat`,
port 8086) and MCP binding (`diagram.render.mcp`, port 8088, the public Mermaid Chart server). Every record the engine
logged chained to a parent that exists, but the pack legs left no record anywhere: 11 engine records, none from the
pack. Two rules met: the calling engine's HTTP client leg `async.http.request` is in `skip.rpc.tracing` by default, so
the caller records nothing for a wire RPC; and this host suppressed the worker-side record of every RPC-served execution
("engine parity") without the callee-side records a Java engine leaves for the same call - the `http.request` edge
(`POST /api/event`), `event.api.service` and the function's round-trip record from the local inbox. The forwarder drops a
record without a valid span id, so even an engine inbox record for a pack route would not have reached a backend: the
reply carried no span.

## The design Eric chose

Three shapes were offered: mirror the Java callee exactly, one record per wire RPC, or keep the rule and document it.
Eric chose the first.

## What changed (branch `feat/trace-records-engine-parity`)

- `src/mercury_composable/bus.py`: the reply carries the responder's span (`WorkerHandler` parity); `round_trip_record`
builds the engines' `InboxBase` record; `EventBus.emit` logs a dataset and forwards it.
- `src/mercury_composable/client.py`: a traced local RPC (plain or interceptor) is recorded by the caller's side with the
responder's span, the caller's span as parent, the responder's `exec_time` and the measured `round_trip`.
- `src/mercury_composable/server.py`: `_Edge` records a traced call at `/api/event` as the Java callee does -
`http.request` (the edge's round trip, parent the caller's span: the envelope's, else the `traceparent` header's),
`event.api.service` (parent the edge), the function's round-trip record for an RPC (parent the service); the relayed
envelope carries the service's span, so a drop-n-forget or streaming function parents onto it; a refusal (403, 404), a
timeout (408) and an in-band stream failure leave the edge record with that status; an untraced call records nothing.
- `tests/test_event_stream.py`: the two tests that pinned "RPC legs emit no dataset" now pin the engines' rule, and a
new test pins the wire RPC's three records, the responder's span on the reply, the refusal's edge record and the silence
of an untraced call.
- `docs/guides/config-logging-actuators.md` states who emits which record; CHANGELOG Unreleased Changed 4; the MCP
binding's README gains a Tracing paragraph (the alias never appears; the route is the identity).

## Verification

- `ruff` clean; `basedpyright` 0 errors; `pytest` 398 passed, 64 skipped.
- Re-driven live the same way: 20 records per run (11 engine, 9 pack: three per wire call, two `llm.chat` turns and one
`diagram.render.mcp`), every parent owned by a record. The helper's edge sits under the engine's `llm.run`, the
binding's under `diagram.render`, each function under its `event.api.service`, and the timings nest (edge >= the
function's round trip >= its `exec_time`). The engines' repository carries the record:
`docs/test-reports/agent-loop-trace-lineage.md`.

## Memory References

- Relied on: mcp-binding-route-per-tool (the alias shape, whose trace identity the README now states; unchanged)
- Created: trace-records-engine-parity (tier: working)
41 changes: 41 additions & 0 deletions src/mercury_composable/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,32 @@ def _emit_trace(delivery: _Delivery, info: TraceInfo, start: str,
return dataset


def round_trip_record(*, service: str, sender: str | None, trace_id: str, trace_path: str | None,
reply: EventEnvelope, parent_span_id: str | None, start: str,
round_trip_ms: float) -> dict[str, Any]:
"""The caller-side record of an RPC round trip - the engines' InboxBase record. Its
span is the responder's own (the reply carries it), its parent the caller's span, its
exec_time the responder's and its round_trip the wait measured by the caller; the
worker-side record of an RPC-served execution is suppressed, so this is the one record
for that span (the wire-format contract's rpc tag)."""
from .actuator import app_origin # late: actuator imports the registry chain
trace: dict[str, Any] = {
"origin": app_origin(), "id": trace_id, "path": trace_path, "service": service,
"start": start, "exec_time": reply.exec_time, "round_trip": round(round_trip_ms, 3),
"status": reply.get_status(), "success": not reply.has_error(),
}
if sender:
trace["from"] = sender
if reply.span_id:
trace["span_id"] = reply.span_id
if parent_span_id:
trace["parent_span_id"] = parent_span_id
if reply.has_error():
# for data privacy, only a recognized error message is shown (the engines' InboxBase)
trace["exception"] = reply.body if isinstance(reply.body, str) else "***"
return trace


class EventBus:
"""Per-registry bus: one FIFO mailbox and N workers per registered route."""

Expand Down Expand Up @@ -320,6 +346,10 @@ async def _execute(self, delivery: _Delivery) -> EventEnvelope:
_reset_trace(token)
reply.sender = reply.sender or service.route
reply.exec_time = round((time.perf_counter() - start) * 1000, 3)
if info.span_id:
# the responder's span rides the reply, so the caller's round-trip record chains
# onto it - the engines' WorkerHandler contract
reply.span_id = info.span_id
if info.annotations:
reply.annotations.update(info.annotations)
self._forward(_emit_trace(delivery, info, start_iso, reply.exec_time,
Expand Down Expand Up @@ -371,6 +401,17 @@ async def _execute_interceptor(self, delivery: _Delivery) -> EventEnvelope:
# an interceptor's own outcome is never auto-replied
return EventEnvelope()

def emit(self, trace: dict[str, Any], annotations: dict[str, Any] | None = None) -> None:
"""Emit one distributed-trace dataset the engines' way: the record on the telemetry
log stream, then the extension route. The caller-side records - an RPC round trip
recorded by the PostOffice, the edge records of a call through /api/event - come
through here; a worker-side record is emitted by the execution itself."""
dataset: dict[str, Any] = {"trace": trace}
if annotations:
dataset["annotations"] = dict(annotations)
telemetry_log.info(dataset)
self._forward(dataset)

def _forward(self, dataset: dict[str, Any] | None) -> None:
"""Hand an emitted dataset to the engines' extension route when a
function is registered there (the OpenTelemetry forwarder, or an
Expand Down
36 changes: 27 additions & 9 deletions src/mercury_composable/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@
import concurrent.futures
import json
import re
import time
from collections.abc import AsyncIterator, Callable, Coroutine
from typing import Any

import aiohttp

from .bus import DeliveryTimeout, get_host_loop
from .envelope import EventEnvelope
from .bus import DeliveryTimeout, get_host_loop, round_trip_record
from .envelope import EventEnvelope, iso_utc
from .event_stream import (
DATA,
ENVELOPE,
Expand Down Expand Up @@ -166,27 +167,44 @@ async def _call_local(self, route: str, body: Any, headers: dict[str, str] | Non
# unmarked = the reply; marked = a streaming target refusing a
# single-shot caller (the pinned 406)
sink_route, queue = bus.open_sink()
start, started = iso_utc(), time.perf_counter()
try:
bus.publish_envelope(service, event.set_reply_to(sink_route))
try:
first = await asyncio.wait_for(queue.get(), max(100, timeout_ms) / 1000)
except asyncio.TimeoutError:
return EventEnvelope().set_status(408).set_body(
f"Timeout for {timeout_ms} ms")
first = EventEnvelope().set_status(408).set_body(f"Timeout for {timeout_ms} ms")
if stream_signal(first) is not None:
return EventEnvelope().set_status(406).set_body(STREAM_CALLER_REQUIRED)
first = EventEnvelope().set_status(406).set_body(STREAM_CALLER_REQUIRED)
self._record_round_trip(route, event, first, start, started)
return first
finally:
bus.close_sink(sink_route)
if is_async:
return bus.publish(service, event.headers, event.body, trace_id=event.trace_id,
trace_path=event.trace_path, cid=event.cid, envelope=event)
start, started = iso_utc(), time.perf_counter()
try:
return await bus.deliver(service, event.headers, event.body, timeout_ms,
trace_id=event.trace_id, trace_path=event.trace_path,
cid=event.cid, envelope=event)
reply = await bus.deliver(service, event.headers, event.body, timeout_ms,
trace_id=event.trace_id, trace_path=event.trace_path,
cid=event.cid, envelope=event)
except DeliveryTimeout:
return EventEnvelope().set_status(408).set_body(f"Timeout for {timeout_ms} ms")
reply = EventEnvelope().set_status(408).set_body(f"Timeout for {timeout_ms} ms")
self._record_round_trip(route, event, reply, start, started)
return reply

def _record_round_trip(self, route: str, event: EventEnvelope, reply: EventEnvelope,
start: str, started: float) -> None:
"""A traced caller's record of a local RPC round trip - the engines' InboxBase:
the responder's span from the reply, the caller's span (on the outbound envelope)
as the parent, and the round trip measured here. Untraced calls record nothing."""
if not event.trace_id:
return
self._registry.bus.emit(
round_trip_record(service=route, sender=event.sender, trace_id=event.trace_id,
trace_path=event.trace_path, reply=reply, parent_span_id=event.span_id,
start=start, round_trip_ms=(time.perf_counter() - started) * 1000),
reply.annotations)

async def _call(self, route: str, body: Any, headers: dict[str, str] | None,
timeout_ms: int, endpoint: str | None, is_async: bool,
Expand Down
Loading
Loading