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

941 lines
29 KiB
Python

"""Tests for server performance metrics tracking."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from dataclasses import dataclass, field
import httpx
import pytest
from asgiref.testing import ApplicationCommunicator
from fastapi import FastAPI, Request
from opentelemetry.util.types import Attributes
from starlette.types import Scope
from omnigent.server.performance_metrics import (
RequestDurationAccessFormatter,
ServerMetricsOtelPublisher,
ServerMetricsSnapshot,
ServerPerformanceMetrics,
SystemLoadAverage,
publish_server_metrics_periodically,
set_request_duration_for_access_log,
set_request_id_for_access_log,
set_request_session_id_for_access_log,
set_request_user_agent_for_access_log,
)
def _default_load_average() -> SystemLoadAverage:
"""
Return the default fake load average used by metrics tests.
:returns: One-, five-, and fifteen-minute load averages.
"""
return SystemLoadAverage(
one_minute=1.0,
five_minutes=2.0,
fifteen_minutes=3.0,
)
@dataclass
class _FakeMetricInputs:
"""
Deterministic metric input source for unit tests.
:param wall: Monotonic wall time in seconds.
:param cpu: Process CPU time in seconds.
:param rss: Resident memory in bytes.
:param load: One-, five-, and fifteen-minute load average values.
"""
wall: float = 0.0
cpu: float = 0.0
rss: int = 128 * 1024 * 1024
load: SystemLoadAverage = field(default_factory=_default_load_average)
def clock(self) -> float:
"""
Return the current fake wall time.
:returns: Monotonic wall time in seconds.
"""
return self.wall
def process_time(self) -> float:
"""
Return the current fake process CPU time.
:returns: Process CPU time in seconds.
"""
return self.cpu
def rss_bytes(self) -> int:
"""
Return fake resident memory usage.
:returns: Resident memory in bytes.
"""
return self.rss
def load_average(self) -> SystemLoadAverage:
"""
Return fake system load averages.
:returns: One-, five-, and fifteen-minute load averages.
"""
return self.load
@dataclass(frozen=True)
class _MetricRecord:
"""
Recorded metric value from a fake OpenTelemetry instrument.
:param amount: Numeric metric value.
:param attributes: Metric attributes recorded with the value.
"""
amount: int | float
attributes: Attributes
@dataclass
class _FakeCounter:
"""
Fake OpenTelemetry counter that records ``add`` calls.
:param name: Instrument name, e.g.
``"omnigent.server.http.requests.started"``.
:param records: Values added to the counter.
"""
name: str
records: list[_MetricRecord] = field(default_factory=list)
def add(self, amount: int | float, attributes: Attributes = None) -> None:
"""
Record a counter delta.
:param amount: Counter delta.
:param attributes: Optional metric attributes.
"""
self.records.append(_MetricRecord(amount=amount, attributes=attributes))
@dataclass
class _FakeGauge:
"""
Fake OpenTelemetry gauge that records ``set`` calls.
:param name: Instrument name, e.g.
``"omnigent.server.http.requests.in_flight"``.
:param records: Values set on the gauge.
"""
name: str
records: list[_MetricRecord] = field(default_factory=list)
def set(self, amount: int | float, attributes: Attributes = None) -> None:
"""
Record a gauge value.
:param amount: Gauge value.
:param attributes: Optional metric attributes.
"""
self.records.append(_MetricRecord(amount=amount, attributes=attributes))
@dataclass
class _FakeHistogram:
"""
Fake OpenTelemetry histogram that records ``record`` calls.
:param name: Instrument name, e.g.
``"omnigent.server.http.request.duration"``.
:param records: Values recorded in the histogram.
"""
name: str
records: list[_MetricRecord] = field(default_factory=list)
def record(self, amount: int | float, attributes: Attributes = None) -> None:
"""
Record a histogram sample.
:param amount: Histogram sample value.
:param attributes: Optional metric attributes.
"""
self.records.append(_MetricRecord(amount=amount, attributes=attributes))
@dataclass
class _FakeMeter:
"""
Fake OpenTelemetry meter that creates recording instruments.
:param counters: Counters created by metric name.
:param gauges: Gauges created by metric name.
:param histograms: Histograms created by metric name.
"""
counters: dict[str, _FakeCounter] = field(default_factory=dict)
gauges: dict[str, _FakeGauge] = field(default_factory=dict)
histograms: dict[str, _FakeHistogram] = field(default_factory=dict)
def create_counter(
self,
name: str,
unit: str = "",
description: str = "",
) -> _FakeCounter:
"""
Create and record a fake counter.
:param name: Metric name.
:param unit: Metric unit.
:param description: Metric description.
:returns: Fake counter instrument.
"""
counter = _FakeCounter(name)
self.counters[name] = counter
return counter
def create_gauge(
self,
name: str,
unit: str = "",
description: str = "",
) -> _FakeGauge:
"""
Create and record a fake gauge.
:param name: Metric name.
:param unit: Metric unit.
:param description: Metric description.
:returns: Fake gauge instrument.
"""
gauge = _FakeGauge(name)
self.gauges[name] = gauge
return gauge
def create_histogram(
self,
name: str,
unit: str = "",
description: str = "",
) -> _FakeHistogram:
"""
Create and record a fake histogram.
:param name: Metric name.
:param unit: Metric unit.
:param description: Metric description.
:returns: Fake histogram instrument.
"""
histogram = _FakeHistogram(name)
self.histograms[name] = histogram
return histogram
def _fake_metrics(inputs: _FakeMetricInputs) -> ServerPerformanceMetrics:
"""
Build a metrics tracker wired to fake inputs.
:param inputs: Fake input source for clocks and process metrics.
:returns: Metrics tracker using deterministic input functions.
"""
return ServerPerformanceMetrics(
clock=inputs.clock,
process_time_fn=inputs.process_time,
rss_bytes_fn=inputs.rss_bytes,
load_avg_fn=inputs.load_average,
)
def _websocket_scope(path: str) -> Scope:
"""
Build a minimal ASGI WebSocket scope for metrics tests.
:param path: WebSocket path, e.g.
``"/v1/runners/metrics-runner/tunnel"``.
:returns: ASGI WebSocket scope accepted by FastAPI.
"""
return {
"type": "websocket",
"asgi": {"version": "3.0"},
"scheme": "ws",
"path": path,
"raw_path": path.encode("ascii"),
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 50000),
"server": ("testserver", 80),
"subprotocols": [],
}
def test_snapshot_reports_rolling_request_windows_and_process_metrics() -> None:
"""
Rolling counters include request starts and processing time.
A request started at t=0 falls out of the 30s window at t=31,
while requests at t=5 and t=25 remain. Completed request timings
are averaged by completion time, so only the t=25 completion is in
the 10s processing window at t=31.
"""
inputs = _FakeMetricInputs()
metrics = _fake_metrics(inputs)
started_at = metrics.request_started()
inputs.wall = 0.1
metrics.request_finished(started_at=started_at)
inputs.wall = 5.0
started_at = metrics.request_started()
inputs.wall = 5.4
metrics.request_finished(started_at=started_at, failed=True)
inputs.wall = 25.0
started_at = metrics.request_started()
inputs.wall = 25.5
metrics.request_finished(started_at=started_at)
inputs.wall = 31.0
inputs.cpu = 2.5
snapshot = metrics.snapshot()
assert snapshot.in_flight == 0
assert snapshot.total_started == 3
assert snapshot.total_completed == 3
assert snapshot.total_failed == 1
assert snapshot.requests_last_1s == 0
assert snapshot.requests_last_10s == 1
assert snapshot.requests_last_30s == 2
assert snapshot.active_websockets == 0
assert snapshot.request_processing_avg_ms == pytest.approx((0.1 + 0.4 + 0.5) / 3 * 1000.0)
assert snapshot.request_processing_max_ms == pytest.approx(500.0)
assert snapshot.request_processing_avg_1s_ms == 0.0
assert snapshot.request_processing_avg_10s_ms == pytest.approx(500.0)
assert snapshot.request_processing_avg_30s_ms == pytest.approx((0.4 + 0.5) / 2 * 1000.0)
assert snapshot.process_cpu_percent == pytest.approx(2.5 / 31.0 * 100.0)
assert snapshot.load_average_1m == 1.0
assert snapshot.load_average_5m == 2.0
assert snapshot.load_average_15m == 3.0
assert snapshot.rss_bytes == 128 * 1024 * 1024
assert snapshot.rss_mib == 128.0
def test_snapshot_tracks_active_websocket_connections() -> None:
"""
Active WebSocket counters increment, decrement, and never underflow.
If accepted WebSocket lifecycle tracking regresses, the exact
snapshot assertions below catch both missing increments and
double-decrement underflow.
"""
metrics = _fake_metrics(_FakeMetricInputs())
metrics.websocket_connected()
metrics.websocket_connected()
assert metrics.snapshot().active_websockets == 2
metrics.websocket_disconnected()
assert metrics.snapshot().active_websockets == 1
metrics.websocket_disconnected()
metrics.websocket_disconnected()
assert metrics.snapshot().active_websockets == 0
def test_request_duration_access_formatter_appends_individual_duration() -> None:
"""
Uvicorn access logs include individual request processing duration.
A regression that drops the millisecond suffix from the Uvicorn access
formatter would recreate the operator problem where access logs
show that a request happened but not how long it spent in the
server.
"""
formatter = RequestDurationAccessFormatter(
fmt='%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
use_colors=False,
)
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=0,
msg='%s - "%s %s HTTP/%s" %d',
args=(
"10.125.55.247:0",
"GET",
"/health?session_ids=conv_one%2Cconv_two",
"1.1",
200,
),
exc_info=None,
)
set_request_duration_for_access_log(0.12345)
try:
assert formatter.format(record) == (
'INFO: 10.125.55.247:0 - "GET '
'/health?session_ids=conv_one%2Cconv_two HTTP/1.1" '
"200 OK 123.5ms"
)
assert formatter.format(record) == (
'INFO: 10.125.55.247:0 - "GET '
'/health?session_ids=conv_one%2Cconv_two HTTP/1.1" '
"200 OK"
)
finally:
set_request_duration_for_access_log(None)
def _make_access_record() -> logging.LogRecord:
"""
Build a minimal Uvicorn-style access log record.
:returns: A ``LogRecord`` matching the shape Uvicorn's access
logger emits.
"""
return logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=0,
msg='%s - "%s %s HTTP/%s" %d',
args=(
"10.0.0.1:0",
"GET",
"/v1/sessions/conv_abc/events",
"1.1",
200,
),
exc_info=None,
)
def _make_formatter() -> RequestDurationAccessFormatter:
"""
Create a formatter with colours disabled for deterministic assertions.
:returns: Configured ``RequestDurationAccessFormatter``.
"""
return RequestDurationAccessFormatter(
fmt='%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
use_colors=False,
)
def test_access_formatter_includes_request_id() -> None:
"""Request ID appears in the access log when set."""
formatter = _make_formatter()
record = _make_access_record()
set_request_id_for_access_log("aabbccdd11223344")
try:
output = formatter.format(record)
assert "rid=aabbccdd11223344" in output
finally:
set_request_id_for_access_log(None)
def test_access_formatter_includes_user_agent() -> None:
"""User-Agent header appears quoted in the access log when set."""
formatter = _make_formatter()
record = _make_access_record()
set_request_user_agent_for_access_log("python-httpx/0.27")
try:
output = formatter.format(record)
assert 'ua="python-httpx/0.27"' in output
finally:
set_request_user_agent_for_access_log(None)
def test_access_formatter_truncates_long_user_agent() -> None:
"""User-Agent values longer than 80 characters are truncated."""
formatter = _make_formatter()
record = _make_access_record()
long_ua = "A" * 120
set_request_user_agent_for_access_log(long_ua)
try:
output = formatter.format(record)
assert f'ua="{"A" * 80}"' in output
assert "A" * 81 not in output
finally:
set_request_user_agent_for_access_log(None)
def test_access_formatter_sanitizes_user_agent_control_chars() -> None:
"""A crafted User-Agent cannot inject newlines or terminal escapes."""
formatter = _make_formatter()
record = _make_access_record()
# CRLF log-forging attempt plus an ANSI escape sequence.
set_request_user_agent_for_access_log(
'evil\r\n10.0.0.1 - "GET /admin" 200\x1b[2J',
)
try:
output = formatter.format(record)
# The whole access line stays on one physical line.
assert "\n" not in output
assert "\r" not in output
assert "\x1b" not in output
# Sanitized field is present with control chars replaced by '?'.
assert "ua=" in output
finally:
set_request_user_agent_for_access_log(None)
def test_access_formatter_sanitizes_quote_in_user_agent() -> None:
"""An embedded double quote cannot break out of the quoted ua field."""
formatter = _make_formatter()
record = _make_access_record()
set_request_user_agent_for_access_log('foo" bar')
try:
output = formatter.format(record)
# Exactly one opening and one closing quote around the field.
assert 'ua="foo? bar"' in output
finally:
set_request_user_agent_for_access_log(None)
def test_access_formatter_sanitizes_session_id_control_chars() -> None:
"""Control chars surviving URL parsing are stripped from the sid field."""
formatter = _make_formatter()
record = _make_access_record()
# \x1b (ANSI ESC) survives Starlette's URL path parsing; ensure it
# cannot reach the log line unescaped.
set_request_session_id_for_access_log("conv_abc\x1b[31m")
try:
output = formatter.format(record)
assert "\x1b" not in output
assert "sid=conv_abc?[31m" in output
finally:
set_request_session_id_for_access_log(None)
def test_access_formatter_includes_session_id() -> None:
"""Session ID appears in the access log when set."""
formatter = _make_formatter()
record = _make_access_record()
set_request_session_id_for_access_log("conv_abc")
try:
output = formatter.format(record)
assert "sid=conv_abc" in output
finally:
set_request_session_id_for_access_log(None)
def test_access_formatter_includes_all_fields() -> None:
"""All enrichment fields appear together in the expected order."""
formatter = _make_formatter()
record = _make_access_record()
set_request_duration_for_access_log(0.005)
set_request_id_for_access_log("deadbeef")
set_request_user_agent_for_access_log("curl/8.0")
set_request_session_id_for_access_log("conv_xyz")
try:
output = formatter.format(record)
assert "5.0ms" in output
assert "rid=deadbeef" in output
assert 'ua="curl/8.0"' in output
assert "sid=conv_xyz" in output
# Verify ordering: duration before rid before ua before sid.
dur_pos = output.index("5.0ms")
rid_pos = output.index("rid=")
ua_pos = output.index("ua=")
sid_pos = output.index("sid=")
assert dur_pos < rid_pos < ua_pos < sid_pos
finally:
set_request_duration_for_access_log(None)
set_request_id_for_access_log(None)
set_request_user_agent_for_access_log(None)
set_request_session_id_for_access_log(None)
def test_access_formatter_clears_context_after_format() -> None:
"""Context variables are cleared after formatting so they don't leak."""
formatter = _make_formatter()
record = _make_access_record()
set_request_id_for_access_log("first_req")
set_request_user_agent_for_access_log("test-agent/1.0")
set_request_session_id_for_access_log("conv_123")
first = formatter.format(record)
assert "rid=first_req" in first
assert 'ua="test-agent/1.0"' in first
assert "sid=conv_123" in first
second = formatter.format(record)
assert "rid=" not in second
assert "ua=" not in second
assert "sid=" not in second
def test_access_formatter_omits_absent_fields() -> None:
"""Fields not set via context variables are omitted, not blank."""
formatter = _make_formatter()
record = _make_access_record()
set_request_id_for_access_log("only_rid")
try:
output = formatter.format(record)
assert "rid=only_rid" in output
assert "ua=" not in output
assert "sid=" not in output
finally:
set_request_id_for_access_log(None)
@pytest.mark.asyncio
async def test_middleware_sets_request_id_header_and_access_log_context(
app: FastAPI,
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The metrics middleware sets X-Request-Id on responses and populates
access log context variables for request ID, User-Agent, and
session ID.
"""
from omnigent.server import app as server_app
captured_ids: list[str | None] = []
captured_uas: list[str | None] = []
captured_sids: list[str | None] = []
_original_set_rid = set_request_id_for_access_log
_original_set_ua = set_request_user_agent_for_access_log
_original_set_sid = set_request_session_id_for_access_log
def spy_rid(v: str | None) -> None:
captured_ids.append(v)
_original_set_rid(v)
def spy_ua(v: str | None) -> None:
captured_uas.append(v)
_original_set_ua(v)
def spy_sid(v: str | None) -> None:
captured_sids.append(v)
_original_set_sid(v)
monkeypatch.setattr(server_app, "set_request_id_for_access_log", spy_rid)
monkeypatch.setattr(server_app, "set_request_user_agent_for_access_log", spy_ua)
monkeypatch.setattr(server_app, "set_request_session_id_for_access_log", spy_sid)
resp = await client.get("/health")
assert resp.status_code == 200
assert "x-request-id" in resp.headers
assert len(resp.headers["x-request-id"]) == 32 # uuid4 hex
assert len(captured_ids) == 1
assert captured_ids[0] == resp.headers["x-request-id"]
assert len(captured_uas) == 1
assert captured_uas[0] is not None # httpx sends a User-Agent
assert len(captured_sids) == 1
assert captured_sids[0] is None # /health has no session ID
@pytest.mark.asyncio
async def test_middleware_extracts_session_id_from_path(
app: FastAPI,
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The middleware extracts the session ID from session-scoped URL paths.
"""
from omnigent.server import app as server_app
captured_sids: list[str | None] = []
_original = set_request_session_id_for_access_log
def spy_sid(v: str | None) -> None:
captured_sids.append(v)
_original(v)
monkeypatch.setattr(server_app, "set_request_session_id_for_access_log", spy_sid)
# This will 404 but the middleware still runs and extracts the ID.
await client.get("/v1/sessions/conv_test123/events")
assert len(captured_sids) == 1
assert captured_sids[0] == "conv_test123"
def test_otel_publisher_emits_snapshot_values_and_counter_deltas() -> None:
"""
The OTEL publisher exports every snapshot metric value.
Counter assertions prove cumulative snapshot totals are converted
to deltas; gauge assertions prove the same operational fields as
the periodic log line are emitted.
"""
meter = _FakeMeter()
publisher = ServerMetricsOtelPublisher(meter=meter)
first = ServerMetricsSnapshot(
in_flight=2,
total_started=9,
total_completed=7,
total_failed=1,
requests_last_1s=3,
requests_last_10s=5,
requests_last_30s=8,
active_websockets=4,
request_processing_avg_ms=123.45,
request_processing_max_ms=987.65,
request_processing_avg_1s_ms=111.11,
request_processing_avg_10s_ms=222.22,
request_processing_avg_30s_ms=333.33,
process_cpu_percent=12.34,
load_average_1m=1.25,
load_average_5m=2.5,
load_average_15m=3.75,
rss_bytes=64 * 1024 * 1024,
)
second = ServerMetricsSnapshot(
in_flight=1,
total_started=11,
total_completed=10,
total_failed=1,
requests_last_1s=2,
requests_last_10s=4,
requests_last_30s=6,
active_websockets=3,
request_processing_avg_ms=222.0,
request_processing_max_ms=999.0,
request_processing_avg_1s_ms=100.0,
request_processing_avg_10s_ms=200.0,
request_processing_avg_30s_ms=300.0,
process_cpu_percent=50.0,
load_average_1m=None,
load_average_5m=None,
load_average_15m=None,
rss_bytes=32 * 1024 * 1024,
)
publisher.publish(first)
publisher.publish(second)
assert [
record.amount for record in meter.counters["omnigent.server.http.requests.started"].records
] == [9, 2]
assert [
record.amount
for record in meter.counters["omnigent.server.http.requests.completed"].records
] == [7, 3]
assert [
record.amount for record in meter.counters["omnigent.server.http.requests.failed"].records
] == [1]
assert meter.gauges["omnigent.server.http.requests.in_flight"].records[-1].amount == 1
assert meter.gauges["omnigent.server.http.requests.last_1s"].records[-1].amount == 2
assert meter.gauges["omnigent.server.http.requests.last_10s"].records[-1].amount == 4
assert meter.gauges["omnigent.server.http.requests.last_30s"].records[-1].amount == 6
assert meter.gauges["omnigent.server.websocket.connections.active"].records[-1].amount == 3
assert meter.gauges["omnigent.server.http.request.processing.avg"].records[-1].amount == 222.0
assert meter.gauges["omnigent.server.process.cpu.percent"].records[-1].amount == 50.0
assert meter.gauges["omnigent.server.system.load_average.1m"].records[-1].amount == 1.25
assert meter.gauges["omnigent.server.process.memory.rss"].records[-1].amount == (
32 * 1024 * 1024
)
def test_otel_publisher_records_request_duration_histogram() -> None:
"""
Request durations are exported as individual OTEL histogram samples.
"""
meter = _FakeMeter()
publisher = ServerMetricsOtelPublisher(meter=meter)
publisher.record_request_duration(
duration_seconds=0.125,
failed=True,
method="POST",
route="/v1/sessions/{session_id}",
status_code=500,
)
records = meter.histograms["omnigent.server.http.request.duration"].records
assert records == [
_MetricRecord(
amount=0.125,
attributes={
"failed": True,
"http.request.method": "POST",
"http.route": "/v1/sessions/{session_id}",
"http.response.status_code": 500,
},
),
]
@pytest.mark.asyncio
async def test_publish_server_metrics_periodically_exports_until_cancelled() -> None:
"""
The periodic publisher emits metric snapshots through OTEL.
The test waits for concrete fake OTEL records, so it fails if the
publisher stops snapshotting or forgets to publish counter/gauge
values. Cancelling the task then verifies shutdown does not hang.
"""
inputs = _FakeMetricInputs()
metrics = _fake_metrics(inputs)
metrics.request_started()
meter = _FakeMeter()
publisher = ServerMetricsOtelPublisher(meter=meter)
task = asyncio.create_task(
publish_server_metrics_periodically(
metrics,
otel_publisher=publisher,
interval_seconds=0.01,
)
)
async def wait_for_started_counter() -> None:
"""
Wait until the fake OTEL started counter receives a record.
"""
while not meter.counters["omnigent.server.http.requests.started"].records:
await asyncio.sleep(0.01)
try:
await asyncio.wait_for(wait_for_started_counter(), timeout=1.0)
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert [
record.amount for record in meter.counters["omnigent.server.http.requests.started"].records
] == [1]
assert meter.gauges["omnigent.server.http.requests.in_flight"].records[-1].amount == 1
@pytest.mark.asyncio
async def test_create_app_metrics_middleware_counts_http_requests(
app: FastAPI,
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``create_app`` wires metrics middleware into real HTTP handling.
Driving ``GET /health`` through the test ASGI client exercises the
same middleware path production requests use, then checks the
app-owned tracker rather than a test-local stand-in.
"""
from omnigent.server import app as server_app
before = app.state.server_metrics.snapshot()
access_log_durations: list[float | None] = []
def spy_set_request_duration_for_access_log(
duration_seconds: float | None,
) -> None:
"""
Capture the duration handed from middleware to access logging.
:param duration_seconds: Request processing duration in
seconds, or ``None`` when cleared.
"""
access_log_durations.append(duration_seconds)
monkeypatch.setattr(
server_app,
"set_request_duration_for_access_log",
spy_set_request_duration_for_access_log,
)
resp = await client.get(
"/health",
params={"session_ids": "conv_one,conv_two"},
)
after = app.state.server_metrics.snapshot()
assert resp.status_code == 200
assert after.total_started == before.total_started + 1
assert after.total_completed == before.total_completed + 1
assert after.total_failed == before.total_failed
assert after.in_flight == 0
assert after.active_websockets == before.active_websockets
assert after.requests_last_1s >= 1
assert after.requests_last_10s >= 1
assert after.requests_last_30s >= 1
assert after.request_processing_avg_ms >= 0.0
assert after.request_processing_max_ms >= 0.0
assert after.request_processing_avg_1s_ms >= 0.0
assert after.request_processing_avg_10s_ms >= 0.0
assert after.request_processing_avg_30s_ms >= 0.0
assert len(access_log_durations) == 1
assert access_log_durations[0] is not None
assert access_log_durations[0] >= 0.0
def test_request_route_template_for_metrics_uses_sentinel_for_unmatched_routes() -> None:
"""
Unmatched routes do not use the raw URL path as a metric label.
A request scope with no matched Starlette route catches the
cardinality regression where scanner paths or probe IDs leak into the
``http.route`` OTEL attribute.
"""
from omnigent.server.app import request_route_template_for_metrics
scope: Scope = {
"type": "http",
"method": "GET",
"path": "/scanner/probe/conv_1234567890abcdef",
"headers": [],
"query_string": b"",
}
assert request_route_template_for_metrics(Request(scope)) == "<unmatched>"
@pytest.mark.asyncio
async def test_create_app_websocket_metrics_count_accepted_connections(
app: FastAPI,
) -> None:
"""
``create_app`` wires metrics middleware into real WebSocket handling.
The runner tunnel route accepts before waiting for its hello frame,
which lets the test observe the active counter while the accepted
WebSocket remains open.
"""
before = app.state.server_metrics.snapshot()
communicator = ApplicationCommunicator(
app,
_websocket_scope("/v1/runners/metrics-runner/tunnel"),
)
try:
await communicator.send_input({"type": "websocket.connect"})
accepted = await communicator.receive_output(timeout=1.0)
assert accepted["type"] == "websocket.accept"
during = app.state.server_metrics.snapshot()
assert during.active_websockets == before.active_websockets + 1
finally:
await communicator.send_input({"type": "websocket.disconnect", "code": 1000})
await communicator.wait(timeout=1.0)
after = app.state.server_metrics.snapshot()
assert after.active_websockets == before.active_websockets