headroom/tests/test_observability_tracing.py
Krishna Chaitanya 7f7af667ed
feat(observability): add gen_ai.request.model to the compression span (#1667)
## Description

Emit the OpenTelemetry GenAI semantic-convention attribute
`gen_ai.request.model` on the existing `headroom.compression.pipeline`
span, alongside the current `headroom.*` attributes. Today Headroom's
OTel spans use only proprietary `headroom.*` names, so a team pointing
an OTel-native backend at Headroom can't join its telemetry to their
existing `gen_ai.*` LLM dashboards. This makes the compression span
groupable/filterable by the standard schema.

Proposed and scoped in #1671. Per CONTRIBUTING (new features want a
maintainer 👍 + spec first), this is opened as a **draft** to get
sign-off on the approach and v1 scope before finalizing.

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Short spec

- API surface: one additive span attribute, `gen_ai.request.model`, on
the existing `headroom.compression.pipeline` span. No new endpoints,
headers, or config; nothing renamed.
- Scope (v1, deliberately minimal): only `gen_ai.request.model` — the
one gen_ai attribute this pre-flight compression span can set correctly
and unconditionally (the model is always known here).
- Deferred to v2 (each needs work this span cannot do correctly, and I'd
value your steer on all three):
- `gen_ai.operation.name`: `apply()` is shared by many callers (chat,
`/v1/compress`, batch, Gemini `countTokens`), so no single hardcoded
value is right — it has to be threaded from each caller.
- `gen_ai.provider.name`: Headroom's provider label can't distinguish
Bedrock/Gemini from Anthropic/OpenAI at this layer (Bedrock routes
through the Anthropic provider).
- `gen_ai.usage.*`: provider-authoritative usage lives on the response
path, not this span; the compressed-input estimate stays under
`headroom.tokens.after`.
- Failure modes: model missing → attribute omitted (never a blank
string); span not recording / `record_metrics=False` → no attribute, no
crash.
- Security: no new input surface; derived from data already on the span.

## Changes Made

- `headroom/transforms/pipeline.py`: emit `gen_ai.request.model` on the
pipeline span (guarded on model present), with a comment documenting why
the other gen_ai.* attributes are deferred.
- `tests/test_observability_tracing.py`: assert the attribute is
emitted, the deferred attrs are omitted, the model-missing guard, and
the non-recording path.
- `CHANGELOG.md`: Unreleased → Features entry.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

### Test Output

