Files
2026-07-13 13:12:00 +08:00

1101 lines
40 KiB
Python

"""
Unit tests for the ``omnigent.runtime.telemetry`` helpers.
Exercises pure helpers (no spans created) and the trace-context
wrapper with an in-memory OTel exporter so the tests stay fast
and deterministic. Integration with the full workflow lives in
``test_telemetry_integration.py``.
"""
from __future__ import annotations
import os
from collections.abc import Iterator
import pytest
from fastapi import FastAPI
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from opentelemetry.trace import (
StatusCode,
)
from omnigent.runtime import telemetry
_RESP_HEX = "d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3"
_RESP_ID = f"resp_{_RESP_HEX}"
# ── Fixtures ────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _opt_in_telemetry(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""
Telemetry is opt-in (``OMNIGENT_TELEMETRY_ENABLED``, off by default).
This module exercises telemetry behavior, so opt in for every test; the
opt-out test clears it explicitly. Also resets the session-id contextvar
around each test so a ``set_session_id`` / hook call in one test (which
deliberately does not reset — prod requests are isolated async tasks)
cannot leak into the next.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setenv("OMNIGENT_TELEMETRY_ENABLED", "true")
token = telemetry._session_id_var.set(None)
try:
yield
finally:
telemetry._session_id_var.reset(token)
# init()/enable_tracing() in a telemetry test flips global tracing on
# and never resets it; clear it so it can't leak "tracing on" into
# other suites (e.g. the executor-adapter tests).
from omnigent.inner.tracing import disable_tracing
disable_tracing()
telemetry._initialized = False
@pytest.fixture
def in_memory_exporter(monkeypatch: pytest.MonkeyPatch) -> Iterator[InMemorySpanExporter]:
"""
Install a fresh SDK TracerProvider with an in-memory exporter
for the duration of one test.
"""
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
# Set via private attribute to bypass OTel's set-once guard.
otel_trace._TRACER_PROVIDER = provider # type: ignore[attr-defined]
otel_trace._TRACER_PROVIDER_SET_ONCE._done = True # type: ignore[attr-defined]
yield exporter
exporter.clear()
# ── parse_provider_name ─────────────────────────────────
@pytest.mark.parametrize(
"input_model,expected",
[
("openai/gpt-5.4", ("openai", "gpt-5.4")),
("anthropic/claude-sonnet-4", ("anthropic", "claude-sonnet-4")),
("gpt-5.4", ("", "gpt-5.4")),
("", ("", "")),
(
"vertex/publishers/google/models/gemini-2.0",
("vertex", "publishers/google/models/gemini-2.0"),
),
],
)
def test_parse_provider_name(input_model: str, expected: tuple[str, str]) -> None:
"""
:param input_model: Model string under test.
:param expected: Expected ``(provider, model)`` tuple.
"""
assert telemetry.parse_provider_name(input_model) == expected
# ── trace_id_from_response_id ───────────────────────────
def test_trace_id_from_response_id_valid() -> None:
"""
A well-formed response ID decodes to its 32-char hex suffix.
This proves operators can strip the ``resp_`` prefix and paste
the hex into a trace backend's lookup UI.
"""
assert telemetry.trace_id_from_response_id(_RESP_ID) == _RESP_HEX
def test_trace_id_from_response_id_wrong_prefix() -> None:
"""
An ID without the ``resp_`` prefix raises ValueError. This is
the first validation line — operators should not be able to
confuse conversation IDs (``conv_...``) for response IDs.
"""
with pytest.raises(ValueError, match="resp_"):
telemetry.trace_id_from_response_id("conv_" + _RESP_HEX)
def test_trace_id_from_response_id_short_hex_zero_padded() -> None:
"""
A short hex suffix (< 32 chars) is zero-padded to 32 chars.
Harness-allocated response IDs use 24-char hex; the padding
produces a valid 128-bit W3C trace ID.
"""
result = telemetry.trace_id_from_response_id("resp_abcdef")
assert result == "abcdef" + "0" * 26
assert len(result) == 32
def test_trace_id_from_response_id_too_long() -> None:
"""
A hex suffix longer than 32 chars raises ValueError.
"""
with pytest.raises(ValueError, match="at most"):
telemetry.trace_id_from_response_id("resp_" + "a" * 33)
def test_trace_id_from_response_id_invalid_hex() -> None:
"""
An ID whose hex suffix contains non-hex characters raises
ValueError. Non-hex input would produce an undefined int
conversion, so we catch it explicitly.
"""
bad_id = "resp_" + "Z" * 32
with pytest.raises(ValueError, match="hex"):
telemetry.trace_id_from_response_id(bad_id)
# ── _env_bool / should_capture_content ─────────────────
@pytest.mark.parametrize(
"raw,expected",
[
("true", True),
("TRUE", True),
("True", True),
("1", True),
("yes", True),
("false", False),
("0", False),
("", False),
("maybe", False),
],
)
def test_env_bool(
raw: str,
expected: bool,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
:param raw: The raw env var value.
:param expected: Expected parsed boolean.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setenv("_AP_TEST_FLAG", raw)
assert telemetry._env_bool("_AP_TEST_FLAG") is expected
def test_env_bool_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""Unset env vars read as ``False``."""
monkeypatch.delenv("_AP_TEST_FLAG", raising=False)
assert telemetry._env_bool("_AP_TEST_FLAG") is False
def test_should_capture_content_reflects_module_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``should_capture_content()`` reads the module-level flag that
:func:`init` populates from the env var.
"""
monkeypatch.setattr(telemetry, "_capture_content", False)
assert telemetry.should_capture_content() is False
monkeypatch.setattr(telemetry, "_capture_content", True)
assert telemetry.should_capture_content() is True
# ── trace_context_for_response ──────────────────────────
def test_trace_context_for_response_root(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
A span opened inside ``trace_context_for_response(response_id)``
has the trace_id derived from ``response_id`` — the full
omnigent-to-trace-backend lookup chain works end-to-end.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("invoke_agent"):
pass
spans = in_memory_exporter.get_finished_spans()
assert len(spans) == 1, f"expected 1 span, got {len(spans)}"
actual_hex = format(spans[0].context.trace_id, "032x")
assert actual_hex == _RESP_HEX, (
f"trace_id {actual_hex!r} does not match response ID hex {_RESP_HEX!r}"
)
def test_trace_context_for_response_sub_agent(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
When ``root_response_id`` is set, the sub-agent span nests
under the root's trace, not its own. This proves sub-agent
spawn workflows share the root's trace.
"""
tracer = otel_trace.get_tracer("test")
sub_response_id = "resp_" + "a" * 32
with telemetry.trace_context_for_response(
response_id=sub_response_id,
root_response_id=_RESP_ID,
):
with tracer.start_as_current_span("invoke_agent sub"):
pass
spans = in_memory_exporter.get_finished_spans()
assert len(spans) == 1
actual_hex = format(spans[0].context.trace_id, "032x")
# Sub-agent span has the ROOT response's trace ID, not its own.
assert actual_hex == _RESP_HEX, (
f"sub-agent trace_id {actual_hex!r} should match root response hex {_RESP_HEX!r}"
)
def test_trace_context_for_response_shared_across_children(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
Multiple child spans created inside the same trace context all
share one trace ID.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("invoke_agent"):
with tracer.start_as_current_span("chat"):
pass
with tracer.start_as_current_span("raw-otel-child"):
pass
spans = in_memory_exporter.get_finished_spans()
trace_ids = {format(s.context.trace_id, "032x") for s in spans}
assert trace_ids == {_RESP_HEX}, (
f"expected all spans to share trace_id {_RESP_HEX!r}, got {trace_ids!r}"
)
# ── get_traceparent_env ─────────────────────────────────
def test_get_traceparent_env_no_span() -> None:
"""
Outside of any span, ``get_traceparent_env`` returns an empty
dict — we do not invent a trace context for subprocess
inheritance when the parent has none.
"""
env = telemetry.get_traceparent_env()
assert env == {}, f"expected empty env dict outside of any span, got {env!r}"
def test_get_traceparent_env_inside_span(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
Inside an active span, ``get_traceparent_env`` returns a
``TRACEPARENT`` env var whose trace_id matches the active
span's trace_id.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("root"):
env = telemetry.get_traceparent_env()
assert "TRACEPARENT" in env, f"expected TRACEPARENT key, got {list(env.keys())!r}"
parts = env["TRACEPARENT"].split("-")
assert len(parts) == 4
version, trace_id_hex, _span_id_hex, _flags = parts
assert version == "00"
assert trace_id_hex == _RESP_HEX, (
f"traceparent trace_id {trace_id_hex!r} does not match response hex {_RESP_HEX!r}"
)
# ── record_llm_usage ────────────────────────────────────
def test_record_llm_usage_basic(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
``record_llm_usage`` sets OTel GenAI semantic convention attributes
for input/output/total tokens.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("chat") as span:
telemetry.record_llm_usage(
span,
{"input_tokens": 100, "output_tokens": 50},
)
spans = in_memory_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes or {}
assert attrs.get("gen_ai.usage.input_tokens") == 100
assert attrs.get("gen_ai.usage.output_tokens") == 50
# Total is derived (100 + 50 = 150) when not provided.
assert attrs.get("gen_ai.usage.total_tokens") == 150
def test_record_llm_usage_with_cache(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
Cache breakdown fields are recorded when present.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("chat") as span:
telemetry.record_llm_usage(
span,
{
"input_tokens": 1000,
"output_tokens": 200,
"total_tokens": 1200,
"cache_read_input_tokens": 800,
"cache_creation_input_tokens": 100,
},
)
spans = in_memory_exporter.get_finished_spans()
attrs = spans[0].attributes or {}
assert attrs.get("gen_ai.usage.input_tokens") == 1000
assert attrs.get("gen_ai.usage.output_tokens") == 200
assert attrs.get("gen_ai.usage.total_tokens") == 1200
assert attrs.get("gen_ai.usage.cache_read_input_tokens") == 800
assert attrs.get("gen_ai.usage.cache_creation_input_tokens") == 100
def test_record_llm_usage_without_cache_omits_fields(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
When cache fields are absent from the input dict, they are NOT
recorded — this prevents masking "caching not reported" as
"zero tokens cached".
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("chat") as span:
telemetry.record_llm_usage(
span,
{"input_tokens": 10, "output_tokens": 5},
)
spans = in_memory_exporter.get_finished_spans()
attrs = spans[0].attributes or {}
assert "gen_ai.usage.cache_read_input_tokens" not in attrs
assert "gen_ai.usage.cache_creation_input_tokens" not in attrs
# ── record_error / record_cancellation ─────────────────
def test_record_error_sets_error_type_and_status(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
``record_error`` marks the span as ERROR and sets ``error.type``
to the exception class name.
"""
tracer = otel_trace.get_tracer("test")
class CustomError(Exception):
pass
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("test") as span:
telemetry.record_error(span, CustomError("boom"))
spans = in_memory_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes or {}
assert attrs.get("error.type") == "CustomError"
assert spans[0].status.status_code == StatusCode.ERROR
def test_record_cancellation_sets_cancelled_error_type(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
Cancellation uses ``error.type = "cancelled"`` as the
distinguishing marker.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("test") as span:
telemetry.record_cancellation(span)
spans = in_memory_exporter.get_finished_spans()
attrs = spans[0].attributes or {}
assert attrs.get("error.type") == "cancelled"
assert spans[0].status.status_code == StatusCode.ERROR
# ── init() ──────────────────────────────────────────────
def test_init_no_endpoint_does_not_install_otlp_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When ``OTEL_EXPORTER_OTLP_ENDPOINT`` is unset, ``init`` must
NOT replace the global TracerProvider — callers may have already
installed their own.
"""
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
before = otel_trace.get_tracer_provider()
telemetry.init()
# No new provider installed when no endpoint is configured.
assert otel_trace.get_tracer_provider() is before
def test_init_with_endpoint_installs_sdk_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, ``init`` installs a
``TracerProvider`` backed by an OTLP span exporter.
"""
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
# Reset OTel set-once guard so init() can install a new provider.
otel_trace._TRACER_PROVIDER = None # type: ignore[attr-defined]
otel_trace._TRACER_PROVIDER_SET_ONCE._done = False # type: ignore[attr-defined]
telemetry.init()
assert isinstance(otel_trace.get_tracer_provider(), SdkTracerProvider)
def test_init_respects_capture_content_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``init`` reads ``OMNIGENT_OTEL_CAPTURE_CONTENT`` each call
so operators can toggle it after restart.
"""
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
monkeypatch.setenv("OMNIGENT_OTEL_CAPTURE_CONTENT", "true")
telemetry.init()
assert telemetry.should_capture_content() is True
monkeypatch.setenv("OMNIGENT_OTEL_CAPTURE_CONTENT", "false")
telemetry.init()
assert telemetry.should_capture_content() is False
def test_init_disabled_by_default_is_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Telemetry is opt-in: with ``OMNIGENT_TELEMETRY_ENABLED`` unset,
``init`` is a no-op even when an OTLP endpoint is configured — no
provider is installed, so a default install pays nothing.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.delenv("OMNIGENT_TELEMETRY_ENABLED", raising=False)
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
monkeypatch.setattr(telemetry, "_initialized", False)
assert telemetry.telemetry_enabled() is False
before = otel_trace.get_tracer_provider()
telemetry.init()
assert otel_trace.get_tracer_provider() is before
def test_fastapi_session_id_hook_stamps_session_id(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
The FastAPI server-request hook tags the server span with the
Omnigent session id parsed from a ``/sessions/<conv_…>/`` path, so
every session-scoped request span is findable by session.
:param in_memory_exporter: In-memory span exporter fixture.
"""
tracer = otel_trace.get_tracer("test")
span = tracer.start_span("POST /v1/sessions/{id}/events")
telemetry._fastapi_session_id_hook(span, {"path": "/v1/sessions/conv_deadbeef/events"})
span.end()
exported = in_memory_exporter.get_finished_spans()
assert exported[-1].attributes is not None
assert exported[-1].attributes.get("session.id") == "conv_deadbeef"
def test_fastapi_session_id_hook_ignores_non_session_paths(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
The hook leaves spans for non-session routes untouched.
:param in_memory_exporter: In-memory span exporter fixture.
"""
tracer = otel_trace.get_tracer("test")
span = tracer.start_span("GET /v1/info")
telemetry._fastapi_session_id_hook(span, {"path": "/v1/info"})
span.end()
exported = in_memory_exporter.get_finished_spans()
assert "session.id" not in (exported[-1].attributes or {})
def test_tracing_context_stamps_session_id_on_agent_span(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
Spans created by a session-scoped ``TracingContext`` carry the
``session.id`` attribute, so agent-turn spans — which can root their
own trace — stay groupable by session in the backend.
:param in_memory_exporter: In-memory span exporter fixture.
"""
from omnigent.inner.tracing import TracingContext
tctx = TracingContext(session_id="conv_abc123")
agent_span = tctx.start_agent_span("my-agent", "hello")
tctx.end_agent_span(agent_span, response="hi")
exported = in_memory_exporter.get_finished_spans()
agent_spans = [s for s in exported if s.name == "agent:my-agent"]
assert agent_spans, "expected an agent span to be exported"
assert agent_spans[-1].attributes.get("session.id") == "conv_abc123"
def test_set_session_id_stamps_current_span(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
``set_session_id`` tags the active span — used by session-creating
routes (``POST /v1/sessions``) where the conv id is minted in the body
and so is absent from the request path the FastAPI hook reads. A falsy
id is a no-op.
:param in_memory_exporter: In-memory span exporter fixture.
"""
tracer = otel_trace.get_tracer("test")
with tracer.start_as_current_span("POST /v1/sessions"):
telemetry.set_session_id("conv_cafef00d")
telemetry.set_session_id(None) # no-op, must not raise or clear
exported = in_memory_exporter.get_finished_spans()
assert exported[-1].attributes.get("session.id") == "conv_cafef00d"
def test_session_scope_processor_stamps_every_span() -> None:
"""
The generic mechanism: every span created inside ``session_scope`` is
tagged with ``session.id`` by ``_SessionIdSpanProcessor`` — no per-span
code — and spans outside the scope are left untouched.
"""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(telemetry._make_session_id_processor())
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("test")
with telemetry.session_scope("conv_generic01"):
with tracer.start_as_current_span("server.request"):
with tracer.start_as_current_span("db.query"): # child span, never stamped by hand
pass
with tracer.start_as_current_span("outside.scope"):
pass
spans = {s.name: s for s in exporter.get_finished_spans()}
assert spans["server.request"].attributes.get("session.id") == "conv_generic01"
assert spans["db.query"].attributes.get("session.id") == "conv_generic01"
assert "session.id" not in (spans["outside.scope"].attributes or {})
def test_init_sets_service_name_from_argument(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A passed ``service_name`` becomes ``OTEL_SERVICE_NAME``, overriding
any inherited value — so a child process (e.g. the runner spawned by
the server) is attributable as its own component rather than
inheriting the parent's name.
"""
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
# Simulate an inherited value from a parent process.
monkeypatch.setenv("OTEL_SERVICE_NAME", "omni-server")
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
telemetry.init("omni-runner")
assert os.environ["OTEL_SERVICE_NAME"] == "omni-runner"
def test_init_defaults_service_name_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With no argument and no operator-set ``OTEL_SERVICE_NAME``, the
service name defaults to ``omnigent`` so spans are never anonymous.
"""
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
telemetry.init()
assert os.environ["OTEL_SERVICE_NAME"] == "omnigent"
def test_init_honors_operator_service_name_without_argument(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With no argument but an operator-set ``OTEL_SERVICE_NAME``, the
operator's value is preserved.
"""
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setenv("OTEL_SERVICE_NAME", "my-deployment")
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
monkeypatch.setattr(telemetry, "_initialized", False)
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
monkeypatch.setattr(telemetry, "_logs_initialized", False)
telemetry.init()
assert os.environ["OTEL_SERVICE_NAME"] == "my-deployment"
def _stub_fastapi_instrumentor(monkeypatch: pytest.MonkeyPatch) -> list[FastAPI]:
"""
Replace ``FastAPIInstrumentor.instrument_app`` with a recorder.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: A list that accumulates each app passed to the
instrumentor — empty means instrumentation was skipped.
"""
calls: list[FastAPI] = []
monkeypatch.setattr(
"opentelemetry.instrumentation.fastapi.FastAPIInstrumentor.instrument_app",
lambda app, **kwargs: calls.append(app),
)
return calls
def test_instrument_fastapi_app_disabled_without_backend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With no flag and no tracing backend configured, FastAPI
instrumentation is skipped — bare installs pay no span overhead.
"""
monkeypatch.delenv("OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION", raising=False)
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
calls = _stub_fastapi_instrumentor(monkeypatch)
telemetry.instrument_fastapi_app(FastAPI())
assert calls == []
def test_instrument_fastapi_app_default_on_with_backend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With the flag unset, instrumentation defaults ON when an OTLP
endpoint is configured — that is when HTTP server spans have
somewhere to go and when cross-app trace propagation matters.
"""
monkeypatch.delenv("OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION", raising=False)
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
app = FastAPI()
calls = _stub_fastapi_instrumentor(monkeypatch)
telemetry.instrument_fastapi_app(app)
assert calls == [app]
def test_instrument_fastapi_app_explicit_false_overrides_backend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
An explicit ``OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION=false`` wins
even when a backend is configured — operators can force it off.
"""
monkeypatch.setenv("OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION", "false")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
calls = _stub_fastapi_instrumentor(monkeypatch)
telemetry.instrument_fastapi_app(FastAPI())
assert calls == []
def test_instrument_fastapi_app_calls_instrumentor_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The explicit flag installs OpenTelemetry FastAPI instrumentation
even with no backend configured (the in-memory-exporter test path).
"""
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setenv("OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION", "true")
app = FastAPI()
calls = _stub_fastapi_instrumentor(monkeypatch)
telemetry.instrument_fastapi_app(app)
assert calls == [app]
def test_instrument_httpx_wires_global_client() -> None:
"""
``_instrument_httpx`` installs the global HTTPX instrumentation so
outbound httpx requests inject ``traceparent``. Idempotent — a
second call must not raise. Uninstruments afterward to avoid
leaking global state into other tests.
"""
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
instrumentor = HTTPXClientInstrumentor()
was_instrumented = instrumentor.is_instrumented_by_opentelemetry
try:
telemetry._instrument_httpx()
assert instrumentor.is_instrumented_by_opentelemetry is True
# Idempotent: calling again is a no-op, not an error.
telemetry._instrument_httpx()
assert instrumentor.is_instrumented_by_opentelemetry is True
finally:
if not was_instrumented:
instrumentor.uninstrument()
def test_instrument_sqlalchemy_engine_instruments() -> None:
"""
``instrument_sqlalchemy_engine`` instruments a real engine so its
statements emit spans. Engine-scoped instrumentation does not leak
globally, so no teardown is needed.
"""
from sqlalchemy import create_engine
engine = create_engine("sqlite://")
# Must not raise; the call is the contract exercised at engine
# creation in ``db.utils.get_or_create_engine``.
telemetry.instrument_sqlalchemy_engine(engine)
def test_instrument_sqlalchemy_engine_missing_package_is_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the optional SQLAlchemy instrumentation package is absent,
the helper degrades to a no-op rather than raising — bare installs
without the tracing extras must still create engines.
"""
import sys
from sqlalchemy import create_engine
# Force the import inside the helper to fail with ImportError.
monkeypatch.setitem(sys.modules, "opentelemetry.instrumentation.sqlalchemy", None)
engine = create_engine("sqlite://")
# Should not raise despite the missing package.
telemetry.instrument_sqlalchemy_engine(engine)
def test_inject_extract_frame_round_trip(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
A frame injected under a span and consumed via ``consume_frame_span``
nests under the same trace — the JSON-frame websocket propagation
invariant (host tunnel, session-updates) holds end to end.
"""
tracer = otel_trace.get_tracer("test")
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("producer"):
frame = telemetry.inject_trace_context({"kind": "host.launch_runner"})
assert "traceparent" in frame
with telemetry.consume_frame_span("host.launch_runner", frame) as span:
consumed_hex = format(span.get_span_context().trace_id, "032x")
assert consumed_hex == _RESP_HEX, (
f"consumer trace {consumed_hex!r} should match producer trace "
f"{_RESP_HEX!r} — frame trace-context propagation is broken."
)
def test_inject_trace_context_noop_without_active_span() -> None:
"""
Outside any span, ``inject_trace_context`` leaves the carrier
unchanged so frames stay byte-for-byte wire-compatible.
"""
carrier = {"kind": "host.stat", "request_id": "req_1"}
result = telemetry.inject_trace_context(carrier)
assert result is carrier
assert "traceparent" not in carrier
def test_consume_frame_span_roots_new_trace_without_carrier(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
A carrier with no trace headers roots a fresh trace rather than
raising — a frame from a peer that never injected context is still
handled, just without an upstream parent.
"""
with telemetry.consume_frame_span("host.hello", {"kind": "host.hello"}) as span:
assert span.get_span_context().trace_id != 0
def test_consume_frame_span_omits_payload_when_capture_off(
in_memory_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With content capture off (the default), the frame body is NOT
attached to the span — only its structure/metadata is traced.
"""
monkeypatch.setattr(telemetry, "_capture_content", False)
with telemetry.consume_frame_span(
"host.launch_runner",
{"kind": "host.launch_runner", "workspace": "/tmp"},
):
pass
span = in_memory_exporter.get_finished_spans()[-1]
assert "omnigent.message.payload" not in (span.attributes or {})
def test_consume_frame_span_records_redacted_payload_when_capture_on(
in_memory_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With content capture on, the received frame body is attached to the
span — but secret-looking keys are redacted, the W3C propagation
keys are dropped, and benign fields are preserved verbatim.
"""
monkeypatch.setattr(telemetry, "_capture_content", True)
with telemetry.consume_frame_span(
"host.launch_runner",
{
"kind": "host.launch_runner",
"binding_token": "SUPER_SECRET",
"workspace": "/tmp/ws",
"traceparent": "00-abc-def-01",
},
):
pass
span = in_memory_exporter.get_finished_spans()[-1]
payload = (span.attributes or {})["omnigent.message.payload"]
assert "SUPER_SECRET" not in payload
assert "[redacted]" in payload
assert "traceparent" not in payload
assert "/tmp/ws" in payload
def test_record_message_payload_truncates(
in_memory_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
An oversized payload is capped so the trace backend never becomes a
payload store.
"""
monkeypatch.setattr(telemetry, "_capture_content", True)
with telemetry.span("x") as span:
telemetry.record_message_payload({"blob": "A" * 9000}, span=span)
out = in_memory_exporter.get_finished_spans()[-1]
payload = (out.attributes or {})["omnigent.message.payload"]
assert payload.endswith("…[truncated]")
assert len(payload) <= telemetry._CONTENT_MAX_LEN + len("…[truncated]")
def test_span_helper_emits_named_span_with_attributes(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
``telemetry.span`` emits a named child span with the given
attributes — the helper used to instrument plain infra boundaries
(terminal attach, policy evaluation).
"""
with telemetry.span(
"terminal.attach",
attributes={"session.id": "s1", "terminal.read_only": True},
):
pass
spans = in_memory_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].name == "terminal.attach"
assert spans[0].attributes["session.id"] == "s1"
assert spans[0].attributes["terminal.read_only"] is True
def test_span_helper_nests_under_active_trace(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
A span opened with ``telemetry.span`` nests under the currently
active trace context, so infra spans join the request/turn trace
rather than rooting their own.
"""
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with telemetry.span("policy.evaluate"):
pass
spans = in_memory_exporter.get_finished_spans()
assert len(spans) == 1
assert format(spans[0].context.trace_id, "032x") == _RESP_HEX
def test_httpx_to_fastapi_propagates_trace_across_http_hop(
in_memory_exporter: InMemorySpanExporter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A real HTTP hop continues the caller's trace.
With HTTPX client instrumentation (inject) and FastAPI server
instrumentation (extract) both active, a request made inside a
parent span must run its server-side handler in the *same* trace.
This is the Phase 1 propagation invariant that makes the
server -> runner -> harness HTTP/tunnel mesh render as one trace:
httpx injects ``traceparent``, FastAPI extracts it, and the handler
nests under the caller rather than rooting a new trace.
"""
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from starlette.testclient import TestClient
monkeypatch.setenv("OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION", "true")
app = FastAPI()
handler_trace_id: dict[str, str] = {}
@app.get("/ping")
def ping() -> dict[str, bool]:
"""
Record the trace ID active inside the server handler.
:returns: A trivial JSON body.
"""
ctx = otel_trace.get_current_span().get_span_context()
handler_trace_id["value"] = format(ctx.trace_id, "032x")
return {"ok": True}
telemetry.instrument_fastapi_app(app)
httpx_instrumentor = HTTPXClientInstrumentor()
was_instrumented = httpx_instrumentor.is_instrumented_by_opentelemetry
telemetry._instrument_httpx()
tracer = otel_trace.get_tracer("test")
try:
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("client-call"):
# Starlette's TestClient runs on an instrumented httpx
# client, so the outbound request carries traceparent.
response = TestClient(app).get("/ping")
assert response.status_code == 200
finally:
if not was_instrumented:
httpx_instrumentor.uninstrument()
FastAPIInstrumentor.uninstrument_app(app)
# The server handler ran under the caller's derived trace, proving
# the traceparent crossed the HTTP boundary and was extracted.
assert handler_trace_id.get("value") == _RESP_HEX, (
f"server handler trace_id {handler_trace_id.get('value')!r} does "
f"not match caller trace {_RESP_HEX!r} — traceparent did not "
"propagate across the HTTP hop (inject or extract is broken)."
)
def test_instrument_httpx_client_injects_over_custom_transport(
in_memory_exporter: InMemorySpanExporter,
) -> None:
"""
A client on a custom transport propagates only after instrument_client.
The process-wide httpx instrumentation patches only httpx's *standard*
transports, so a client built on a custom ``AsyncBaseTransport`` — the
server->runner ``WSTunnelTransport`` — is invisible to it: outbound
requests carry no ``traceparent`` and the runner roots a disconnected
trace. :func:`telemetry.instrument_httpx_client` wraps the instance to
close that gap. This guards the server->runner forward staying in the
caller's trace; without the per-client instrumentation the dispatch
hop silently splits into two traces again.
"""
import asyncio
import httpx
captured: dict[str, dict[str, str]] = {}
class _CapturingTransport(httpx.AsyncBaseTransport):
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
captured["headers"] = dict(request.headers)
return httpx.Response(200, request=request)
tracer = otel_trace.get_tracer("test")
async def _call(client: httpx.AsyncClient) -> None:
with telemetry.trace_context_for_response(response_id=_RESP_ID):
with tracer.start_as_current_span("client-call"):
await client.get("http://runner/v1/ping")
await client.aclose()
# Baseline: a custom transport is not reached by the global hook, so no
# context rides along regardless of whether global httpx is instrumented.
captured.clear()
bare = httpx.AsyncClient(transport=_CapturingTransport(), base_url="http://runner")
asyncio.run(_call(bare))
assert "traceparent" not in captured["headers"], (
"a custom-transport client unexpectedly injected traceparent without "
"instrument_client — the per-client fix may be unnecessary; re-evaluate."
)
# After instrument_client the traceparent rides the custom transport,
# pinned to the caller's response-derived trace.
captured.clear()
wrapped = httpx.AsyncClient(transport=_CapturingTransport(), base_url="http://runner")
telemetry.instrument_httpx_client(wrapped)
asyncio.run(_call(wrapped))
traceparent = captured["headers"].get("traceparent")
assert traceparent is not None and _RESP_HEX in traceparent, (
f"traceparent {traceparent!r} missing or not pinned to caller trace "
f"{_RESP_HEX!r} after instrument_client — the server->runner forward "
"would not stay in the originating trace."
)