Skip to content

Commit 7f1f6d7

Browse files
committed
feat: add wake interaction latency report CLI
1 parent a980b40 commit 7f1f6d7

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""CLI report for the latest durable wake interaction latency traces."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import asyncio
7+
import json
8+
import os
9+
from typing import Any
10+
11+
from motor.motor_asyncio import AsyncIOMotorClient
12+
13+
from .interaction_ledger import WakeAudioInterval, WakeInteractionFact
14+
from .latency import build_wake_latency_report
15+
16+
COLLECTION = "wake_interaction_facts"
17+
18+
19+
def _fact_from_document(document: dict[str, Any]) -> WakeInteractionFact:
20+
values = {key: value for key, value in document.items() if key != "_id"}
21+
interval = values.get("audio_interval")
22+
if interval:
23+
values["audio_interval"] = WakeAudioInterval(**interval)
24+
values.setdefault("payload", {})
25+
return WakeInteractionFact(**values)
26+
27+
28+
async def _latest_trace_ids(collection, *, client_id: str | None, limit: int):
29+
match = {"client_id": client_id} if client_id else {}
30+
pipeline = [
31+
{"$match": match},
32+
{
33+
"$group": {
34+
"_id": "$wake_trace_id",
35+
"latest": {"$max": "$occurred_at"},
36+
}
37+
},
38+
{"$sort": {"latest": -1}},
39+
{"$limit": limit},
40+
]
41+
return [row["_id"] async for row in collection.aggregate(pipeline)]
42+
43+
44+
async def _run(args) -> list[dict[str, Any]]:
45+
client = AsyncIOMotorClient(
46+
os.getenv("MONGODB_URI", "mongodb://mongo:27017"),
47+
serverSelectionTimeoutMS=5000,
48+
tz_aware=True,
49+
)
50+
try:
51+
database = client[os.getenv("MONGODB_DATABASE", "chronicle")]
52+
collection = database[COLLECTION]
53+
trace_ids = (
54+
[args.trace_id]
55+
if args.trace_id
56+
else await _latest_trace_ids(
57+
collection, client_id=args.client_id, limit=args.limit
58+
)
59+
)
60+
reports = []
61+
for trace_id in trace_ids:
62+
documents = (
63+
await collection.find({"wake_trace_id": trace_id})
64+
.sort([("occurred_at", 1), ("ordinal", 1)])
65+
.to_list(length=None)
66+
)
67+
if documents:
68+
reports.append(
69+
build_wake_latency_report(
70+
_fact_from_document(document) for document in documents
71+
).to_dict()
72+
)
73+
return reports
74+
finally:
75+
client.close()
76+
77+
78+
def main() -> None:
79+
parser = argparse.ArgumentParser(
80+
description="Report stage-by-stage latency for durable wake traces"
81+
)
82+
parser.add_argument("--trace-id")
83+
parser.add_argument("--client-id")
84+
parser.add_argument("--limit", type=int, default=5)
85+
args = parser.parse_args()
86+
if args.limit <= 0:
87+
parser.error("--limit must be positive")
88+
print(json.dumps(asyncio.run(_run(args)), indent=2, sort_keys=True))
89+
90+
91+
if __name__ == "__main__":
92+
main()
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import json
2+
from datetime import UTC, datetime, timedelta
3+
from types import SimpleNamespace
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from backend.services.wakeword import benchmark
9+
10+
TRACE_A = "7ce4d46b-232f-47f9-8148-d595ed344cf2"
11+
TRACE_B = "46bc4b51-9b24-40fd-a73b-00523242b428"
12+
START = datetime(2026, 9, 8, 8, 0, tzinfo=UTC)
13+
14+
15+
def _document(trace_id, stage, ordinal, milliseconds, *, payload=None):
16+
return {
17+
"_id": f"mongo-{trace_id}-{ordinal}",
18+
"wake_trace_id": trace_id,
19+
"stage": stage,
20+
"ordinal": ordinal,
21+
"occurred_at": START + timedelta(milliseconds=milliseconds),
22+
"user_id": "user-1",
23+
"client_id": "phone-1",
24+
"audio_session_id": "audio-1",
25+
"capture_epoch": 3,
26+
"payload": payload or {},
27+
}
28+
29+
30+
class _Cursor:
31+
def __init__(self, rows):
32+
self.rows = rows
33+
self.sort_spec = None
34+
self.requested_length = None
35+
36+
def sort(self, spec):
37+
self.sort_spec = spec
38+
return self
39+
40+
async def to_list(self, *, length):
41+
self.requested_length = length
42+
return self.rows
43+
44+
45+
class _Collection:
46+
def __init__(self, aggregate_rows, documents_by_trace):
47+
self.aggregate_rows = aggregate_rows
48+
self.documents_by_trace = documents_by_trace
49+
self.pipeline = None
50+
self.find_queries = []
51+
self.cursors = []
52+
53+
def aggregate(self, pipeline):
54+
self.pipeline = pipeline
55+
56+
async def rows():
57+
for row in self.aggregate_rows:
58+
yield row
59+
60+
return rows()
61+
62+
def find(self, query):
63+
self.find_queries.append(query)
64+
cursor = _Cursor(self.documents_by_trace.get(query["wake_trace_id"], []))
65+
self.cursors.append(cursor)
66+
return cursor
67+
68+
69+
class _Client:
70+
def __init__(self, collection):
71+
self.collection = collection
72+
self.database_name = None
73+
self.closed = False
74+
75+
def __getitem__(self, database_name):
76+
self.database_name = database_name
77+
return {benchmark.COLLECTION: self.collection}
78+
79+
def close(self):
80+
self.closed = True
81+
82+
83+
def _args(**overrides):
84+
values = {"trace_id": None, "client_id": None, "limit": 5}
85+
values.update(overrides)
86+
return SimpleNamespace(**values)
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_run_filters_latest_traces_by_client_and_closes_client(monkeypatch):
91+
documents = [
92+
_document(TRACE_A, "armed", 0, 0),
93+
_document(TRACE_A, "end_of_turn", 1, 250),
94+
]
95+
collection = _Collection([{"_id": TRACE_A}], {TRACE_A: documents})
96+
client = _Client(collection)
97+
monkeypatch.setattr(benchmark, "AsyncIOMotorClient", lambda *args, **kwargs: client)
98+
monkeypatch.setenv("MONGODB_DATABASE", "wake_cli_test")
99+
100+
reports = await benchmark._run(_args(client_id="phone-1", limit=2))
101+
102+
assert client.database_name == "wake_cli_test"
103+
assert client.closed is True
104+
assert collection.pipeline == [
105+
{"$match": {"client_id": "phone-1"}},
106+
{"$group": {"_id": "$wake_trace_id", "latest": {"$max": "$occurred_at"}}},
107+
{"$sort": {"latest": -1}},
108+
{"$limit": 2},
109+
]
110+
assert collection.find_queries == [{"wake_trace_id": TRACE_A}]
111+
assert collection.cursors[0].sort_spec == [("occurred_at", 1), ("ordinal", 1)]
112+
assert collection.cursors[0].requested_length is None
113+
assert reports == [
114+
{
115+
"wake_trace_id": TRACE_A,
116+
"status": "incomplete",
117+
"observed_stages": ("armed", "end_of_turn"),
118+
"missing_stages": (
119+
"command_resolved",
120+
"dispatched",
121+
"response_queued",
122+
"response_ready",
123+
"response_offered",
124+
"response_playing",
125+
"response_done",
126+
),
127+
"metrics_ms": {"wake_capture": 250.0},
128+
"plugins": (),
129+
}
130+
]
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_run_uses_requested_trace_and_ignores_empty_trace_results(monkeypatch):
135+
collection = _Collection([], {TRACE_B: []})
136+
client = _Client(collection)
137+
monkeypatch.setattr(benchmark, "AsyncIOMotorClient", lambda *args, **kwargs: client)
138+
139+
reports = await benchmark._run(_args(trace_id=TRACE_B, client_id="other-client"))
140+
141+
assert collection.pipeline is None
142+
assert collection.find_queries == [{"wake_trace_id": TRACE_B}]
143+
assert reports == []
144+
assert client.closed is True
145+
146+
147+
def test_main_serializes_actual_run_output_from_fake_mongo(monkeypatch, capsys):
148+
documents = [
149+
_document(TRACE_A, "armed", 0, 0),
150+
_document(TRACE_A, "end_of_turn", 1, 500),
151+
]
152+
collection = _Collection([], {TRACE_A: documents})
153+
client = _Client(collection)
154+
monkeypatch.setattr(benchmark, "AsyncIOMotorClient", lambda *args, **kwargs: client)
155+
monkeypatch.setattr("sys.argv", ["wake-latency", "--trace-id", TRACE_A])
156+
157+
benchmark.main()
158+
159+
assert json.loads(capsys.readouterr().out) == [
160+
{
161+
"wake_trace_id": TRACE_A,
162+
"status": "incomplete",
163+
"observed_stages": ["armed", "end_of_turn"],
164+
"missing_stages": [
165+
"command_resolved",
166+
"dispatched",
167+
"response_queued",
168+
"response_ready",
169+
"response_offered",
170+
"response_playing",
171+
"response_done",
172+
],
173+
"metrics_ms": {"wake_capture": 500.0},
174+
"plugins": [],
175+
}
176+
]
177+
assert client.closed is True
178+
179+
180+
def test_main_rejects_non_positive_limit_before_opening_mongo(monkeypatch):
181+
monkeypatch.setattr("sys.argv", ["wake-latency", "--limit", "0"])
182+
with patch.object(benchmark, "AsyncIOMotorClient") as client, pytest.raises(
183+
SystemExit, match="2"
184+
):
185+
benchmark.main()
186+
client.assert_not_called()

0 commit comments

Comments
 (0)