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
57 changes: 56 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,62 @@
# Changelog

## 0.1.0 (unreleased)
## 4.12.0 (2026-08-30)

The progressive-rendering milestone release. The version aligns with the Mercury
Composable engine lock-step line (Java and Rust engines, Python and Node.js
language packs all at v4.12.0): token/event streaming end to end with full
OpenTelemetry lineage, business-correlation continuity and application log
context across all four runtimes - useful on its own, and the foundation for
the AI SDLC (agent, MCP and tool adapters as wrapper-side functions with
complete observability).

- **Event streaming** - the platform-wide multi-shot reply contract, both halves.
Producer: `@preload(..., interceptor=True)` handlers receive the raw envelope
and stream through `EventStreamWriter` (the engines' exact API - `first`,
`write`, `write_named`, `close` with trailing metadata, `fail` with the standard
error key-values); the `/api/event` host answers a caller that accepts
`text/event-stream` with the platform's hybrid SSE dialect (envelope frames for
the head, the terminals and non-text segments; raw frames for text tokens),
refuses a non-accepting caller of a streaming function with the pinned 406, and
keeps single-shot replies over the capable path byte-identical. Consumer:
`PostOffice.stream()` (an async iterator yielding the same decoded envelopes an
engine reply route receives, with the dialect conformance guards) and
`PostOffice.stream_to()` (the relay form: forward your caller's reply address
and segments flow through verbatim - engine-parity composition). Under it all,
the primitive event bus gained the engines' reply_to mechanism: envelope-routed
delivery to a LOCAL function or per-request reply sink - simple routing, no
orchestration. Same keep-alive config key as the engines
(`event.stream.keep.alive`). Engine-identical wire and messages
(Java PR #299-#301 / Rust PR #216-#218 lineage).
- **Business correlation-id continuity** (the engines' PostOffice parity): the
client stamps the current context's business correlation-id onto outbound
events as the engine-managed `my_cid` tag, local bus deliveries inject the
read-only `my_correlation_id` header view exactly like the HTTP host, and
`get_trace()` / `trace_context()` carry `my_correlation_id` - so the business
correlation-id continues across engine⇄wrapper and wrapper⇄wrapper hops.
- **Span lineage** (the engines' telemetry model): every traced execution mints
a 16-hex span with the caller's span (from the inbound envelope) as its
parent, outbound events carry the current span so the next hop parents onto
it (`PostOffice.touch` parity, W3C `traceparent` included), streaming
segments carry the producer's span, and non-RPC executions emit the engines'
distributed-trace dataset record on the `distributed.tracing` log stream -
the same `{"trace": {...}, "annotations": {...}}` shape the Java engine
logs, so stdout log-ingest agents stitch spans across all four runtimes.
RPC round-trips are suppressed exactly like the engines (the new `rpc`
envelope tag rides `request()` calls). `trace_context()` accepts `span_id`
to parent onto an external OpenTelemetry span. Outbound events and stream
segments also fill their sender with the executing function's route, and
the `/api/event` host fills `event.api.service` for an anonymous caller -
the engines' sender-attribution rules.
- **Application log context** (the engines' app-log-context feature, on by
default via the packaged `default-log-context.yaml` - the engines' resource
twin): with `log.format` json/compact, every log line inside a traced
request carries a `context` block - cid (the business correlation-id),
traceId, tracePath, spanId, parentSpanId, service, timestamp - so app logs
and the distributed-trace records correlate end to end. Customize with
`resources/app-log-context.yaml` (reserved `$tokens` or constants with
`${ENV:default}`), opt out with `app.log.context=false`, and add
per-request key-values with `update_context()` (reserved keys guarded).
- Documentation site (mkdocs-material, the engine repo's theme): the three-layer theme
reference, rationale/design foundations, function-writing patterns, flow and
knowledge-graph join chapters, a one-page AI agent guide with llms.txt, and
Expand Down
39 changes: 33 additions & 6 deletions docs/guides/function-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,23 +95,50 @@ message and a stack trace, mirroring the engines. Handler-level errors always ri
HTTP 200; only transport-level failures (unknown route, private target, timeout,
undecodable envelope) surface as HTTP status codes.

## Trace context
## Trace context and span lineage

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

```python
from mercury_composable import annotate_trace, get_trace

info = get_trace() # trace_id, trace_path, cid - or None
annotate_trace("model", "v3") # rides back on the reply envelope
info = get_trace() # trace_id, trace_path, cid, my_correlation_id,
# span_id, parent_span_id - or None
annotate_trace("model", "v3") # rides back on the reply envelope AND the trace record
```

Outside a hosted function (batch jobs, tests), establish context explicitly:
Outbound calls carry the current span (the receiver's parent), the business
correlation-id (`my_cid` tag), and a W3C `traceparent` header when the trace id
is W3C-shaped. Non-RPC executions emit the engines' distributed-trace dataset
on the `distributed.tracing` log stream - the same
`{"trace": {...}, "annotations": {...}}` record the Java engine logs - so a
stdout log-ingest agent (Dynatrace-style) or any log aggregation stitches the
span tree across all four runtimes. RPC round-trips fold into the caller's
view, exactly like the engines.

**Application log context**: with `log.format` json/compact, every log line a
function writes inside a traced request carries a `context` block (the
engines' app-log-context feature, on by default) - the standard trace context
(`cid` = the business correlation-id, `traceId`, `tracePath`, `spanId`,
`parentSpanId`, `service`, `timestamp`) - so application logs and the
distributed-trace records correlate in one aggregation. Customize with your
own `resources/app-log-context.yaml` (`context:` section mapping output keys
to reserved `$tokens` or constants, `${ENV:default}` supported), opt out with
`app.log.context=false`, and add per-request key-values from a handler with
`update_context("tenant", "acme")` (a logging-only sink; reserved keys are
guarded; `None` removes).

Outside a hosted function (batch jobs, tests), establish context explicitly -
including an external OpenTelemetry span to parent onto:

```python
from mercury_composable import trace_context

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

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/http-surface-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Mirrors the engines' `event.api.service`:
| Reply | always envelope bytes, `content-type: application/octet-stream` |
| Handler outcome | rides **HTTP 200** with the status inside the envelope (including AppException and unexpected errors) |
| 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`) |
| Header hygiene | inbound `x-event-api` and `my_*` removed; the `my_cid` tag becomes the read-only `my_correlation_id` header |
| 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 |
| `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) |

## Actuator endpoints

Expand Down
128 changes: 128 additions & 0 deletions docs/guides/streaming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Event Streaming

A function that produces its result progressively — an LLM relay emitting tokens, a
long-running job reporting progress — should not make its caller wait for the whole
answer. This chapter is the wrapper's half of the platform-wide streaming contract:
the same paradigm on all four runtimes (Java, Rust, Python, Node.js):

> **The caller provides a reply address; the callee streams events to it until a
> terminal signal.**

Each segment is one event to the caller's `reply_to`, marked with the reserved
envelope header `x-event-stream: data | eof | exception`. A calling engine renders
the segments out its HTTP edge, hands them to a flow, or relays them onward — your
Python function neither knows nor cares.

## Write a streaming function

A streaming producer is an **interceptor**: it receives the raw `EventEnvelope`
(so the caller's reply address travels the engines' way) and replies through
`EventStreamWriter` instead of a return value:

```python
from mercury_composable import EventEnvelope, EventStreamWriter, preload

@preload(route="hello.tokens", instances=10, interceptor=True)
async def stream_tokens(headers: dict[str, str], event: EventEnvelope):
out = EventStreamWriter.from_request(event)
out.first(200, "text/event-stream") # head control rides the first event
out.write("The answer is") # data segment
out.write_named("tokens", {"n": 2}) # named (typed) SSE event
out.close({"usage": {"tokens": 2}}) # end of transmission + trailing metadata
# or out.fail(e) # in-band failure
```

The writer is the engines' exact API. `first(status, content_type, ttl_seconds=None)`
declares the response head and, optionally, the idle allowance between segments;
`fail(e)` carries the standard error key-values
`'{"type": "error", "status": n, "message": text}'`; writes after `close()`/`fail()`
are dropped. Plain-`def` handlers can stream too — the writer bridges from the
executor thread back to the host loop.

An interceptor's return value is never auto-replied. To answer single-shot from an
interceptor (a relay that sometimes buffers, for example), send a plain envelope to
`event.reply_to` yourself. An uncaught exception becomes the standard error envelope
to the caller — single-shot before the stream starts, in-band after.

## How it crosses the wire

When a calling engine (or `curl`) invokes your streaming function through
`POST /api/event` with `Accept: text/event-stream`, the host answers the same call
with a Server-Sent Events response in the platform's hybrid dialect:

- **envelope frames** — the reserved SSE event name `envelope`, one base64-encoded
serialized envelope per frame — carry everything with envelope semantics: the
first event (head control), the `eof`/`exception` terminals, and any segment that
cannot round-trip as plain text (a dict or bytes body, text containing a carriage
return, an event name colliding with the reserved word);
- **raw SSE frames** carry plain text segments, so token relays stay near-zero
overhead.

Everything degrades explicitly: a caller that did not opt in receives
`406 Streaming function requires a caller that accepts text/event-stream` instead of
a truncated reply; a non-streaming (single-shot) answer over the capable path is
byte-identical to a normal RPC reply; idle expiry fails the stream in-band with the
standard 408 error body. The `x-ttl` request header (ms) is the idle allowance
between segments — your `first(..., ttl_seconds=...)` can extend it for the whole
stream. While the producer is quiet, the host emits `: ping` keep-alive comments
(`event.stream.keep.alive`, the engines' config key — default 30s, `0` disables).

## Consume a stream

`PostOffice.stream()` is the consumer surface — an async iterator yielding the same
decoded envelopes an engine reply route receives: `data` segments, then the terminal.
It works against a remote peer's `/api/event` (an engine or another function host)
and against local functions alike, and opting in is always safe — a non-streaming
target simply yields its one classic reply:

```python
from mercury_composable import PostOffice

async with PostOffice() as po:
async for segment in po.stream("hello.tokens", None,
endpoint="http://127.0.0.1:8100/api/event",
timeout_ms=30000):
marker = segment.headers.get("x-event-stream")
if marker == "data":
print(segment.body)
elif marker == "exception":
raise RuntimeError(segment.body["message"])
# eof: segment.body carries the trailing metadata, if any
```

`timeout_ms` is the idle allowance between segments. The consumer guards the dialect
for you: a malformed frame, a stream that ends without a terminal, or idle expiry
each yield the standard in-band exception envelope, then the iterator ends.

## Compose a relay

The pattern the whole streaming program is built on: forward **your own caller's**
reply address into a call against a remote streaming function, and the segments flow
`engine → your function → remote peer → back to the original caller` with no
buffering anywhere:

```python
@preload(route="llm.relay", instances=10, interceptor=True)
async def relay(headers: dict[str, str], event: EventEnvelope):
async with PostOffice() as po:
await po.stream_to("remote.tokens", None,
reply_to=event.reply_to or "",
endpoint="http://peer:8085/api/event",
cid=event.cid, timeout_ms=30000)
```

`stream_to()` forwards every decoded envelope verbatim to the named LOCAL route
(here, the reply sink the host opened for your caller) and returns the terminal.
Combined with a calling engine's `stream: true` endpoint, this streams a remote
peer's tokens progressively out that engine's HTTP edge — with zero imperative
streaming code in between.

## See also

- The engines' HTTP Response Streaming guides (the same contract at the HTTP edge):
[Java](https://accenture.github.io/mercury-composable/guides/http-streaming/) ·
[Rust](https://accenture.github.io/mercury/guides/http-streaming/)
- [Interop Test Report — Progressive Rendering](../test-reports/progressive-rendering-interop.md) —
the live four-runtime validation of this contract
- [HTTP Surface Reference](http-surface-reference.md) — the `/api/event` contract
- [Function Writing Patterns](function-patterns.md)
Loading
Loading