Skip to content

Commit bfca7e4

Browse files
authored
Merge pull request #21 from Accenture/feature/progressive-rendering-v4.12.0
Event streaming, telemetry lineage and app log context - v4.12.0 milestone
2 parents f55377e + d50986a commit bfca7e4

23 files changed

Lines changed: 2543 additions & 54 deletions

CHANGELOG.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,62 @@
11
# Changelog
22

3-
## 0.1.0 (unreleased)
3+
## 4.12.0 (2026-08-30)
44

5+
The progressive-rendering milestone release. The version aligns with the Mercury
6+
Composable engine lock-step line (Java and Rust engines, Python and Node.js
7+
language packs all at v4.12.0): token/event streaming end to end with full
8+
OpenTelemetry lineage, business-correlation continuity and application log
9+
context across all four runtimes - useful on its own, and the foundation for
10+
the AI SDLC (agent, MCP and tool adapters as wrapper-side functions with
11+
complete observability).
12+
13+
- **Event streaming** - the platform-wide multi-shot reply contract, both halves.
14+
Producer: `@preload(..., interceptor=True)` handlers receive the raw envelope
15+
and stream through `EventStreamWriter` (the engines' exact API - `first`,
16+
`write`, `write_named`, `close` with trailing metadata, `fail` with the standard
17+
error key-values); the `/api/event` host answers a caller that accepts
18+
`text/event-stream` with the platform's hybrid SSE dialect (envelope frames for
19+
the head, the terminals and non-text segments; raw frames for text tokens),
20+
refuses a non-accepting caller of a streaming function with the pinned 406, and
21+
keeps single-shot replies over the capable path byte-identical. Consumer:
22+
`PostOffice.stream()` (an async iterator yielding the same decoded envelopes an
23+
engine reply route receives, with the dialect conformance guards) and
24+
`PostOffice.stream_to()` (the relay form: forward your caller's reply address
25+
and segments flow through verbatim - engine-parity composition). Under it all,
26+
the primitive event bus gained the engines' reply_to mechanism: envelope-routed
27+
delivery to a LOCAL function or per-request reply sink - simple routing, no
28+
orchestration. Same keep-alive config key as the engines
29+
(`event.stream.keep.alive`). Engine-identical wire and messages
30+
(Java PR #299-#301 / Rust PR #216-#218 lineage).
31+
- **Business correlation-id continuity** (the engines' PostOffice parity): the
32+
client stamps the current context's business correlation-id onto outbound
33+
events as the engine-managed `my_cid` tag, local bus deliveries inject the
34+
read-only `my_correlation_id` header view exactly like the HTTP host, and
35+
`get_trace()` / `trace_context()` carry `my_correlation_id` - so the business
36+
correlation-id continues across engine⇄wrapper and wrapper⇄wrapper hops.
37+
- **Span lineage** (the engines' telemetry model): every traced execution mints
38+
a 16-hex span with the caller's span (from the inbound envelope) as its
39+
parent, outbound events carry the current span so the next hop parents onto
40+
it (`PostOffice.touch` parity, W3C `traceparent` included), streaming
41+
segments carry the producer's span, and non-RPC executions emit the engines'
42+
distributed-trace dataset record on the `distributed.tracing` log stream -
43+
the same `{"trace": {...}, "annotations": {...}}` shape the Java engine
44+
logs, so stdout log-ingest agents stitch spans across all four runtimes.
45+
RPC round-trips are suppressed exactly like the engines (the new `rpc`
46+
envelope tag rides `request()` calls). `trace_context()` accepts `span_id`
47+
to parent onto an external OpenTelemetry span. Outbound events and stream
48+
segments also fill their sender with the executing function's route, and
49+
the `/api/event` host fills `event.api.service` for an anonymous caller -
50+
the engines' sender-attribution rules.
51+
- **Application log context** (the engines' app-log-context feature, on by
52+
default via the packaged `default-log-context.yaml` - the engines' resource
53+
twin): with `log.format` json/compact, every log line inside a traced
54+
request carries a `context` block - cid (the business correlation-id),
55+
traceId, tracePath, spanId, parentSpanId, service, timestamp - so app logs
56+
and the distributed-trace records correlate end to end. Customize with
57+
`resources/app-log-context.yaml` (reserved `$tokens` or constants with
58+
`${ENV:default}`), opt out with `app.log.context=false`, and add
59+
per-request key-values with `update_context()` (reserved keys guarded).
560
- Documentation site (mkdocs-material, the engine repo's theme): the three-layer theme
661
reference, rationale/design foundations, function-writing patterns, flow and
762
knowledge-graph join chapters, a one-page AI agent guide with llms.txt, and

docs/guides/function-patterns.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,23 +95,50 @@ message and a stack trace, mirroring the engines. Handler-level errors always ri
9595
HTTP 200; only transport-level failures (unknown route, private target, timeout,
9696
undecodable envelope) surface as HTTP status codes.
9797

98-
## Trace context
98+
## Trace context and span lineage
9999

100-
Every delivery runs under its caller's trace:
100+
Every delivery runs under its caller's trace, and every traced execution mints
101+
its own **span** with the caller's span as its parent - the engines' exact
102+
OpenTelemetry lineage model, so a chain like *user → engine flow → wrapper
103+
function (agent, MCP tool) → engine* stays one connected trace tree:
101104

102105
```python
103106
from mercury_composable import annotate_trace, get_trace
104107

105-
info = get_trace() # trace_id, trace_path, cid - or None
106-
annotate_trace("model", "v3") # rides back on the reply envelope
108+
info = get_trace() # trace_id, trace_path, cid, my_correlation_id,
109+
# span_id, parent_span_id - or None
110+
annotate_trace("model", "v3") # rides back on the reply envelope AND the trace record
107111
```
108112

109-
Outside a hosted function (batch jobs, tests), establish context explicitly:
113+
Outbound calls carry the current span (the receiver's parent), the business
114+
correlation-id (`my_cid` tag), and a W3C `traceparent` header when the trace id
115+
is W3C-shaped. Non-RPC executions emit the engines' distributed-trace dataset
116+
on the `distributed.tracing` log stream - the same
117+
`{"trace": {...}, "annotations": {...}}` record the Java engine logs - so a
118+
stdout log-ingest agent (Dynatrace-style) or any log aggregation stitches the
119+
span tree across all four runtimes. RPC round-trips fold into the caller's
120+
view, exactly like the engines.
121+
122+
**Application log context**: with `log.format` json/compact, every log line a
123+
function writes inside a traced request carries a `context` block (the
124+
engines' app-log-context feature, on by default) - the standard trace context
125+
(`cid` = the business correlation-id, `traceId`, `tracePath`, `spanId`,
126+
`parentSpanId`, `service`, `timestamp`) - so application logs and the
127+
distributed-trace records correlate in one aggregation. Customize with your
128+
own `resources/app-log-context.yaml` (`context:` section mapping output keys
129+
to reserved `$tokens` or constants, `${ENV:default}` supported), opt out with
130+
`app.log.context=false`, and add per-request key-values from a handler with
131+
`update_context("tenant", "acme")` (a logging-only sink; reserved keys are
132+
guarded; `None` removes).
133+
134+
Outside a hosted function (batch jobs, tests), establish context explicitly -
135+
including an external OpenTelemetry span to parent onto:
110136

111137
```python
112138
from mercury_composable import trace_context
113139

114-
with trace_context("trace-1", "BATCH /nightly", cid="order-42"):
140+
with trace_context("4bf92f3577b34da6a3ce929d0e0e4736", "BATCH /nightly",
141+
cid="order-42", span_id="00f067aa0ba902b7"):
115142
reply = await po.request("my.function", body={...})
116143
```
117144

docs/guides/http-surface-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ Mirrors the engines' `event.api.service`:
2222
| Reply | always envelope bytes, `content-type: application/octet-stream` |
2323
| Handler outcome | rides **HTTP 200** with the status inside the envelope (including AppException and unexpected errors) |
2424
| Transport failures | set the HTTP status too: 400 undecodable / missing route field, 403 private target, 404 unknown route (`Route X not found`), 408 timeout (`Timeout for N ms`) |
25-
| Header hygiene | inbound `x-event-api` and `my_*` removed; the `my_cid` tag becomes the read-only `my_correlation_id` header |
25+
| Header hygiene | inbound `x-event-api` and `my_*` removed; the `my_cid` tag becomes the read-only `my_correlation_id` header (local bus deliveries inject the same view). Outbound, the client stamps the current context's business correlation-id back onto the event as the `my_cid` tag — the engines' PostOffice parity, so the business correlation-id continues across every hop |
26+
| `accept: text/event-stream` | streaming-capable call to an interceptor target: a streamed reply rides the same call as SSE in the envelope-mode dialect; a single-shot reply stays byte-identical; a streaming reply to a NON-accepting caller → 406 `Streaming function requires a caller that accepts text/event-stream`. See [Event Streaming](streaming.md) |
2627

2728
## Actuator endpoints
2829

docs/guides/streaming.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Event Streaming
2+
3+
A function that produces its result progressively — an LLM relay emitting tokens, a
4+
long-running job reporting progress — should not make its caller wait for the whole
5+
answer. This chapter is the wrapper's half of the platform-wide streaming contract:
6+
the same paradigm on all four runtimes (Java, Rust, Python, Node.js):
7+
8+
> **The caller provides a reply address; the callee streams events to it until a
9+
> terminal signal.**
10+
11+
Each segment is one event to the caller's `reply_to`, marked with the reserved
12+
envelope header `x-event-stream: data | eof | exception`. A calling engine renders
13+
the segments out its HTTP edge, hands them to a flow, or relays them onward — your
14+
Python function neither knows nor cares.
15+
16+
## Write a streaming function
17+
18+
A streaming producer is an **interceptor**: it receives the raw `EventEnvelope`
19+
(so the caller's reply address travels the engines' way) and replies through
20+
`EventStreamWriter` instead of a return value:
21+
22+
```python
23+
from mercury_composable import EventEnvelope, EventStreamWriter, preload
24+
25+
@preload(route="hello.tokens", instances=10, interceptor=True)
26+
async def stream_tokens(headers: dict[str, str], event: EventEnvelope):
27+
out = EventStreamWriter.from_request(event)
28+
out.first(200, "text/event-stream") # head control rides the first event
29+
out.write("The answer is") # data segment
30+
out.write_named("tokens", {"n": 2}) # named (typed) SSE event
31+
out.close({"usage": {"tokens": 2}}) # end of transmission + trailing metadata
32+
# or out.fail(e) # in-band failure
33+
```
34+
35+
The writer is the engines' exact API. `first(status, content_type, ttl_seconds=None)`
36+
declares the response head and, optionally, the idle allowance between segments;
37+
`fail(e)` carries the standard error key-values
38+
`'{"type": "error", "status": n, "message": text}'`; writes after `close()`/`fail()`
39+
are dropped. Plain-`def` handlers can stream too — the writer bridges from the
40+
executor thread back to the host loop.
41+
42+
An interceptor's return value is never auto-replied. To answer single-shot from an
43+
interceptor (a relay that sometimes buffers, for example), send a plain envelope to
44+
`event.reply_to` yourself. An uncaught exception becomes the standard error envelope
45+
to the caller — single-shot before the stream starts, in-band after.
46+
47+
## How it crosses the wire
48+
49+
When a calling engine (or `curl`) invokes your streaming function through
50+
`POST /api/event` with `Accept: text/event-stream`, the host answers the same call
51+
with a Server-Sent Events response in the platform's hybrid dialect:
52+
53+
- **envelope frames** — the reserved SSE event name `envelope`, one base64-encoded
54+
serialized envelope per frame — carry everything with envelope semantics: the
55+
first event (head control), the `eof`/`exception` terminals, and any segment that
56+
cannot round-trip as plain text (a dict or bytes body, text containing a carriage
57+
return, an event name colliding with the reserved word);
58+
- **raw SSE frames** carry plain text segments, so token relays stay near-zero
59+
overhead.
60+
61+
Everything degrades explicitly: a caller that did not opt in receives
62+
`406 Streaming function requires a caller that accepts text/event-stream` instead of
63+
a truncated reply; a non-streaming (single-shot) answer over the capable path is
64+
byte-identical to a normal RPC reply; idle expiry fails the stream in-band with the
65+
standard 408 error body. The `x-ttl` request header (ms) is the idle allowance
66+
between segments — your `first(..., ttl_seconds=...)` can extend it for the whole
67+
stream. While the producer is quiet, the host emits `: ping` keep-alive comments
68+
(`event.stream.keep.alive`, the engines' config key — default 30s, `0` disables).
69+
70+
## Consume a stream
71+
72+
`PostOffice.stream()` is the consumer surface — an async iterator yielding the same
73+
decoded envelopes an engine reply route receives: `data` segments, then the terminal.
74+
It works against a remote peer's `/api/event` (an engine or another function host)
75+
and against local functions alike, and opting in is always safe — a non-streaming
76+
target simply yields its one classic reply:
77+
78+
```python
79+
from mercury_composable import PostOffice
80+
81+
async with PostOffice() as po:
82+
async for segment in po.stream("hello.tokens", None,
83+
endpoint="http://127.0.0.1:8100/api/event",
84+
timeout_ms=30000):
85+
marker = segment.headers.get("x-event-stream")
86+
if marker == "data":
87+
print(segment.body)
88+
elif marker == "exception":
89+
raise RuntimeError(segment.body["message"])
90+
# eof: segment.body carries the trailing metadata, if any
91+
```
92+
93+
`timeout_ms` is the idle allowance between segments. The consumer guards the dialect
94+
for you: a malformed frame, a stream that ends without a terminal, or idle expiry
95+
each yield the standard in-band exception envelope, then the iterator ends.
96+
97+
## Compose a relay
98+
99+
The pattern the whole streaming program is built on: forward **your own caller's**
100+
reply address into a call against a remote streaming function, and the segments flow
101+
`engine → your function → remote peer → back to the original caller` with no
102+
buffering anywhere:
103+
104+
```python
105+
@preload(route="llm.relay", instances=10, interceptor=True)
106+
async def relay(headers: dict[str, str], event: EventEnvelope):
107+
async with PostOffice() as po:
108+
await po.stream_to("remote.tokens", None,
109+
reply_to=event.reply_to or "",
110+
endpoint="http://peer:8085/api/event",
111+
cid=event.cid, timeout_ms=30000)
112+
```
113+
114+
`stream_to()` forwards every decoded envelope verbatim to the named LOCAL route
115+
(here, the reply sink the host opened for your caller) and returns the terminal.
116+
Combined with a calling engine's `stream: true` endpoint, this streams a remote
117+
peer's tokens progressively out that engine's HTTP edge — with zero imperative
118+
streaming code in between.
119+
120+
## See also
121+
122+
- The engines' HTTP Response Streaming guides (the same contract at the HTTP edge):
123+
[Java](https://accenture.github.io/mercury-composable/guides/http-streaming/) ·
124+
[Rust](https://accenture.github.io/mercury/guides/http-streaming/)
125+
- [Interop Test Report — Progressive Rendering](../test-reports/progressive-rendering-interop.md)
126+
the live four-runtime validation of this contract
127+
- [HTTP Surface Reference](http-surface-reference.md) — the `/api/event` contract
128+
- [Function Writing Patterns](function-patterns.md)

0 commit comments

Comments
 (0)