From 75a6e0550412a96812afa71b97315dd6ddbe22b7 Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:41:29 -0700 Subject: [PATCH] OpenTelemetry forwarder: the edge's round-trip record is the SERVER span What - span kind: SERVER iff the record's service is http.request (an engine edge's round-trip record); every function execution is INTERNAL - a record's "from" no longer decides it. - tests pin both kinds (a function execution encodes as kind 1; the edge record as kind 2); CHANGELOG "Unreleased" carries the behaviour change. Why The engines now record the HTTP round trip as its own span (mercury-composable and mercury fix/connected-edge-spans) and map only that record to SERVER; the four forwarders keep one rule so a mixed-runtime trace renders one consistent tree. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 ++++++++ src/mercury_composable/otel/span.py | 8 ++++++-- tests/test_otel_forwarder.py | 20 ++++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e643e3..b375c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased (4.12.15 in preparation) +### Changed + +- The forwarder's span kind now follows the engines' connected-span-tree rule: the record whose + `service` is `http.request` - an engine edge's round-trip record - is the `SERVER` span, and every + function execution is `INTERNAL` (a record's `from` no longer decides the kind). A host's own + records are function executions, so a backend now shows them as `INTERNAL` hops under the + calling engine's edge span. + ### Added - The **OpenTelemetry trace forwarder** (`mercury_composable.otel`), the lock-step twin of the diff --git a/src/mercury_composable/otel/span.py b/src/mercury_composable/otel/span.py index ff2f122..1402095 100644 --- a/src/mercury_composable/otel/span.py +++ b/src/mercury_composable/otel/span.py @@ -17,7 +17,8 @@ ``service`` (route) span name (``path``, then ``task``, when absent) ``start`` + ``exec_time`` start / end timestamps ``success`` / ``status`` / ``exception`` status OK, or ERROR with a description -``from`` = http.request kind SERVER (else INTERNAL) +``service`` = http.request kind SERVER - the edge's round-trip record; a function + execution is INTERNAL (its ``from`` does not decide the kind) ``path``, ``from``, ``origin``, ``status``, ``exec_time_ms``, ``round_trip_ms``, ``exception`` attributes (same names); ``service`` is the ``route`` attribute ``annotations`` entries ``annotation.`` attributes @@ -114,7 +115,10 @@ def span_from_dataset(dataset: Any) -> Span | None: service = _display(trace.get("service")) path = _display(trace.get("path")) name = service or path or "task" - kind = KIND_SERVER if _display(trace.get("from")) == HTTP_REQUEST else KIND_INTERNAL + # the edge's round-trip record (service "http.request", emitted by an engine's REST + # automation when the response completes) is the SERVER span; every function execution + # - including the first one, whose "from" is http.request - is an INTERNAL hop under it + kind = KIND_SERVER if service == HTTP_REQUEST else KIND_INTERNAL attributes: list[tuple[str, AttributeValue]] = [] def put_str(key: str, value: str | None) -> None: diff --git a/tests/test_otel_forwarder.py b/tests/test_otel_forwarder.py index da716af..c9e64fc 100644 --- a/tests/test_otel_forwarder.py +++ b/tests/test_otel_forwarder.py @@ -273,7 +273,8 @@ def test_span_mapping_preserves_the_ids_and_the_metrics() -> None: assert span.span_id_hex == SPAN_ID assert span.parent_span_id_hex == PARENT_SPAN_ID assert span.name == "hello.world" - assert span.kind == KIND_SERVER + # a function execution is an INTERNAL hop, even the first one (from=http.request) + assert span.kind == KIND_INTERNAL assert span.status_code == STATUS_OK assert span.start_unix_nano == 1_782_295_200_000_000_000 assert span.end_unix_nano - span.start_unix_nano == 12_500_000 @@ -329,7 +330,8 @@ def test_encoding_round_trips_through_the_reader() -> None: assert len(decoded.spans) == 1 got = decoded.spans[0] assert (got.trace_id, got.span_id, got.parent_span_id) == (TRACE_ID, SPAN_ID, PARENT_SPAN_ID) - assert got.name == "hello.world" and got.kind == 2 and got.status_code == 1 + # a function execution encodes as kind 1 (INTERNAL); only the edge's round-trip record is SERVER + assert got.name == "hello.world" and got.kind == 1 and got.status_code == 1 assert got.flags == otlp.SPAN_FLAGS_SAMPLED_LOCAL assert got.start_unix_nano == 1_782_295_200_000_000_000 assert got.end_unix_nano - got.start_unix_nano == 12_500_000 @@ -542,3 +544,17 @@ async def _wait_for(condition: Callable[[], bool], timeout: float = 5.0) -> None raise AssertionError("condition not met in time") await asyncio.sleep(0.02) + +def test_edge_round_trip_record_is_the_server_span() -> None: + # an engine's REST automation emits one record per traced request with service + # "http.request" - the round trip from receipt to the completed response; it is the + # SERVER span and the first function's parent (the same rule as the engines' forwarders) + dataset = sample_dataset(service="http.request", path="GET /api/hello", exec_time=2016.0) + del dataset["trace"]["from"] + span = span_from_dataset(dataset) + assert span is not None + assert span.name == "http.request" + assert span.kind == KIND_SERVER + assert span.end_unix_nano - span.start_unix_nano == 2_016_000_000 + assert span.attribute("path") == "GET /api/hello" + assert span.attribute("from") is None