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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/mercury_composable/otel/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`` attributes
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions tests/test_otel_forwarder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading