tokenjam 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenjam/__init__.py +1 -0
- tokenjam/api/__init__.py +0 -0
- tokenjam/api/app.py +104 -0
- tokenjam/api/deps.py +18 -0
- tokenjam/api/middleware.py +28 -0
- tokenjam/api/routes/__init__.py +0 -0
- tokenjam/api/routes/agents.py +33 -0
- tokenjam/api/routes/alerts.py +77 -0
- tokenjam/api/routes/budget.py +96 -0
- tokenjam/api/routes/cost.py +43 -0
- tokenjam/api/routes/drift.py +63 -0
- tokenjam/api/routes/logs.py +511 -0
- tokenjam/api/routes/metrics.py +81 -0
- tokenjam/api/routes/otlp.py +63 -0
- tokenjam/api/routes/spans.py +202 -0
- tokenjam/api/routes/status.py +84 -0
- tokenjam/api/routes/tools.py +22 -0
- tokenjam/api/routes/traces.py +92 -0
- tokenjam/cli/__init__.py +0 -0
- tokenjam/cli/cmd_alerts.py +94 -0
- tokenjam/cli/cmd_budget.py +119 -0
- tokenjam/cli/cmd_cost.py +90 -0
- tokenjam/cli/cmd_demo.py +82 -0
- tokenjam/cli/cmd_doctor.py +173 -0
- tokenjam/cli/cmd_drift.py +238 -0
- tokenjam/cli/cmd_export.py +200 -0
- tokenjam/cli/cmd_mcp.py +78 -0
- tokenjam/cli/cmd_onboard.py +779 -0
- tokenjam/cli/cmd_serve.py +85 -0
- tokenjam/cli/cmd_status.py +153 -0
- tokenjam/cli/cmd_stop.py +87 -0
- tokenjam/cli/cmd_tools.py +45 -0
- tokenjam/cli/cmd_traces.py +161 -0
- tokenjam/cli/cmd_uninstall.py +159 -0
- tokenjam/cli/main.py +110 -0
- tokenjam/core/__init__.py +0 -0
- tokenjam/core/alerts.py +619 -0
- tokenjam/core/api_backend.py +235 -0
- tokenjam/core/config.py +360 -0
- tokenjam/core/cost.py +102 -0
- tokenjam/core/db.py +718 -0
- tokenjam/core/drift.py +256 -0
- tokenjam/core/ingest.py +265 -0
- tokenjam/core/models.py +225 -0
- tokenjam/core/pricing.py +54 -0
- tokenjam/core/retention.py +21 -0
- tokenjam/core/schema_validator.py +156 -0
- tokenjam/demo/__init__.py +0 -0
- tokenjam/demo/env.py +96 -0
- tokenjam/mcp/__init__.py +0 -0
- tokenjam/mcp/server.py +1067 -0
- tokenjam/otel/__init__.py +0 -0
- tokenjam/otel/exporters.py +26 -0
- tokenjam/otel/provider.py +207 -0
- tokenjam/otel/semconv.py +144 -0
- tokenjam/pricing/models.toml +70 -0
- tokenjam/py.typed +0 -0
- tokenjam/sdk/__init__.py +21 -0
- tokenjam/sdk/agent.py +206 -0
- tokenjam/sdk/bootstrap.py +120 -0
- tokenjam/sdk/http_exporter.py +109 -0
- tokenjam/sdk/integrations/__init__.py +0 -0
- tokenjam/sdk/integrations/anthropic.py +200 -0
- tokenjam/sdk/integrations/autogen.py +97 -0
- tokenjam/sdk/integrations/base.py +27 -0
- tokenjam/sdk/integrations/bedrock.py +103 -0
- tokenjam/sdk/integrations/crewai.py +96 -0
- tokenjam/sdk/integrations/gemini.py +131 -0
- tokenjam/sdk/integrations/langchain.py +156 -0
- tokenjam/sdk/integrations/langgraph.py +101 -0
- tokenjam/sdk/integrations/litellm.py +323 -0
- tokenjam/sdk/integrations/llamaindex.py +52 -0
- tokenjam/sdk/integrations/nemoclaw.py +139 -0
- tokenjam/sdk/integrations/openai.py +159 -0
- tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
- tokenjam/sdk/transport.py +98 -0
- tokenjam/ui/index.html +1213 -0
- tokenjam/utils/__init__.py +0 -0
- tokenjam/utils/formatting.py +43 -0
- tokenjam/utils/ids.py +15 -0
- tokenjam/utils/time_parse.py +54 -0
- tokenjam-0.2.0.dist-info/METADATA +622 -0
- tokenjam-0.2.0.dist-info/RECORD +86 -0
- tokenjam-0.2.0.dist-info/WHEEL +4 -0
- tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
- tokenjam-0.2.0.dist-info/licenses/LICENSE +21 -0
tokenjam/core/alerts.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alert engine — evaluates per-span and per-session alert rules, dispatches to channels.
|
|
3
|
+
|
|
4
|
+
Called as a post-ingest hook by IngestPipeline.process().
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from tokenjam.core.config import AlertChannelConfig, TjConfig, resolve_effective_budget
|
|
16
|
+
from tokenjam.core.models import Alert, AlertType, Severity
|
|
17
|
+
from tokenjam.otel.semconv import TjAttributes
|
|
18
|
+
from tokenjam.utils.formatting import console, severity_colour
|
|
19
|
+
from tokenjam.utils.ids import new_uuid
|
|
20
|
+
from tokenjam.utils.time_parse import utcnow
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from tokenjam.core.db import StorageBackend
|
|
24
|
+
from tokenjam.core.models import NormalizedSpan, SessionRecord
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
SENSITIVE_DETAIL_KEYS = frozenset({
|
|
29
|
+
"prompt_content", "completion_content", "tool_input", "tool_output",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
# Sandbox event value -> AlertType mapping
|
|
33
|
+
_SANDBOX_EVENT_MAP: dict[str, AlertType] = {
|
|
34
|
+
"network_blocked": AlertType.NETWORK_EGRESS_BLOCKED,
|
|
35
|
+
"fs_denied": AlertType.FILESYSTEM_ACCESS_DENIED,
|
|
36
|
+
"syscall_denied": AlertType.SYSCALL_DENIED,
|
|
37
|
+
"inference_rerouted": AlertType.INFERENCE_REROUTED,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Default thresholds
|
|
41
|
+
_RETRY_LOOP_WINDOW = 6
|
|
42
|
+
_RETRY_LOOP_THRESHOLD = 4
|
|
43
|
+
_FAILURE_RATE_WINDOW = 20
|
|
44
|
+
_FAILURE_RATE_THRESHOLD = 0.20
|
|
45
|
+
_FAILURE_RATE_CHECK_INTERVAL = 5
|
|
46
|
+
_SESSION_DURATION_DEFAULT = 3600 # seconds
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Cooldown tracker ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class CooldownTracker:
|
|
52
|
+
"""
|
|
53
|
+
Prevents alert storms by suppressing repeat alerts of the same type
|
|
54
|
+
for the same agent within the cooldown window.
|
|
55
|
+
Stored in-memory — resets when the process restarts.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, cooldown_seconds: int = 60) -> None:
|
|
59
|
+
self.cooldown_seconds = cooldown_seconds
|
|
60
|
+
self._last_fired: dict[tuple[str, str], datetime] = {}
|
|
61
|
+
|
|
62
|
+
def is_suppressed(self, agent_id: str | None, alert_type: AlertType) -> bool:
|
|
63
|
+
key = (agent_id or "", alert_type.value)
|
|
64
|
+
last = self._last_fired.get(key)
|
|
65
|
+
if last is None:
|
|
66
|
+
return False
|
|
67
|
+
return (utcnow() - last).total_seconds() < self.cooldown_seconds
|
|
68
|
+
|
|
69
|
+
def record(self, agent_id: str | None, alert_type: AlertType) -> None:
|
|
70
|
+
key = (agent_id or "", alert_type.value)
|
|
71
|
+
self._last_fired[key] = utcnow()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Alert engine ───────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
class AlertEngine:
|
|
77
|
+
"""
|
|
78
|
+
Post-ingest hook. Evaluates all alert rules after each span is written.
|
|
79
|
+
Called by IngestPipeline.process() after the span is in the DB.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, db: StorageBackend, config: TjConfig) -> None:
|
|
83
|
+
self.db = db
|
|
84
|
+
self.config = config
|
|
85
|
+
self.cooldown = CooldownTracker(config.alerts.cooldown_seconds)
|
|
86
|
+
self.dispatcher = AlertDispatcher(config)
|
|
87
|
+
# Tracks the error_count value at which the failure-rate check last fired per session.
|
|
88
|
+
# Prevents non-deterministic re-firing when the sliding-window count oscillates.
|
|
89
|
+
self._last_failure_rate_check: dict[str, int] = {}
|
|
90
|
+
|
|
91
|
+
def evaluate(self, span: NormalizedSpan) -> None:
|
|
92
|
+
"""Evaluate all per-span alert rules against this span."""
|
|
93
|
+
self._check_sensitive_action(span)
|
|
94
|
+
self._check_retry_loop(span)
|
|
95
|
+
self._check_failure_rate(span)
|
|
96
|
+
self._check_sandbox_events(span)
|
|
97
|
+
|
|
98
|
+
def evaluate_session_end(self, session: SessionRecord) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Evaluate per-session alert rules when a session ends.
|
|
101
|
+
DRIFT_DETECTED and TOKEN_ANOMALY are fired from drift.py, not here.
|
|
102
|
+
"""
|
|
103
|
+
self._check_cost_budgets(session)
|
|
104
|
+
self._check_session_duration(session)
|
|
105
|
+
|
|
106
|
+
def fire(
|
|
107
|
+
self,
|
|
108
|
+
alert_type: AlertType,
|
|
109
|
+
span_or_session: NormalizedSpan | SessionRecord,
|
|
110
|
+
detail: dict[str, Any],
|
|
111
|
+
severity: Severity | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
External entry point for other modules (SchemaValidator, DriftDetector)
|
|
115
|
+
to fire alerts they detect.
|
|
116
|
+
"""
|
|
117
|
+
from tokenjam.core.models import NormalizedSpan
|
|
118
|
+
|
|
119
|
+
if severity is None:
|
|
120
|
+
severity = Severity.WARNING
|
|
121
|
+
|
|
122
|
+
if isinstance(span_or_session, NormalizedSpan):
|
|
123
|
+
agent_id = span_or_session.agent_id
|
|
124
|
+
session_id = span_or_session.session_id
|
|
125
|
+
span_id = span_or_session.span_id
|
|
126
|
+
else:
|
|
127
|
+
agent_id = span_or_session.agent_id
|
|
128
|
+
session_id = span_or_session.session_id
|
|
129
|
+
span_id = None
|
|
130
|
+
|
|
131
|
+
alert = Alert(
|
|
132
|
+
alert_id=new_uuid(),
|
|
133
|
+
fired_at=utcnow(),
|
|
134
|
+
type=alert_type,
|
|
135
|
+
severity=severity,
|
|
136
|
+
title=f"{alert_type.value} — {agent_id or 'unknown'}",
|
|
137
|
+
detail=detail,
|
|
138
|
+
agent_id=agent_id,
|
|
139
|
+
session_id=session_id,
|
|
140
|
+
span_id=span_id,
|
|
141
|
+
)
|
|
142
|
+
self._fire(alert)
|
|
143
|
+
|
|
144
|
+
# ── Per-span checks ────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def _check_sensitive_action(self, span: NormalizedSpan) -> None:
|
|
147
|
+
"""Fire SENSITIVE_ACTION if span.tool_name matches the agent's sensitive_actions."""
|
|
148
|
+
if not span.tool_name or not span.agent_id:
|
|
149
|
+
return
|
|
150
|
+
agent_cfg = self.config.agents.get(span.agent_id)
|
|
151
|
+
if not agent_cfg:
|
|
152
|
+
return
|
|
153
|
+
for sa in agent_cfg.sensitive_actions:
|
|
154
|
+
if sa.name == span.tool_name:
|
|
155
|
+
sev = Severity(sa.severity) if sa.severity in ("critical", "warning", "info") else Severity.WARNING
|
|
156
|
+
alert = Alert(
|
|
157
|
+
alert_id=new_uuid(),
|
|
158
|
+
fired_at=utcnow(),
|
|
159
|
+
type=AlertType.SENSITIVE_ACTION,
|
|
160
|
+
severity=sev,
|
|
161
|
+
title=f"sensitive_action — {span.agent_id}",
|
|
162
|
+
detail={
|
|
163
|
+
"tool_name": span.tool_name,
|
|
164
|
+
"message": f"{span.tool_name} called",
|
|
165
|
+
},
|
|
166
|
+
agent_id=span.agent_id,
|
|
167
|
+
session_id=span.session_id,
|
|
168
|
+
span_id=span.span_id,
|
|
169
|
+
)
|
|
170
|
+
self._fire(alert)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
def _check_retry_loop(self, span: NormalizedSpan) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Fetch last 6 spans for this session. If same tool_name appears 4+ times,
|
|
176
|
+
fire RETRY_LOOP.
|
|
177
|
+
"""
|
|
178
|
+
if not span.session_id or not span.tool_name:
|
|
179
|
+
return
|
|
180
|
+
recent = self.db.get_recent_spans(span.session_id, _RETRY_LOOP_WINDOW)
|
|
181
|
+
tool_counts: dict[str, int] = {}
|
|
182
|
+
for s in recent:
|
|
183
|
+
if s.tool_name:
|
|
184
|
+
tool_counts[s.tool_name] = tool_counts.get(s.tool_name, 0) + 1
|
|
185
|
+
count = tool_counts.get(span.tool_name, 0)
|
|
186
|
+
if count >= _RETRY_LOOP_THRESHOLD:
|
|
187
|
+
alert = Alert(
|
|
188
|
+
alert_id=new_uuid(),
|
|
189
|
+
fired_at=utcnow(),
|
|
190
|
+
type=AlertType.RETRY_LOOP,
|
|
191
|
+
severity=Severity.WARNING,
|
|
192
|
+
title=f"retry_loop — {span.agent_id or 'unknown'}",
|
|
193
|
+
detail={
|
|
194
|
+
"tool_name": span.tool_name,
|
|
195
|
+
"count": count,
|
|
196
|
+
"window": _RETRY_LOOP_WINDOW,
|
|
197
|
+
"message": f"{span.tool_name} called {count} times in last {_RETRY_LOOP_WINDOW} spans",
|
|
198
|
+
},
|
|
199
|
+
agent_id=span.agent_id,
|
|
200
|
+
session_id=span.session_id,
|
|
201
|
+
span_id=span.span_id,
|
|
202
|
+
)
|
|
203
|
+
self._fire(alert)
|
|
204
|
+
|
|
205
|
+
def _check_failure_rate(self, span: NormalizedSpan) -> None:
|
|
206
|
+
"""
|
|
207
|
+
In a rolling window of last 20 spans, fire FAILURE_RATE if error rate > 20%.
|
|
208
|
+
Only check when error_count reaches a new multiple of the check interval to
|
|
209
|
+
avoid firing on every single error and to avoid re-firing when the sliding
|
|
210
|
+
window count oscillates.
|
|
211
|
+
"""
|
|
212
|
+
if not span.session_id or span.status_code.value != "error":
|
|
213
|
+
return
|
|
214
|
+
recent = self.db.get_recent_spans(span.session_id, _FAILURE_RATE_WINDOW)
|
|
215
|
+
total = len(recent)
|
|
216
|
+
if total < _FAILURE_RATE_CHECK_INTERVAL:
|
|
217
|
+
return
|
|
218
|
+
error_count = sum(1 for s in recent if s.status_code.value == "error")
|
|
219
|
+
session_key = span.session_id
|
|
220
|
+
last_checked = self._last_failure_rate_check.get(session_key, 0)
|
|
221
|
+
if error_count < _FAILURE_RATE_CHECK_INTERVAL or error_count <= last_checked:
|
|
222
|
+
return
|
|
223
|
+
self._last_failure_rate_check[session_key] = error_count
|
|
224
|
+
rate = error_count / total
|
|
225
|
+
if rate > _FAILURE_RATE_THRESHOLD:
|
|
226
|
+
alert = Alert(
|
|
227
|
+
alert_id=new_uuid(),
|
|
228
|
+
fired_at=utcnow(),
|
|
229
|
+
type=AlertType.FAILURE_RATE,
|
|
230
|
+
severity=Severity.WARNING,
|
|
231
|
+
title=f"failure_rate — {span.agent_id or 'unknown'}",
|
|
232
|
+
detail={
|
|
233
|
+
"error_count": error_count,
|
|
234
|
+
"total": total,
|
|
235
|
+
"rate": round(rate, 3),
|
|
236
|
+
"message": f"Failure rate {rate:.0%} exceeds {_FAILURE_RATE_THRESHOLD:.0%} threshold",
|
|
237
|
+
},
|
|
238
|
+
agent_id=span.agent_id,
|
|
239
|
+
session_id=span.session_id,
|
|
240
|
+
span_id=span.span_id,
|
|
241
|
+
)
|
|
242
|
+
self._fire(alert)
|
|
243
|
+
|
|
244
|
+
def _check_sandbox_events(self, span: NormalizedSpan) -> None:
|
|
245
|
+
"""Check for NemoClaw/OpenShell sandbox event attributes."""
|
|
246
|
+
event = span.attributes.get(TjAttributes.SANDBOX_EVENT)
|
|
247
|
+
if not event:
|
|
248
|
+
return
|
|
249
|
+
alert_type = _SANDBOX_EVENT_MAP.get(event)
|
|
250
|
+
if not alert_type:
|
|
251
|
+
return
|
|
252
|
+
detail: dict[str, Any] = {"sandbox_event": event}
|
|
253
|
+
if event == "network_blocked":
|
|
254
|
+
detail["host"] = span.attributes.get(TjAttributes.EGRESS_HOST, "unknown")
|
|
255
|
+
detail["port"] = span.attributes.get(TjAttributes.EGRESS_PORT)
|
|
256
|
+
detail["message"] = f"Network egress blocked to {detail['host']}"
|
|
257
|
+
elif event == "fs_denied":
|
|
258
|
+
detail["path"] = span.attributes.get(TjAttributes.FILESYSTEM_PATH, "unknown")
|
|
259
|
+
detail["message"] = f"Filesystem access denied: {detail['path']}"
|
|
260
|
+
elif event == "syscall_denied":
|
|
261
|
+
detail["syscall"] = span.attributes.get(TjAttributes.SYSCALL_NAME, "unknown")
|
|
262
|
+
detail["message"] = f"Syscall denied: {detail['syscall']}"
|
|
263
|
+
elif event == "inference_rerouted":
|
|
264
|
+
detail["message"] = "Inference endpoint changed from expected"
|
|
265
|
+
|
|
266
|
+
alert = Alert(
|
|
267
|
+
alert_id=new_uuid(),
|
|
268
|
+
fired_at=utcnow(),
|
|
269
|
+
type=alert_type,
|
|
270
|
+
severity=Severity.CRITICAL,
|
|
271
|
+
title=f"{alert_type.value} — {span.agent_id or 'unknown'}",
|
|
272
|
+
detail=detail,
|
|
273
|
+
agent_id=span.agent_id,
|
|
274
|
+
session_id=span.session_id,
|
|
275
|
+
span_id=span.span_id,
|
|
276
|
+
)
|
|
277
|
+
self._fire(alert)
|
|
278
|
+
|
|
279
|
+
# ── Per-session checks ─────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def _check_cost_budgets(self, session: SessionRecord) -> None:
|
|
282
|
+
"""Check daily and session cost thresholds against the agent's budget config."""
|
|
283
|
+
from tokenjam.core.config import BudgetConfig
|
|
284
|
+
budget = resolve_effective_budget(session.agent_id, self.config)
|
|
285
|
+
if budget == BudgetConfig():
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
# Session budget
|
|
289
|
+
if budget.session_usd is not None and session.total_cost_usd is not None:
|
|
290
|
+
if session.total_cost_usd > budget.session_usd:
|
|
291
|
+
alert = Alert(
|
|
292
|
+
alert_id=new_uuid(),
|
|
293
|
+
fired_at=utcnow(),
|
|
294
|
+
type=AlertType.COST_BUDGET_SESSION,
|
|
295
|
+
severity=Severity.CRITICAL,
|
|
296
|
+
title=f"cost_budget_session — {session.agent_id}",
|
|
297
|
+
detail={
|
|
298
|
+
"session_cost": session.total_cost_usd,
|
|
299
|
+
"budget": budget.session_usd,
|
|
300
|
+
"message": f"Session cost ${session.total_cost_usd:.4f} exceeds budget ${budget.session_usd:.4f}",
|
|
301
|
+
},
|
|
302
|
+
agent_id=session.agent_id,
|
|
303
|
+
session_id=session.session_id,
|
|
304
|
+
)
|
|
305
|
+
self._fire(alert)
|
|
306
|
+
|
|
307
|
+
# Daily budget
|
|
308
|
+
if budget.daily_usd is not None:
|
|
309
|
+
today = utcnow().date()
|
|
310
|
+
daily_cost = self.db.get_daily_cost(session.agent_id, today)
|
|
311
|
+
if daily_cost > budget.daily_usd:
|
|
312
|
+
alert = Alert(
|
|
313
|
+
alert_id=new_uuid(),
|
|
314
|
+
fired_at=utcnow(),
|
|
315
|
+
type=AlertType.COST_BUDGET_DAILY,
|
|
316
|
+
severity=Severity.CRITICAL,
|
|
317
|
+
title=f"cost_budget_daily — {session.agent_id}",
|
|
318
|
+
detail={
|
|
319
|
+
"daily_cost": daily_cost,
|
|
320
|
+
"budget": budget.daily_usd,
|
|
321
|
+
"message": f"Daily cost ${daily_cost:.4f} exceeds budget ${budget.daily_usd:.4f}",
|
|
322
|
+
},
|
|
323
|
+
agent_id=session.agent_id,
|
|
324
|
+
session_id=session.session_id,
|
|
325
|
+
)
|
|
326
|
+
self._fire(alert)
|
|
327
|
+
|
|
328
|
+
def _check_session_duration(self, session: SessionRecord) -> None:
|
|
329
|
+
"""Fire SESSION_DURATION if session wall time exceeds threshold."""
|
|
330
|
+
duration = session.duration_seconds
|
|
331
|
+
if duration is None:
|
|
332
|
+
return
|
|
333
|
+
if duration > _SESSION_DURATION_DEFAULT:
|
|
334
|
+
alert = Alert(
|
|
335
|
+
alert_id=new_uuid(),
|
|
336
|
+
fired_at=utcnow(),
|
|
337
|
+
type=AlertType.SESSION_DURATION,
|
|
338
|
+
severity=Severity.WARNING,
|
|
339
|
+
title=f"session_duration — {session.agent_id}",
|
|
340
|
+
detail={
|
|
341
|
+
"duration_seconds": duration,
|
|
342
|
+
"threshold_seconds": _SESSION_DURATION_DEFAULT,
|
|
343
|
+
"message": f"Session lasted {duration:.0f}s, exceeding {_SESSION_DURATION_DEFAULT}s threshold",
|
|
344
|
+
},
|
|
345
|
+
agent_id=session.agent_id,
|
|
346
|
+
session_id=session.session_id,
|
|
347
|
+
)
|
|
348
|
+
self._fire(alert)
|
|
349
|
+
|
|
350
|
+
# ── Internal dispatch ──────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
def _fire(self, alert: Alert) -> None:
|
|
353
|
+
"""Persist alert to DB and dispatch. Suppressed alerts are persisted but not dispatched."""
|
|
354
|
+
if self.cooldown.is_suppressed(alert.agent_id, alert.type):
|
|
355
|
+
alert.suppressed = True
|
|
356
|
+
self.db.insert_alert(alert)
|
|
357
|
+
return
|
|
358
|
+
self.db.insert_alert(alert)
|
|
359
|
+
self.cooldown.record(alert.agent_id, alert.type)
|
|
360
|
+
self.dispatcher.dispatch(alert)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── Alert dispatcher ───────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
class AlertDispatcher:
|
|
366
|
+
"""Routes a fired alert to all configured output channels."""
|
|
367
|
+
|
|
368
|
+
def __init__(self, config: TjConfig) -> None:
|
|
369
|
+
self.channels: list[AlertChannel] = [
|
|
370
|
+
_build_channel(ch_config, config.alerts.include_captured_content)
|
|
371
|
+
for ch_config in config.alerts.channels
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
def dispatch(self, alert: Alert) -> None:
|
|
375
|
+
for channel in self.channels:
|
|
376
|
+
# Enforce min_severity gate centrally so every channel type honours it.
|
|
377
|
+
min_sev = getattr(channel, "min_severity", None)
|
|
378
|
+
if min_sev is not None and _severity_rank(alert.severity) < _severity_rank(min_sev):
|
|
379
|
+
continue
|
|
380
|
+
try:
|
|
381
|
+
channel.send(alert)
|
|
382
|
+
except Exception as exc:
|
|
383
|
+
logger.warning("Alert channel %s failed: %s", channel, exc)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _build_channel(
|
|
387
|
+
config: AlertChannelConfig, include_captured_content: bool
|
|
388
|
+
) -> AlertChannel:
|
|
389
|
+
"""Factory: return the correct channel instance for the config type."""
|
|
390
|
+
match config.type:
|
|
391
|
+
case "stdout":
|
|
392
|
+
return StdoutChannel(min_severity=Severity(config.min_severity))
|
|
393
|
+
case "file":
|
|
394
|
+
return FileChannel(
|
|
395
|
+
config.path or "alerts.jsonl",
|
|
396
|
+
include_captured_content,
|
|
397
|
+
min_severity=Severity(config.min_severity),
|
|
398
|
+
)
|
|
399
|
+
case "ntfy":
|
|
400
|
+
return NtfyChannel(config, include_captured_content)
|
|
401
|
+
case "webhook":
|
|
402
|
+
return WebhookChannel(config, include_captured_content)
|
|
403
|
+
case "discord":
|
|
404
|
+
return DiscordChannel(config, include_captured_content)
|
|
405
|
+
case "telegram":
|
|
406
|
+
return TelegramChannel(config, include_captured_content)
|
|
407
|
+
case _:
|
|
408
|
+
raise ValueError(f"Unknown alert channel type: {config.type!r}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _strip_sensitive(detail: dict[str, Any]) -> dict[str, Any]:
|
|
412
|
+
"""Return a copy of detail with captured content keys removed."""
|
|
413
|
+
return {k: v for k, v in detail.items() if k not in SENSITIVE_DETAIL_KEYS}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _format_detail_text(alert: Alert, strip: bool) -> str:
|
|
417
|
+
"""Format alert detail as a human-readable text block."""
|
|
418
|
+
detail = _strip_sensitive(alert.detail) if strip else alert.detail
|
|
419
|
+
return detail.get("message", json.dumps(detail, default=str))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _alert_to_dict(alert: Alert, strip: bool) -> dict[str, Any]:
|
|
423
|
+
"""Serialise an alert to a dict, optionally stripping sensitive content."""
|
|
424
|
+
detail = _strip_sensitive(alert.detail) if strip else alert.detail
|
|
425
|
+
return {
|
|
426
|
+
"alert_id": alert.alert_id,
|
|
427
|
+
"fired_at": alert.fired_at.isoformat(),
|
|
428
|
+
"type": alert.type.value,
|
|
429
|
+
"severity": alert.severity.value,
|
|
430
|
+
"title": alert.title,
|
|
431
|
+
"detail": detail,
|
|
432
|
+
"agent_id": alert.agent_id,
|
|
433
|
+
"session_id": alert.session_id,
|
|
434
|
+
"span_id": alert.span_id,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ── Channel base ──────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
class AlertChannel:
|
|
441
|
+
"""Base class for alert output channels."""
|
|
442
|
+
|
|
443
|
+
def send(self, alert: Alert) -> None:
|
|
444
|
+
raise NotImplementedError
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ── Channel implementations ───────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
class StdoutChannel(AlertChannel):
|
|
450
|
+
"""
|
|
451
|
+
Prints to stdout using Rich.
|
|
452
|
+
Format: [HH:MM:SS] icon SEVERITY type agent message
|
|
453
|
+
Always includes full detail (no content stripping).
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
def __init__(self, min_severity: Severity = Severity.INFO) -> None:
|
|
457
|
+
self.min_severity = min_severity
|
|
458
|
+
|
|
459
|
+
def send(self, alert: Alert) -> None:
|
|
460
|
+
time_str = alert.fired_at.strftime("%H:%M:%S")
|
|
461
|
+
sev = alert.severity.value.upper()
|
|
462
|
+
colour = severity_colour(alert.severity.value)
|
|
463
|
+
icon = "\u26a0" if alert.severity in (Severity.CRITICAL, Severity.WARNING) else "\u2139"
|
|
464
|
+
message = alert.detail.get("message", alert.title)
|
|
465
|
+
agent = alert.agent_id or "unknown"
|
|
466
|
+
console.print(
|
|
467
|
+
f"[dim]{time_str}[/dim] {icon} [{colour} bold]{sev}[/] "
|
|
468
|
+
f"[cyan]{alert.type.value}[/] [dim]{agent}[/] {message}"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class FileChannel(AlertChannel):
|
|
473
|
+
"""
|
|
474
|
+
Appends a JSON line to the configured log file path.
|
|
475
|
+
Always includes full detail (no content stripping).
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
def __init__(self, path: str, include_captured_content: bool,
|
|
479
|
+
min_severity: Severity = Severity.INFO) -> None:
|
|
480
|
+
self.path = path
|
|
481
|
+
# File channels always get full payload regardless of config
|
|
482
|
+
self._include_captured_content = True
|
|
483
|
+
self.min_severity = min_severity
|
|
484
|
+
|
|
485
|
+
def send(self, alert: Alert) -> None:
|
|
486
|
+
from pathlib import Path
|
|
487
|
+
|
|
488
|
+
p = Path(self.path)
|
|
489
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
490
|
+
record = _alert_to_dict(alert, strip=False)
|
|
491
|
+
with open(p, "a") as f:
|
|
492
|
+
f.write(json.dumps(record, default=str) + "\n")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class NtfyChannel(AlertChannel):
|
|
496
|
+
"""Sends push notifications via ntfy.sh or self-hosted ntfy."""
|
|
497
|
+
|
|
498
|
+
def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
|
|
499
|
+
self.server = config.server
|
|
500
|
+
self.topic = config.topic or ""
|
|
501
|
+
self.token = config.token
|
|
502
|
+
self.min_severity = Severity(config.min_severity)
|
|
503
|
+
self._include_captured_content = include_captured_content
|
|
504
|
+
|
|
505
|
+
def send(self, alert: Alert) -> None:
|
|
506
|
+
if not self.topic:
|
|
507
|
+
return
|
|
508
|
+
if _severity_rank(alert.severity) < _severity_rank(self.min_severity):
|
|
509
|
+
return
|
|
510
|
+
url = f"{self.server.rstrip('/')}/{self.topic}"
|
|
511
|
+
headers: dict[str, str] = {"Title": alert.title}
|
|
512
|
+
if self.token:
|
|
513
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
514
|
+
body = _format_detail_text(alert, strip=not self._include_captured_content)
|
|
515
|
+
with httpx.Client(timeout=5.0) as client:
|
|
516
|
+
client.post(url, content=body, headers=headers)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class WebhookChannel(AlertChannel):
|
|
520
|
+
"""HTTP POST to configured URL with JSON payload."""
|
|
521
|
+
|
|
522
|
+
def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
|
|
523
|
+
self.url = config.url or ""
|
|
524
|
+
self.method = config.method
|
|
525
|
+
self.headers = config.headers
|
|
526
|
+
self._include_captured_content = include_captured_content
|
|
527
|
+
self.min_severity = Severity(config.min_severity)
|
|
528
|
+
|
|
529
|
+
def send(self, alert: Alert) -> None:
|
|
530
|
+
if not self.url:
|
|
531
|
+
return
|
|
532
|
+
payload = _alert_to_dict(alert, strip=not self._include_captured_content)
|
|
533
|
+
with httpx.Client(timeout=5.0) as client:
|
|
534
|
+
client.request(
|
|
535
|
+
self.method,
|
|
536
|
+
self.url,
|
|
537
|
+
json=payload,
|
|
538
|
+
headers=self.headers,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class DiscordChannel(AlertChannel):
|
|
543
|
+
"""POST to Discord webhook URL with an embed coloured by severity."""
|
|
544
|
+
|
|
545
|
+
def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
|
|
546
|
+
self.webhook_url = config.webhook_url or ""
|
|
547
|
+
self._include_captured_content = include_captured_content
|
|
548
|
+
self.min_severity = Severity(config.min_severity)
|
|
549
|
+
|
|
550
|
+
def send(self, alert: Alert) -> None:
|
|
551
|
+
if not self.webhook_url:
|
|
552
|
+
return
|
|
553
|
+
colour_map = {
|
|
554
|
+
Severity.CRITICAL: 0xFF0000,
|
|
555
|
+
Severity.WARNING: 0xFFAA00,
|
|
556
|
+
Severity.INFO: 0x3498DB,
|
|
557
|
+
}
|
|
558
|
+
description = _format_detail_text(alert, strip=not self._include_captured_content)
|
|
559
|
+
payload = {
|
|
560
|
+
"embeds": [{
|
|
561
|
+
"title": alert.title,
|
|
562
|
+
"description": description,
|
|
563
|
+
"color": colour_map.get(alert.severity, 0x3498DB),
|
|
564
|
+
"fields": [
|
|
565
|
+
{"name": "Type", "value": alert.type.value, "inline": True},
|
|
566
|
+
{"name": "Severity", "value": alert.severity.value, "inline": True},
|
|
567
|
+
{"name": "Agent", "value": alert.agent_id or "unknown", "inline": True},
|
|
568
|
+
],
|
|
569
|
+
"timestamp": alert.fired_at.isoformat(),
|
|
570
|
+
}],
|
|
571
|
+
}
|
|
572
|
+
with httpx.Client(timeout=5.0) as client:
|
|
573
|
+
client.post(self.webhook_url, json=payload)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class TelegramChannel(AlertChannel):
|
|
577
|
+
"""POST to Telegram Bot API sendMessage with Markdown formatting."""
|
|
578
|
+
|
|
579
|
+
def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
|
|
580
|
+
self.bot_token = config.bot_token or ""
|
|
581
|
+
self.chat_id = config.chat_id or ""
|
|
582
|
+
self._include_captured_content = include_captured_content
|
|
583
|
+
self.min_severity = Severity(config.min_severity)
|
|
584
|
+
|
|
585
|
+
def send(self, alert: Alert) -> None:
|
|
586
|
+
if not self.bot_token or not self.chat_id:
|
|
587
|
+
return
|
|
588
|
+
message = _format_detail_text(alert, strip=not self._include_captured_content)
|
|
589
|
+
text = (
|
|
590
|
+
f"*{_escape_markdown(alert.title)}*\n"
|
|
591
|
+
f"Severity: {alert.severity.value}\n"
|
|
592
|
+
f"Type: {alert.type.value}\n"
|
|
593
|
+
f"Agent: {alert.agent_id or 'unknown'}\n\n"
|
|
594
|
+
f"{_escape_markdown(message)}"
|
|
595
|
+
)
|
|
596
|
+
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
|
597
|
+
payload = {
|
|
598
|
+
"chat_id": self.chat_id,
|
|
599
|
+
"text": text,
|
|
600
|
+
"parse_mode": "Markdown",
|
|
601
|
+
}
|
|
602
|
+
with httpx.Client(timeout=5.0) as client:
|
|
603
|
+
client.post(url, json=payload)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
_SEVERITY_RANK = {Severity.INFO: 0, Severity.WARNING: 1, Severity.CRITICAL: 2}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _severity_rank(sev: Severity) -> int:
|
|
612
|
+
return _SEVERITY_RANK.get(sev, 0)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _escape_markdown(text: str) -> str:
|
|
616
|
+
"""Escape Telegram Markdown v1 special characters."""
|
|
617
|
+
for ch in ("_", "*", "[", "`"):
|
|
618
|
+
text = text.replace(ch, f"\\{ch}")
|
|
619
|
+
return text
|