Skip to content

Commit 4001203

Browse files
committed
test: route-versioning audit + OTel semconv backstop tests (#130, #132, #147)
1 parent 5b8284d commit 4001203

4 files changed

Lines changed: 332 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "harness-python-react"
3-
version = "0.2.4"
3+
version = "0.2.5"
44
description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend."
55
readme = "README.md"
66
requires-python = ">=3.14"

tests/test_otel_semconv.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""Assertions that emitted spans use OTel semantic-convention attribute names.
2+
3+
Test-side enforcement of the rule "use the official OTel semconv attribute
4+
names where one exists" (see `src/observability/spans.py` and
5+
`docs/INVARIANTS.md`).
6+
7+
The scaffold ships only semconv-defined keys (no custom ones); the
8+
`ALLOWED_CUSTOM_KEYS` set below is intentionally empty. When a project
9+
that extends this template needs a custom attribute (e.g.
10+
`agent.tool_calls_count`), it MUST add a constant to
11+
`src/observability/spans.py` AND register it in `ALLOWED_CUSTOM_KEYS` —
12+
the registry-closure test fails otherwise. That's the forcing function
13+
that keeps custom keys to their documented set rather than letting them
14+
proliferate as one-off raw strings.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import TYPE_CHECKING
20+
21+
import pytest
22+
from opentelemetry import trace
23+
from opentelemetry.sdk.trace import TracerProvider
24+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
25+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
26+
InMemorySpanExporter,
27+
)
28+
29+
from src.observability import spans as spans_module
30+
from src.observability.spans import (
31+
DB_QUERY_TEXT,
32+
DB_RESPONSE_RETURNED_ROWS,
33+
GENAI_CONVERSATION_ID,
34+
GENAI_OPERATION_NAME,
35+
GENAI_PROVIDER_NAME,
36+
GENAI_REQUEST_MODEL,
37+
GENAI_TOOL_CALL_ARGUMENTS,
38+
GENAI_TOOL_CALL_RESULT,
39+
GENAI_TOOL_NAME,
40+
GENAI_USAGE_INPUT_TOKENS,
41+
GENAI_USAGE_OUTPUT_TOKENS,
42+
agent_span,
43+
)
44+
45+
if TYPE_CHECKING:
46+
from collections.abc import Sequence
47+
48+
from opentelemetry.sdk.trace import ReadableSpan
49+
50+
51+
SEMCONV_KEYS: frozenset[str] = frozenset(
52+
{
53+
GENAI_CONVERSATION_ID,
54+
GENAI_REQUEST_MODEL,
55+
GENAI_OPERATION_NAME,
56+
GENAI_PROVIDER_NAME,
57+
GENAI_USAGE_INPUT_TOKENS,
58+
GENAI_USAGE_OUTPUT_TOKENS,
59+
GENAI_TOOL_NAME,
60+
GENAI_TOOL_CALL_ARGUMENTS,
61+
GENAI_TOOL_CALL_RESULT,
62+
DB_QUERY_TEXT,
63+
DB_RESPONSE_RETURNED_ROWS,
64+
}
65+
)
66+
67+
# Custom keys allowed because no semconv equivalent exists. Empty in the
68+
# scaffold — projects that extend this template add entries here as they
69+
# add custom span attributes (and constants in `src/observability/spans.py`).
70+
ALLOWED_CUSTOM_KEYS: frozenset[str] = frozenset()
71+
72+
ACCEPTED_KEYS: frozenset[str] = SEMCONV_KEYS | ALLOWED_CUSTOM_KEYS
73+
74+
75+
@pytest.fixture
76+
def memory_exporter(monkeypatch: pytest.MonkeyPatch) -> InMemorySpanExporter:
77+
"""Install a TracerProvider that records spans into an in-memory exporter.
78+
79+
Uses `monkeypatch.setattr` to swap `trace.get_tracer_provider` for the
80+
test's lifetime instead of `trace.set_tracer_provider` — the OTel SDK
81+
refuses to override the global provider once any other test has called
82+
`set_tracer_provider`, which would silently route spans to the wrong
83+
exporter.
84+
85+
Uses `SimpleSpanProcessor` so spans are visible to `get_finished_spans()`
86+
immediately after the span context exits — no flush race.
87+
"""
88+
exporter = InMemorySpanExporter()
89+
provider = TracerProvider()
90+
provider.add_span_processor(SimpleSpanProcessor(exporter))
91+
monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider)
92+
return exporter
93+
94+
95+
def _span_by_name(spans: Sequence[ReadableSpan], name: str) -> ReadableSpan:
96+
"""Return the unique span with the given name (assert exactly one)."""
97+
matches = [s for s in spans if s.name == name]
98+
assert len(matches) == 1, (
99+
f"expected exactly one span named {name!r}, got {len(matches)} "
100+
f"(all names: {[s.name for s in spans]})"
101+
)
102+
return matches[0]
103+
104+
105+
def test_tool_call_span_carries_genai_tool_attributes(
106+
memory_exporter: InMemorySpanExporter,
107+
) -> None:
108+
"""A tool-call span carries the documented `gen_ai.tool.*` keys."""
109+
tool_attrs: dict[str, str | int] = {
110+
GENAI_TOOL_NAME: "echo",
111+
GENAI_TOOL_CALL_ARGUMENTS: '{"msg": "hi"}',
112+
GENAI_TOOL_CALL_RESULT: '{"echoed": "hi"}',
113+
}
114+
with agent_span("tool.echo", tool_attrs):
115+
pass
116+
117+
spans = memory_exporter.get_finished_spans()
118+
tool_span = _span_by_name(spans, "tool.echo")
119+
assert tool_span.attributes is not None
120+
keys = set(tool_span.attributes)
121+
122+
assert GENAI_TOOL_NAME in keys
123+
assert GENAI_TOOL_CALL_ARGUMENTS in keys
124+
assert GENAI_TOOL_CALL_RESULT in keys
125+
assert tool_span.attributes[GENAI_TOOL_NAME] == "echo"
126+
127+
128+
def test_llm_call_span_carries_genai_attributes(
129+
memory_exporter: InMemorySpanExporter,
130+
) -> None:
131+
"""An LLM-style span carries the documented `gen_ai.*` keys + token usage."""
132+
llm_attrs: dict[str, str | int] = {
133+
GENAI_CONVERSATION_ID: "session-test-1",
134+
GENAI_REQUEST_MODEL: "gpt-4o-mini",
135+
GENAI_OPERATION_NAME: "chat",
136+
GENAI_PROVIDER_NAME: "openai",
137+
GENAI_USAGE_INPUT_TOKENS: 42,
138+
GENAI_USAGE_OUTPUT_TOKENS: 100,
139+
}
140+
with agent_span("agent.llm_call", llm_attrs):
141+
pass
142+
143+
spans = memory_exporter.get_finished_spans()
144+
llm_span = _span_by_name(spans, "agent.llm_call")
145+
assert llm_span.attributes is not None
146+
147+
for key, value in llm_attrs.items():
148+
assert key in llm_span.attributes, f"missing {key}"
149+
assert llm_span.attributes[key] == value
150+
151+
152+
def test_db_query_span_carries_db_attributes(
153+
memory_exporter: InMemorySpanExporter,
154+
) -> None:
155+
"""A DB-query span carries `db.query.text` + `db.response.returned_rows`."""
156+
db_attrs: dict[str, str | int] = {
157+
DB_QUERY_TEXT: "SELECT 1",
158+
DB_RESPONSE_RETURNED_ROWS: 1,
159+
}
160+
with agent_span("db.example_query", db_attrs):
161+
pass
162+
163+
spans = memory_exporter.get_finished_spans()
164+
db_span = _span_by_name(spans, "db.example_query")
165+
assert db_span.attributes is not None
166+
keys = set(db_span.attributes)
167+
168+
assert DB_QUERY_TEXT in keys
169+
assert DB_RESPONSE_RETURNED_ROWS in keys
170+
171+
172+
def test_no_unregistered_attribute_keys_emitted(
173+
memory_exporter: InMemorySpanExporter,
174+
) -> None:
175+
"""Every emitted attribute key is in `SEMCONV_KEYS` or `ALLOWED_CUSTOM_KEYS`."""
176+
with agent_span(
177+
"tool.echo",
178+
{
179+
GENAI_TOOL_NAME: "echo",
180+
GENAI_TOOL_CALL_ARGUMENTS: "{}",
181+
GENAI_TOOL_CALL_RESULT: "{}",
182+
},
183+
):
184+
pass
185+
186+
spans = memory_exporter.get_finished_spans()
187+
188+
rogue: list[tuple[str, str]] = []
189+
for span in spans:
190+
if span.attributes is None:
191+
continue
192+
for key in span.attributes:
193+
if key not in ACCEPTED_KEYS:
194+
rogue.append((span.name, key))
195+
196+
assert not rogue, (
197+
"Span attributes emitted with keys outside the documented registry: "
198+
f"{rogue}. Either use a semconv name from `src/observability/spans.py` "
199+
"or add the new key to both `spans.py` (with a comment explaining why "
200+
"no semconv equivalent applies) and `ALLOWED_CUSTOM_KEYS` in this test."
201+
)
202+
203+
204+
def test_spans_module_constants_are_registered() -> None:
205+
"""Every uppercase `str` constant in `spans.py` belongs to the test allowlist.
206+
207+
The allowlist is `SEMCONV_KEYS | ALLOWED_CUSTOM_KEYS`. Catches the
208+
"added a new constant in `spans.py` without updating the test" drift class.
209+
If this fails, either:
210+
211+
- the new constant is a semconv name → add it to `SEMCONV_KEYS`.
212+
- the new constant is a custom name → add it to `ALLOWED_CUSTOM_KEYS`
213+
AND ensure the comment in `spans.py` explains why no semconv applies.
214+
"""
215+
declared = {
216+
getattr(spans_module, name)
217+
for name in dir(spans_module)
218+
if name.isupper() and isinstance(getattr(spans_module, name), str)
219+
}
220+
unregistered = declared - ACCEPTED_KEYS
221+
assert not unregistered, (
222+
f"`spans.py` defines constants not in the test allowlist: "
223+
f"{sorted(unregistered)!r}. Update `SEMCONV_KEYS` or "
224+
"`ALLOWED_CUSTOM_KEYS` in this test."
225+
)

