mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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>
141 lines
5.6 KiB
Python
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
|