Skip to content

Commit 245df69

Browse files
committed
fix(alerts): evaluate active session limits during ingest
1 parent 9a3a38a commit 245df69

4 files changed

Lines changed: 161 additions & 20 deletions

File tree

tests/integration/test_full_pipeline.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@
1313
from __future__ import annotations
1414

1515
import json
16-
import threading
16+
from datetime import timedelta
1717
from typing import Sequence
1818

1919
import pytest
20-
from opentelemetry import trace
2120
from opentelemetry.sdk.trace import TracerProvider, ReadableSpan
2221
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
2322

@@ -39,6 +38,7 @@
3938
from tokenjam.sdk.agent import watch, AgentSession, record_llm_call, record_tool_call
4039
from tokenjam.utils.time_parse import utcnow
4140
import tokenjam.sdk.agent as agent_mod
41+
from tests.factories import make_llm_span
4242

4343

4444

@@ -156,6 +156,7 @@ class _Stack:
156156

157157
stack = _Stack()
158158
stack.db = db
159+
stack.pipeline = pipeline
159160

160161
yield stack
161162

@@ -268,6 +269,67 @@ def my_agent():
268269
assert len(llm_spans) == 3
269270

270271

272+
def test_llm_only_session_enforces_session_budget_while_still_active(full_stack):
273+
"""A stream without invoke_agent still reaches session-scoped alerts."""
274+
full_stack.pipeline.process(
275+
make_llm_span(
276+
agent_id="test-agent",
277+
session_id="llm-only-budget",
278+
input_tokens=30_000_000,
279+
output_tokens=0,
280+
)
281+
)
282+
full_stack.pipeline.process(
283+
make_llm_span(
284+
agent_id="test-agent",
285+
session_id="llm-only-budget",
286+
input_tokens=30_000_000,
287+
output_tokens=0,
288+
)
289+
)
290+
full_stack.pipeline.process(
291+
make_llm_span(
292+
agent_id="test-agent",
293+
session_id="another-llm-only-budget",
294+
input_tokens=30_000_000,
295+
output_tokens=0,
296+
)
297+
)
298+
299+
rows = full_stack.db.conn.execute(
300+
"SELECT type, suppressed FROM alerts ORDER BY fired_at"
301+
).fetchall()
302+
assert [(row[0], row[1]) for row in rows] == [
303+
("cost_budget_session", False),
304+
("cost_budget_session", False),
305+
]
306+
307+
308+
def test_llm_only_session_enforces_duration_limit_while_still_active(full_stack):
309+
"""Duration is checked from the running session's observed span bounds."""
310+
start = utcnow() - timedelta(seconds=4001)
311+
full_stack.pipeline.process(
312+
make_llm_span(
313+
agent_id="test-agent",
314+
session_id="llm-only-duration",
315+
start_time=start,
316+
cost_usd=0.01,
317+
)
318+
)
319+
full_stack.pipeline.process(
320+
make_llm_span(
321+
agent_id="test-agent",
322+
session_id="llm-only-duration",
323+
cost_usd=0.01,
324+
)
325+
)
326+
327+
rows = full_stack.db.conn.execute(
328+
"SELECT type FROM alerts WHERE session_id = ?", ["llm-only-duration"]
329+
).fetchall()
330+
assert [row[0] for row in rows] == ["session_duration"]
331+
332+
271333
def test_tool_call_flows_to_db(full_stack):
272334
"""record_tool_call() should produce a tool span in the DB."""
273335

tests/synthetic/test_alert_rules.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
"""Synthetic tests for alert rules — uses span factories + mock StorageBackend."""
22
from __future__ import annotations
33

4-
from datetime import date
4+
from datetime import timedelta
55
from unittest.mock import MagicMock
66