tests/test_route_versioning.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Audit every FastAPI route is `/api/v*/...` or in the documented allowlist.
2+
3+
Backstops invariant 7 in `docs/INVARIANTS.md` (was *aspirational on test*).
4+
A future contributor adding `@app.get("/foo")` instead of registering the
5+
endpoint on the versioned `APIRouter(prefix="/api/v1")` would silently
6+
introduce an un-versioned route — breaking the API-versioning contract from
7+
`docs/DEVELOPMENT.md` without any gate firing. This test is the gate.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
14+
from src.api.main import app
15+
16+
VERSIONED_PREFIX = re.compile(r"^/api/v\d+/")
17+
18+
# Routes deliberately NOT under `/api/v*/`. Add an entry here only with a
19+
# comment naming the structural reason — never just "we needed an exception".
20+
UNVERSIONED_ALLOWLIST: frozenset[str] = frozenset(
21+
{
22+
# FastAPI auto-generated documentation surface. Disabled by passing
23+
# docs_url=None / redoc_url=None to FastAPI() if needed; the
24+
# scaffold keeps them enabled at the unversioned root.
25+
"/openapi.json",
26+
"/docs",
27+
"/docs/oauth2-redirect",
28+
"/redoc",
29+
}
30+
)
31+
32+
33+
def test_every_route_is_versioned_or_allowlisted() -> None:
34+
"""Every routable path is `/api/v*/` or in the explicit allowlist."""
35+
offenders: list[str] = []
36+
for route in app.routes:
37+
path = getattr(route, "path", None)
38+
if path is None:
39+
# Not an APIRoute (e.g. Mount). Skip cleanly — `path` is the
40+
# discriminator we care about.
41+
continue
42+
if path in UNVERSIONED_ALLOWLIST:
43+
continue
44+
if not VERSIONED_PREFIX.match(path):
45+
offenders.append(path)
46+
47+
assert not offenders, (
48+
f"un-versioned route(s) detected in app.routes: {offenders!r}. Per "
49+
"docs/DEVELOPMENT.md API Versioning, every routable path must match "
50+
"`/api/v*/<…>` or be added to UNVERSIONED_ALLOWLIST in this test "
51+
"with a comment naming the structural reason. Common entry points "
52+
'for an un-versioned path: a router wired without `prefix="/api/vN"`, '
53+
"an `@app.<verb>` decorator on the FastAPI app instead of a versioned "
54+
"APIRouter, a manual `app.add_api_route(...)` call, or a Mount that "
55+
"exposes paths at the unversioned root. Fix at the wiring site or, "
56+
"if the path is genuinely unversioned by design (cf. `/api/health`), "
57+
"add it to the allowlist."
58+
)
59+
60+
61+
def test_allowlist_has_no_dead_entries() -> None:
62+
"""Every UNVERSIONED_ALLOWLIST entry must correspond to a real route.
63+
64+
Without this assertion, removing a docs surface (e.g. constructing
65+
FastAPI with `docs_url=None`) leaves `/docs` in the allowlist as cruft;
66+
over time the allowlist becomes a graveyard that masks what is
67+
*actually* deliberately unversioned. Any drop-out fails fast and forces
68+
a deletion in the same PR.
69+
"""
70+
actual_paths = {getattr(r, "path", None) for r in app.routes}
71+
actual_paths.discard(None)
72+
dead = sorted(UNVERSIONED_ALLOWLIST - actual_paths)
73+
assert not dead, (
74+
f"UNVERSIONED_ALLOWLIST entries no longer present in app.routes: "
75+
f"{dead}. Remove the dead entry — keeping it is allowlist cruft. "
76+
"If the path was intentionally removed (e.g. FastAPI docs disabled "
77+
"via `docs_url=None`), drop the corresponding allowlist line in "
78+
"this same PR so the allowlist tracks reality."
79+
)
80+
81+
82+
# ---------- Regex sanity checks ----------
83+
#
84+
# Belt-and-braces against the allowlist swallowing a typo that the route
85+
# walk wouldn't catch (e.g. someone mutating the regex to be too permissive).
86+
87+
88+
def test_regex_accepts_v1_routes() -> None:
89+
assert VERSIONED_PREFIX.match("/api/v1/chat")
90+
assert VERSIONED_PREFIX.match("/api/v1/sessions")
91+
assert VERSIONED_PREFIX.match("/api/v1/sessions/{session_id}")
92+
93+
94+
def test_regex_accepts_future_major_versions() -> None:
95+
"""`/api/v2/...` and beyond are accepted; the rule isn't v1-only."""
96+
assert VERSIONED_PREFIX.match("/api/v2/chat")
97+
assert VERSIONED_PREFIX.match("/api/v10/some-endpoint")
98+
99+
100+
def test_regex_rejects_unversioned_paths() -> None:
101+
assert not VERSIONED_PREFIX.match("/foo")
102+
assert not VERSIONED_PREFIX.match("/api/foo")
103+
assert not VERSIONED_PREFIX.match("/api/health") # in allowlist, not regex
104+
assert not VERSIONED_PREFIX.match("/api/v/chat") # missing version digit
105+
assert not VERSIONED_PREFIX.match("/api/version1/chat") # wrong prefix shape

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)