|
| 1 | +"""Tests for src/observability/ — tracing, logging, span helpers.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import json |
| 6 | +import logging |
| 7 | +from io import StringIO |
| 8 | + |
| 9 | +import pytest |
| 10 | +from opentelemetry import trace |
| 11 | +from opentelemetry.sdk.trace import TracerProvider |
| 12 | +from opentelemetry.sdk.trace.export import SimpleSpanProcessor |
| 13 | +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( |
| 14 | + InMemorySpanExporter, |
| 15 | +) |
| 16 | + |
| 17 | +from src.observability.logging import _JSONFormatter, setup_logging |
| 18 | +from src.observability.spans import ( |
| 19 | + GENAI_CONVERSATION_ID, |
| 20 | + GENAI_OPERATION_NAME, |
| 21 | + GENAI_REQUEST_MODEL, |
| 22 | + GENAI_TOOL_NAME, |
| 23 | + agent_span, |
| 24 | + set_span_attributes, |
| 25 | +) |
| 26 | + |
| 27 | + |
| 28 | +@pytest.fixture() |
| 29 | +def exporter() -> InMemorySpanExporter: |
| 30 | + """Attach an in-memory span exporter to the (possibly pre-existing) |
| 31 | + global tracer provider. Clears captured spans on entry so each test |
| 32 | + sees a clean slate even when OTel's provider override has already |
| 33 | + been set by a prior test.""" |
| 34 | + captured = InMemorySpanExporter() |
| 35 | + provider = trace.get_tracer_provider() |
| 36 | + if not isinstance(provider, TracerProvider): |
| 37 | + provider = TracerProvider() |
| 38 | + trace.set_tracer_provider(provider) |
| 39 | + provider.add_span_processor(SimpleSpanProcessor(captured)) |
| 40 | + captured.clear() |
| 41 | + return captured |
| 42 | + |
| 43 | + |
| 44 | +def test_agent_span_records(exporter: InMemorySpanExporter) -> None: |
| 45 | + with agent_span("op", attributes={GENAI_OPERATION_NAME: "echo"}): |
| 46 | + pass |
| 47 | + |
| 48 | + spans = exporter.get_finished_spans() |
| 49 | + assert len(spans) == 1 |
| 50 | + assert spans[0].name == "op" |
| 51 | + attrs = dict(spans[0].attributes or {}) |
| 52 | + assert attrs[GENAI_OPERATION_NAME] == "echo" |
| 53 | + |
| 54 | + |
| 55 | +def test_set_span_attributes_skips_none(exporter: InMemorySpanExporter) -> None: |
| 56 | + with agent_span("op") as span: |
| 57 | + set_span_attributes( |
| 58 | + span, |
| 59 | + **{ |
| 60 | + GENAI_REQUEST_MODEL: "gpt-4o-mini", |
| 61 | + GENAI_CONVERSATION_ID: None, |
| 62 | + GENAI_TOOL_NAME: "echo_tool", |
| 63 | + }, |
| 64 | + ) |
| 65 | + |
| 66 | + spans = exporter.get_finished_spans() |
| 67 | + attrs = dict(spans[0].attributes or {}) |
| 68 | + assert attrs[GENAI_REQUEST_MODEL] == "gpt-4o-mini" |
| 69 | + assert attrs[GENAI_TOOL_NAME] == "echo_tool" |
| 70 | + assert GENAI_CONVERSATION_ID not in attrs |
| 71 | + |
| 72 | + |
| 73 | +def test_semconv_attributes_have_dotted_names() -> None: |
| 74 | + """Sanity check: every exported semconv key uses the official dotted form.""" |
| 75 | + for key in ( |
| 76 | + GENAI_CONVERSATION_ID, |
| 77 | + GENAI_REQUEST_MODEL, |
| 78 | + GENAI_OPERATION_NAME, |
| 79 | + GENAI_TOOL_NAME, |
| 80 | + ): |
| 81 | + assert key.startswith("gen_ai.") |
| 82 | + |
| 83 | + |
| 84 | +def test_json_formatter_emits_trace_and_span_ids() -> None: |
| 85 | + formatter = _JSONFormatter() |
| 86 | + record = logging.LogRecord( |
| 87 | + name="test", |
| 88 | + level=logging.INFO, |
| 89 | + pathname="test.py", |
| 90 | + lineno=1, |
| 91 | + msg="hello %s", |
| 92 | + args=("world",), |
| 93 | + exc_info=None, |
| 94 | + ) |
| 95 | + record.otelTraceID = "abc123" |
| 96 | + record.otelSpanID = "def456" |
| 97 | + |
| 98 | + payload = json.loads(formatter.format(record)) |
| 99 | + assert payload["message"] == "hello world" |
| 100 | + assert payload["trace_id"] == "abc123" |
| 101 | + assert payload["span_id"] == "def456" |
| 102 | + |
| 103 | + |
| 104 | +def test_setup_logging_attaches_json_handler( |
| 105 | + monkeypatch: pytest.MonkeyPatch, |
| 106 | +) -> None: |
| 107 | + monkeypatch.setenv("LOG_LEVEL", "DEBUG") |
| 108 | + setup_logging() |
| 109 | + |
| 110 | + root = logging.getLogger() |
| 111 | + assert root.level == logging.DEBUG |
| 112 | + assert any(isinstance(h.formatter, _JSONFormatter) for h in root.handlers) |
| 113 | + |
| 114 | + # Round-trip: emit a log record and assert the JSON is single-line valid. |
| 115 | + handler = next(h for h in root.handlers if isinstance(h.formatter, _JSONFormatter)) |
| 116 | + assert isinstance(handler, logging.StreamHandler) |
| 117 | + buf = StringIO() |
| 118 | + handler.setStream(buf) |
| 119 | + root.info("ping") |
| 120 | + line = buf.getvalue().strip() |
| 121 | + assert "\n" not in line |
| 122 | + parsed = json.loads(line) |
| 123 | + assert parsed["message"] == "ping" |
| 124 | + assert parsed["level"] == "INFO" |
0 commit comments