77
from tokenjam.core.alerts import (
8-
AlertDispatcher,
98
AlertEngine,
109
FileChannel,
1110
StdoutChannel,
12-
WebhookChannel,
1311
_alert_to_dict,
1412
_strip_sensitive,
1513
SENSITIVE_DETAIL_KEYS,
@@ -220,6 +218,36 @@ def test_cost_budget_session_does_not_fire_under_budget():
220218
db.insert_alert.assert_not_called()
221219

222220

221+
def test_cost_budget_session_fires_during_session_progress():
222+
config = _make_config(agents={
223+
"test-agent": AgentConfig(budget=BudgetConfig(session_usd=1.00)),
224+
})
225+
engine, db = _make_engine(config)
226+
session = make_session(
227+
agent_id="test-agent", total_cost_usd=1.50, status="active", ended_at=None
228+
)
229+
engine.evaluate_session_progress(session)
230+
db.insert_alert.assert_called_once()
231+
assert db.insert_alert.call_args[0][0].type == AlertType.COST_BUDGET_SESSION
232+
233+
234+
def test_session_duration_fires_during_session_progress():
235+
config = _make_config(agents={
236+
"test-agent": AgentConfig(budget=BudgetConfig(session_usd=5.00)),
237+
})
238+
engine, db = _make_engine(config)
239+
now = utcnow()
240+
session = make_session(
241+
agent_id="test-agent",
242+
started_at=now - timedelta(seconds=4000),
243+
ended_at=now,
244+
status="active",
245+
)
246+
engine.evaluate_session_progress(session)
247+
db.insert_alert.assert_called_once()
248+
assert db.insert_alert.call_args[0][0].type == AlertType.SESSION_DURATION
249+
250+
223251
def test_cost_budget_daily_fires_when_exceeded():
224252
config = _make_config(agents={
225253
"test-agent": AgentConfig(budget=BudgetConfig(daily_usd=10.00)),

tokenjam/core/alerts.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
_FAILURE_RATE_THRESHOLD = 0.20
5252
_FAILURE_RATE_CHECK_INTERVAL = 5
5353
_SESSION_DURATION_DEFAULT = 3600 # seconds
54+
_SESSION_SCOPED_ALERT_TYPES = frozenset({
55+
AlertType.COST_BUDGET_SESSION,
56+
AlertType.SESSION_DURATION,
57+
})
5458

5559
# Row cap for the startup queries that rebuild CooldownTracker/_failure_rate_fired
5660
# from the alerts table (#592). Generous rather than exact: missing a stale row
@@ -198,17 +202,23 @@ class CooldownTracker:
198202

199203
def __init__(self, cooldown_seconds: int = 60) -> None:
200204
self.cooldown_seconds = cooldown_seconds
201-
self._last_fired: dict[tuple[str, str], datetime] = {}
205+
self._last_fired: dict[tuple[str, str, str], datetime] = {}
202206

203-
def is_suppressed(self, agent_id: str | None, alert_type: AlertType) -> bool:
204-
key = (agent_id or "", alert_type.value)
207+
def is_suppressed(
208+
self, agent_id: str | None, alert_type: AlertType, session_id: str | None = None,
209+
) -> bool:
210+
scope = session_id if alert_type in _SESSION_SCOPED_ALERT_TYPES else ""
211+
key = (agent_id or "", alert_type.value, scope or "")
205212
last = self._last_fired.get(key)
206213
if last is None:
207214
return False
208215
return (utcnow() - last).total_seconds() < self.cooldown_seconds
209216

210-
def record(self, agent_id: str | None, alert_type: AlertType) -> None:
211-
key = (agent_id or "", alert_type.value)
217+
def record(
218+
self, agent_id: str | None, alert_type: AlertType, session_id: str | None = None,
219+
) -> None:
220+
scope = session_id if alert_type in _SESSION_SCOPED_ALERT_TYPES else ""
221+
key = (agent_id or "", alert_type.value, scope or "")
212222
self._last_fired[key] = utcnow()
213223

214224
def hydrate(self, alerts: list[Alert]) -> None:
@@ -221,7 +231,8 @@ def hydrate(self, alerts: list[Alert]) -> None:
221231
for alert in alerts:
222232
if alert.suppressed:
223233
continue
224-
key = (alert.agent_id or "", alert.type.value)
234+
scope = alert.session_id if alert.type in _SESSION_SCOPED_ALERT_TYPES else ""
235+
key = (alert.agent_id or "", alert.type.value, scope or "")
225236
existing = self._last_fired.get(key)
226237
if existing is None or alert.fired_at > existing:
227238
self._last_fired[key] = alert.fired_at
@@ -245,6 +256,7 @@ def __init__(self, db: StorageBackend, config: TjConfig) -> None:
245256
# threshold re-fired (5/20, 6/20, 7/20 …), turning a few incidents into a
246257
# cascade of near-identical alerts.
247258
self._failure_rate_fired: set[str] = set()
259+
self._session_limit_fired: set[tuple[str, str]] = set()
248260
self._hydrate_from_db()
249261

250262
def _hydrate_from_db(self) -> None:
@@ -275,6 +287,9 @@ def _hydrate_from_db(self) -> None:
275287
since = utcnow() - timedelta(seconds=self.cooldown.cooldown_seconds)
276288
recent = self.db.get_alerts(AlertFilters(since=since, limit=_HYDRATION_LIMIT))
277289
self.cooldown.hydrate(recent)
290+
for alert in recent:
291+
if alert.type in _SESSION_SCOPED_ALERT_TYPES and alert.session_id:
292+
self._session_limit_fired.add((alert.session_id, alert.type.value))
278293

279294
fired = self.db.get_alerts(
280295
AlertFilters(type=AlertType.FAILURE_RATE, limit=_HYDRATION_LIMIT)
@@ -305,6 +320,18 @@ def evaluate_session_end(self, session: SessionRecord) -> None:
305320
self._check_cost_budgets(session)
306321
self._check_session_duration(session)
307322

323+
def evaluate_session_progress(self, session: SessionRecord) -> None:
324+
"""Evaluate limits for sessions that do not emit an end marker.
325+
326+
OTLP and transcript-based SDK integrations may emit only individual
327+
``gen_ai.llm.call`` spans. Those sessions remain active, so waiting
328+
for ``evaluate_session_end`` would make session cost and duration
329+
alerts unreachable. Daily budgets stay end-triggered because they
330+
are not session-scoped.
331+
"""
332+
self._check_session_budget(session)
333+
self._check_session_duration(session)
334+
308335
def fire(
309336
self,
310337
alert_type: AlertType,
@@ -517,7 +544,17 @@ def _check_cost_budgets(self, session: SessionRecord) -> None:
517544
"""
518545
budget = resolve_effective_budget(session.agent_id, self.config)
519546

520-
# Session budget (per-agent, both kinds; backward-compat only).
547+
self._check_session_budget(session)
548+
549+
kind = classify_agent_kind(session.agent_id)
550+
if kind.is_coding and kind.group:
551+
self._check_coding_group_daily_budget(session, kind.group)
552+
else:
553+
self._check_agent_daily_budget(session, budget)
554+
555+
def _check_session_budget(self, session: SessionRecord) -> None:
556+
"""Check the per-session cost threshold for an active or ended session."""
557+
budget = resolve_effective_budget(session.agent_id, self.config)
521558
if budget.session_usd is not None and session.total_cost_usd is not None:
522559
if session.total_cost_usd > budget.session_usd:
523560
alert = Alert(
@@ -536,12 +573,6 @@ def _check_cost_budgets(self, session: SessionRecord) -> None:
536573
)
537574
self._fire(alert)
538575

539-
kind = classify_agent_kind(session.agent_id)
540-
if kind.is_coding and kind.group:
541-
self._check_coding_group_daily_budget(session, kind.group)
542-
else:
543-
self._check_agent_daily_budget(session, budget)
544-
545576
def _check_coding_group_daily_budget(self, session: SessionRecord, group_id: str) -> None:
546577
"""Daily cap for a coding-tool GROUP: compares the SUM of today's
547578
spend across every agent_id that classifies into `group_id` (not
@@ -634,12 +665,18 @@ def _check_session_duration(self, session: SessionRecord) -> None:
634665

635666
def _fire(self, alert: Alert) -> None:
636667
"""Persist alert to DB and dispatch. Suppressed alerts are persisted but not dispatched."""
637-
if self.cooldown.is_suppressed(alert.agent_id, alert.type):
668+
session_key = (alert.session_id, alert.type.value)
669+
if alert.type in _SESSION_SCOPED_ALERT_TYPES and alert.session_id:
670+
if session_key in self._session_limit_fired:
671+
return
672+
if self.cooldown.is_suppressed(alert.agent_id, alert.type, alert.session_id):
638673
alert.suppressed = True
639674
self.db.insert_alert(alert)
640675
return
641676
self.db.insert_alert(alert)
642-
self.cooldown.record(alert.agent_id, alert.type)
677+
if alert.type in _SESSION_SCOPED_ALERT_TYPES and alert.session_id:
678+
self._session_limit_fired.add(session_key)
679+
self.cooldown.record(alert.agent_id, alert.type, alert.session_id)
643680
self.dispatcher.dispatch(alert)
644681

645682

tokenjam/core/ingest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,20 @@ def process(self, span: NormalizedSpan) -> None:
333333
# 6. Post-ingest hooks (never let hook errors kill the pipeline)
334334
self._run_hooks(span)
335335

336+
# CostEngine runs in the post-ingest hooks and may replace the span's
337+
# incoming cost, so evaluate active-session limits against the
338+
# persisted record after those hooks have completed.
339+
if not self._is_session_end(span) and self.alert_engine:
340+
try:
341+
evaluate_progress = getattr(
342+
self.alert_engine, "evaluate_session_progress", None
343+
)
344+
if evaluate_progress is not None:
345+
current_session = self.db.get_session(span.session_id) or session
346+
evaluate_progress(current_session)
347+
except Exception as exc:
348+
logger.warning("AlertEngine progress hook failed: %s", exc)
349+
336350
def _is_duplicate_observation(self, span: NormalizedSpan) -> bool:
337351
"""True when another ingest source already recorded this exact call.
338352

0 commit comments

Comments
 (0)