```text
$ uv run pytest tests/test_observability_tracing.py -q
7 passed

$ uv run pytest tests/test_observability_tracing.py tests/test_compression_observability.py \
    tests/test_observability_metrics.py tests/test_pipeline.py tests/test_canonical_pipeline.py tests/test_telemetry.py -q
76 passed

$ uv run ruff check .  &&  uv run mypy headroom/transforms/pipeline.py --ignore-missing-imports
All checks passed!  /  Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: local, Python 3.12, `opentelemetry-sdk` 1.39.1, real
`ConsoleSpanExporter` (not a mock). Ran the actual
`TransformPipeline.apply()` emission path.
- Exact command / steps: configured a real `TracerProvider` +
`ConsoleSpanExporter`, set it as Headroom's tracer, ran
`TransformPipeline([]).apply([{user msg}],
model="claude-3-5-sonnet-20241022", model_limit=8192)`, then
`force_flush()` and inspected the exported span.
- Observed result: the exported `headroom.compression.pipeline` span
carries `gen_ai.request.model` alongside the existing `headroom.*`
attributes:

  ```json
  "attributes": {
      "headroom.model": "claude-3-5-sonnet-20241022",
      "headroom.provider": "unknown",
      "headroom.message_count": 1,
      "headroom.tokens.before": 83,
      "gen_ai.request.model": "claude-3-5-sonnet-20241022",
      "headroom.tokens.after": 83,
      "headroom.tokens.saved": 0
  }
  ```

- Not tested: no live OTLP collector / Grafana backend (used the console
exporter, which is the same span pipeline); the deferred v2 attributes
(`operation.name`/`provider.name`/`usage.*`) are intentionally not
emitted.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

(Draft: awaiting a maintainer 👍 on the approach and the v1 scope before
marking ready.)

## Additional Notes

Purely additive and back-compatible — no `headroom.*` attribute changed
or removed. The gen_ai attribute name is a string literal because the
`gen_ai.*` conventions are stability=development in the semconv registry
(no stable constants published). No new dependencies.

Signed-off-by: Krishnachaitanyakc <krishnabkc15@gmail.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-11 11:04:54 -05:00

141 lines
5.6 KiB
Python

"""Tests for Langfuse/OTEL tracing helpers."""
from __future__ import annotations
import pytest
from opentelemetry.sdk.resources import Resource
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 headroom.observability import (
HeadroomTracer,
LangfuseTracingConfig,
get_langfuse_tracing_status,
reset_headroom_tracing,
set_headroom_tracer,
)
from headroom.transforms.pipeline import TransformPipeline
def test_langfuse_tracing_config_builds_trace_endpoint() -> None:
config = LangfuseTracingConfig(
enabled=True,
public_key="pk-lf-test",
secret_key="sk-lf-test",
base_url="https://cloud.langfuse.com",
service_name="headroom-proxy",
)
assert config.endpoint == "https://cloud.langfuse.com/api/public/otel/v1/traces"
assert config.headers["x-langfuse-ingestion-version"] == "4"
assert config.headers["Authorization"].startswith("Basic ")
assert "sk-lf-test" not in repr(config)
def test_transform_pipeline_emits_trace_spans() -> None:
exporter = InMemorySpanExporter()
provider = TracerProvider(resource=Resource.create({"service.name": "headroom-test"}))
provider.add_span_processor(SimpleSpanProcessor(exporter))
set_headroom_tracer(HeadroomTracer(tracer_provider=provider))
try:
pipeline = TransformPipeline(transforms=[])
messages = [{"role": "user", "content": "hello world"}]
pipeline.apply(messages, model="gpt-4o", model_limit=1024)
spans = exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "headroom.compression.pipeline"
assert span.attributes["headroom.model"] == "gpt-4o"
assert span.attributes["headroom.tokens.before"] >= 1
assert span.attributes["headroom.tokens.after"] >= 1
finally:
reset_headroom_tracing()
def test_pipeline_span_emits_gen_ai_request_model() -> None:
"""The compression-pipeline span carries the v1 OTel GenAI semconv descriptor
(gen_ai.request.model) alongside headroom.*, so it groups by the standard
schema. operation.name / provider.name / usage.* are intentionally v2."""
exporter = InMemorySpanExporter()
provider = TracerProvider(resource=Resource.create({"service.name": "headroom-test"}))
provider.add_span_processor(SimpleSpanProcessor(exporter))
set_headroom_tracer(HeadroomTracer(tracer_provider=provider))
try:
pipeline = TransformPipeline(transforms=[])
pipeline.apply(
[{"role": "user", "content": "hello world"}],
model="claude-3-5-sonnet-20241022",
model_limit=1024,
)
span = exporter.get_finished_spans()[0]
assert span.name == "headroom.compression.pipeline"
assert span.attributes["gen_ai.request.model"] == "claude-3-5-sonnet-20241022"
# v1 deliberately emits ONLY request.model — no operation/provider/usage
# (each is inaccurate at this span; see pipeline.py).
assert "gen_ai.operation.name" not in span.attributes
assert "gen_ai.provider.name" not in span.attributes
assert "gen_ai.usage.input_tokens" not in span.attributes
finally:
reset_headroom_tracing()
def test_pipeline_span_omits_request_model_when_model_missing() -> None:
"""gen_ai.request.model is omitted (not set to an empty string) when no model
is provided — never emit a blank standard attribute."""
exporter = InMemorySpanExporter()
provider = TracerProvider(resource=Resource.create({"service.name": "headroom-test"}))
provider.add_span_processor(SimpleSpanProcessor(exporter))
set_headroom_tracer(HeadroomTracer(tracer_provider=provider))
try:
pipeline = TransformPipeline(transforms=[])
pipeline.apply([{"role": "user", "content": "hi"}], model="", model_limit=1024)
span = exporter.get_finished_spans()[0]
assert span.name == "headroom.compression.pipeline"
assert "gen_ai.request.model" not in span.attributes
finally:
reset_headroom_tracing()
def test_pipeline_runs_with_metrics_disabled() -> None:
"""record_metrics=False takes the nullcontext (no-span) path: the run still
returns a valid result and emits no spans (guards that building span_attributes
with the gen_ai key never breaks the non-recording path)."""
exporter = InMemorySpanExporter()
provider = TracerProvider(resource=Resource.create({"service.name": "headroom-test"}))
provider.add_span_processor(SimpleSpanProcessor(exporter))
set_headroom_tracer(HeadroomTracer(tracer_provider=provider))
try:
pipeline = TransformPipeline(transforms=[])
result = pipeline.apply(
[{"role": "user", "content": "hi"}],
model="gpt-4o",
model_limit=1024,
record_metrics=False,
)
assert result.messages # pipeline produced output
assert exporter.get_finished_spans() == ()
finally:
reset_headroom_tracing()
def test_langfuse_tracing_status_defaults_to_unconfigured() -> None:
reset_headroom_tracing()
status = get_langfuse_tracing_status()
assert status["configured"] is False
assert status["enabled"] is False
def test_langfuse_tracing_requires_explicit_enable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
config = LangfuseTracingConfig.from_env(default_service_name="headroom-proxy")
assert config.enabled is False