spanforge 2.0.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.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/actor.py ADDED
@@ -0,0 +1,141 @@
1
+ """spanforge.actor — Actor identity context for audit-trail events.
2
+
3
+ Provides :class:`ActorContext`, a lightweight carrier for the user, org,
4
+ and team identity that SOC 2 Type II and enterprise compliance audits
5
+ require on every operation that mutates system state.
6
+
7
+ Typical usage
8
+ -------------
9
+ Embed an ``ActorContext`` directly inside an event payload to satisfy audit
10
+ trail requirements::
11
+
12
+ from spanforge.actor import ActorContext
13
+ from spanforge import Event
14
+ from spanforge.types import EventType
15
+ from spanforge.namespaces.prompt import PromptPromotedPayload
16
+
17
+ actor = ActorContext(
18
+ user_id="usr_abc",
19
+ org_id="org_123",
20
+ team_id="team_456",
21
+ email="priya@acme.com",
22
+ ip_address="203.0.113.5",
23
+ )
24
+ payload = PromptPromotedPayload(
25
+ prompt_id="pmt_xyz",
26
+ version="v7",
27
+ from_environment="staging",
28
+ to_environment="production",
29
+ promoted_by=actor.user_id,
30
+ )
31
+ event = Event(
32
+ event_type=EventType.PROMPT_PROMOTED,
33
+ source="promptlock@1.0.0",
34
+ payload={**payload.to_dict(), "actor": actor.to_dict()},
35
+ )
36
+
37
+ OTel span attributes
38
+ ---------------------
39
+ When emitting to an OTel-compatible back-end, the actor dict maps to
40
+ custom resource/span attributes::
41
+
42
+ span.set_attribute("enduser.id", actor.user_id)
43
+ span.set_attribute("org.id", actor.org_id)
44
+ span.set_attribute("team.id", actor.team_id)
45
+
46
+ These supplement the ``gen_ai.*`` semantic conventions without conflicting
47
+ with them.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from dataclasses import dataclass
53
+ from typing import Any
54
+
55
+ __all__ = ["ActorContext"]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ActorContext:
60
+ """Identity and audit context for an actor performing an operation.
61
+
62
+ Satisfies SOC 2 audit trail requirements: every state-mutating operation
63
+ must record *who* did it, *from where*, and with what organisational
64
+ scope. All fields except ``user_id`` are optional to accommodate both
65
+ human users and CI/CD service accounts.
66
+
67
+ Parameters
68
+ ----------
69
+ user_id:
70
+ Opaque user identifier — stable across sessions and required for
71
+ audit compliance.
72
+ org_id:
73
+ Optional organisation identifier corresponding to the top-level
74
+ tenant in a multi-tenant system.
75
+ team_id:
76
+ Optional team identifier within the organisation.
77
+ email:
78
+ Optional email address of the actor. May be omitted or replaced
79
+ with a placeholder in high-privacy contexts.
80
+ ip_address:
81
+ Optional IP address from which the action originated.
82
+ service_account:
83
+ ``True`` if this action was performed by an automated CI/CD service
84
+ account rather than a human user. Defaults to ``False``.
85
+ """
86
+
87
+ user_id: str
88
+ org_id: str | None = None
89
+ team_id: str | None = None
90
+ email: str | None = None
91
+ ip_address: str | None = None
92
+ service_account: bool = False
93
+
94
+ # -----------------------------------------------------------------
95
+ # Validation
96
+ # -----------------------------------------------------------------
97
+
98
+ def __post_init__(self) -> None:
99
+ if not self.user_id or not isinstance(self.user_id, str):
100
+ raise ValueError("ActorContext.user_id must be a non-empty string")
101
+ for attr in ("org_id", "team_id", "email", "ip_address"):
102
+ value = getattr(self, attr)
103
+ if value is not None and not isinstance(value, str):
104
+ raise TypeError(f"ActorContext.{attr} must be a string or None")
105
+ if not isinstance(self.service_account, bool):
106
+ raise TypeError("ActorContext.service_account must be a bool")
107
+
108
+ # -----------------------------------------------------------------
109
+ # Serialisation
110
+ # -----------------------------------------------------------------
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ """Return a plain dict suitable for embedding in ``Event.payload``.
114
+
115
+ Only non-``None`` optional fields are included so that the
116
+ serialised form stays compact.
117
+ """
118
+ result: dict[str, Any] = {"user_id": self.user_id}
119
+ if self.org_id is not None:
120
+ result["org_id"] = self.org_id
121
+ if self.team_id is not None:
122
+ result["team_id"] = self.team_id
123
+ if self.email is not None:
124
+ result["email"] = self.email
125
+ if self.ip_address is not None:
126
+ result["ip_address"] = self.ip_address
127
+ if self.service_account:
128
+ result["service_account"] = True
129
+ return result
130
+
131
+ @classmethod
132
+ def from_dict(cls, data: dict[str, Any]) -> ActorContext:
133
+ """Reconstruct an :class:`ActorContext` from a plain dict."""
134
+ return cls(
135
+ user_id=str(data["user_id"]),
136
+ org_id=data.get("org_id"),
137
+ team_id=data.get("team_id"),
138
+ email=data.get("email"),
139
+ ip_address=data.get("ip_address"),
140
+ service_account=bool(data.get("service_account", False)),
141
+ )
spanforge/alerts.py ADDED
@@ -0,0 +1,464 @@
1
+ """spanforge.alerts — Built-in alerting integrations for threshold-based notifications.
2
+
3
+ Supports Slack, Microsoft Teams, PagerDuty, and SMTP email. All alerters share
4
+ a cooldown mechanism to avoid alert storms: the same ``alert_key`` will not fire
5
+ again until ``cooldown_seconds`` have elapsed.
6
+
7
+ Zero required dependencies — each alerter uses only stdlib (``urllib.request``,
8
+ ``smtplib``, ``json``).
9
+
10
+ Usage::
11
+
12
+ from spanforge.alerts import AlertManager, AlertConfig, SlackAlerter
13
+
14
+ manager = AlertManager(
15
+ alerters=[SlackAlerter(webhook_url="https://hooks.slack.com/services/...")],
16
+ cooldown_seconds=300,
17
+ )
18
+ manager.fire("high_error_rate", "Error rate exceeded 5% in the last minute")
19
+
20
+ Integration with cost budgets::
21
+
22
+ from spanforge import configure
23
+ from spanforge.alerts import AlertManager, SlackAlerter
24
+
25
+ configure(
26
+ alert_manager=AlertManager(
27
+ alerters=[SlackAlerter(webhook_url=os.environ["SLACK_WEBHOOK"])],
28
+ )
29
+ )
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import smtplib
37
+ import ssl
38
+ import threading
39
+ import time
40
+ import urllib.error
41
+ import urllib.request
42
+ from dataclasses import dataclass, field
43
+ from email.mime.text import MIMEText
44
+ from typing import Optional, Sequence
45
+
46
+ __all__ = [
47
+ "Alerter",
48
+ "SlackAlerter",
49
+ "TeamsAlerter",
50
+ "PagerDutyAlerter",
51
+ "EmailAlerter",
52
+ "AlertConfig",
53
+ "AlertManager",
54
+ ]
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Base protocol
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ class Alerter:
64
+ """Abstract base class for alerters. Subclasses must implement :meth:`send`."""
65
+
66
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
67
+ raise NotImplementedError
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Concrete alerters
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ @dataclass
76
+ class SlackAlerter(Alerter):
77
+ """Send a message to a Slack Incoming Webhook.
78
+
79
+ Args:
80
+ webhook_url: Slack Incoming Webhook URL.
81
+ channel: Optional channel override (e.g. ``"#alerts"``).
82
+ username: Bot display name.
83
+ icon_emoji: Emoji icon for the bot message.
84
+ timeout: HTTP request timeout in seconds.
85
+ """
86
+
87
+ webhook_url: str
88
+ channel: Optional[str] = None
89
+ username: str = "spanforge"
90
+ icon_emoji: str = ":robot_face:"
91
+ timeout: int = 10
92
+
93
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
94
+ colour = {"info": "#36a64f", "warning": "#ffcc00", "critical": "#ff0000"}.get(
95
+ severity, "#36a64f"
96
+ )
97
+ payload: dict = {
98
+ "username": self.username,
99
+ "icon_emoji": self.icon_emoji,
100
+ "attachments": [
101
+ {
102
+ "color": colour,
103
+ "title": title,
104
+ "text": message,
105
+ "footer": "spanforge",
106
+ }
107
+ ],
108
+ }
109
+ if self.channel:
110
+ payload["channel"] = self.channel
111
+
112
+ data = json.dumps(payload).encode()
113
+ req = urllib.request.Request(
114
+ self.webhook_url,
115
+ data=data,
116
+ headers={"Content-Type": "application/json"},
117
+ method="POST",
118
+ )
119
+ try:
120
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
121
+ if resp.status not in (200, 204):
122
+ logger.warning("SlackAlerter: unexpected status %s", resp.status)
123
+ except urllib.error.URLError as exc:
124
+ logger.warning("SlackAlerter: request failed: %s", exc)
125
+
126
+
127
+ @dataclass
128
+ class TeamsAlerter(Alerter):
129
+ """Send an Adaptive Card to a Microsoft Teams Incoming Webhook.
130
+
131
+ Args:
132
+ webhook_url: Teams channel Incoming Webhook URL.
133
+ timeout: HTTP request timeout in seconds.
134
+ """
135
+
136
+ webhook_url: str
137
+ timeout: int = 10
138
+
139
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
140
+ colour = {"info": "Good", "warning": "Warning", "critical": "Attention"}.get(
141
+ severity, "Warning"
142
+ )
143
+ payload = {
144
+ "type": "message",
145
+ "attachments": [
146
+ {
147
+ "contentType": "application/vnd.microsoft.card.adaptive",
148
+ "content": {
149
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
150
+ "type": "AdaptiveCard",
151
+ "version": "1.3",
152
+ "body": [
153
+ {
154
+ "type": "TextBlock",
155
+ "text": title,
156
+ "weight": "Bolder",
157
+ "size": "Medium",
158
+ "color": colour,
159
+ },
160
+ {"type": "TextBlock", "text": message, "wrap": True},
161
+ ],
162
+ },
163
+ }
164
+ ],
165
+ }
166
+ data = json.dumps(payload).encode()
167
+ req = urllib.request.Request(
168
+ self.webhook_url,
169
+ data=data,
170
+ headers={"Content-Type": "application/json"},
171
+ method="POST",
172
+ )
173
+ try:
174
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
175
+ if resp.status not in (200, 202):
176
+ logger.warning("TeamsAlerter: unexpected status %s", resp.status)
177
+ except urllib.error.URLError as exc:
178
+ logger.warning("TeamsAlerter: request failed: %s", exc)
179
+
180
+
181
+ @dataclass
182
+ class PagerDutyAlerter(Alerter):
183
+ """Trigger a PagerDuty incident via the Events API v2.
184
+
185
+ Args:
186
+ integration_key: PagerDuty integration/routing key (32-char hex string).
187
+ source: Source field in the PD event.
188
+ timeout: HTTP request timeout in seconds.
189
+ """
190
+
191
+ integration_key: str = field(repr=False)
192
+ source: str = "spanforge"
193
+ timeout: int = 10
194
+
195
+ _PD_URL = "https://events.pagerduty.com/v2/enqueue"
196
+
197
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
198
+ pd_severity = {"info": "info", "warning": "warning", "critical": "critical"}.get(
199
+ severity, "warning"
200
+ )
201
+ payload = {
202
+ "routing_key": self.integration_key,
203
+ "event_action": "trigger",
204
+ "payload": {
205
+ "summary": title,
206
+ "source": self.source,
207
+ "severity": pd_severity,
208
+ "custom_details": {"message": message},
209
+ },
210
+ }
211
+ data = json.dumps(payload).encode()
212
+ req = urllib.request.Request(
213
+ self._PD_URL,
214
+ data=data,
215
+ headers={"Content-Type": "application/json"},
216
+ method="POST",
217
+ )
218
+ try:
219
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
220
+ if resp.status not in (200, 202):
221
+ logger.warning("PagerDutyAlerter: unexpected status %s", resp.status)
222
+ except urllib.error.URLError as exc:
223
+ logger.warning("PagerDutyAlerter: request failed: %s", exc)
224
+
225
+
226
+ @dataclass
227
+ class EmailAlerter(Alerter):
228
+ """Send alert emails via SMTP.
229
+
230
+ Uses STARTTLS when ``use_tls=True`` (default). Credentials are taken from
231
+ the ``username`` / ``password`` fields; pass ``None`` for unauthenticated
232
+ SMTP (e.g. internal relay).
233
+
234
+ Args:
235
+ smtp_host: SMTP server hostname.
236
+ smtp_port: SMTP server port (default: 587 for STARTTLS).
237
+ from_address: Sender address.
238
+ to_addresses: List of recipient addresses.
239
+ subject_prefix: Prefix for the email subject line.
240
+ username: SMTP auth username; ``None`` skips AUTH.
241
+ password: SMTP auth password; ``None`` skips AUTH.
242
+ use_tls: Use STARTTLS (default: True).
243
+ timeout: Connection timeout in seconds.
244
+ """
245
+
246
+ smtp_host: str
247
+ smtp_port: int = 587
248
+ from_address: str = "spanforge@localhost"
249
+ to_addresses: Sequence[str] = field(default_factory=list)
250
+ subject_prefix: str = "[spanforge]"
251
+ username: Optional[str] = field(default=None, repr=False)
252
+ password: Optional[str] = field(default=None, repr=False)
253
+ use_tls: bool = True
254
+ timeout: int = 10
255
+
256
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
257
+ if not self.to_addresses:
258
+ logger.warning("EmailAlerter: no recipients configured, skipping")
259
+ return
260
+
261
+ subject = f"{self.subject_prefix} [{severity.upper()}] {title}"
262
+ body = f"{title}\n\nSeverity: {severity}\n\n{message}"
263
+ msg = MIMEText(body, "plain", "utf-8")
264
+ msg["Subject"] = subject
265
+ msg["From"] = self.from_address
266
+ msg["To"] = ", ".join(self.to_addresses)
267
+
268
+ context = ssl.create_default_context() if self.use_tls else None
269
+ try:
270
+ with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=self.timeout) as smtp:
271
+ if self.use_tls and context:
272
+ smtp.starttls(context=context)
273
+ if self.username and self.password:
274
+ smtp.login(self.username, self.password)
275
+ smtp.sendmail(self.from_address, list(self.to_addresses), msg.as_string())
276
+ except (smtplib.SMTPException, OSError) as exc:
277
+ logger.warning("EmailAlerter: send failed: %s", exc)
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # AlertConfig (thin data container for env-var driven configuration)
282
+ # ---------------------------------------------------------------------------
283
+
284
+
285
+ @dataclass
286
+ class AlertConfig:
287
+ """Data class holding alert configuration loaded from environment variables.
288
+
289
+ Typically accessed via :attr:`spanforge.config.SpanForgeConfig.alert_config`.
290
+
291
+ Environment variable mapping::
292
+
293
+ SPANFORGE_ALERT_SLACK_WEBHOOK → slack_webhook_url
294
+ SPANFORGE_ALERT_TEAMS_WEBHOOK → teams_webhook_url
295
+ SPANFORGE_ALERT_PAGERDUTY_KEY → pagerduty_integration_key
296
+ SPANFORGE_ALERT_SMTP_HOST → smtp_host
297
+ SPANFORGE_ALERT_SMTP_PORT → smtp_port (int)
298
+ SPANFORGE_ALERT_EMAIL_FROM → email_from
299
+ SPANFORGE_ALERT_EMAIL_TO → email_to (comma-separated)
300
+ SPANFORGE_ALERT_EMAIL_USERNAME → email_username
301
+ SPANFORGE_ALERT_EMAIL_PASSWORD → email_password
302
+ SPANFORGE_ALERT_COOLDOWN_SECONDS → cooldown_seconds (int, default 300)
303
+ """
304
+
305
+ slack_webhook_url: Optional[str] = None
306
+ teams_webhook_url: Optional[str] = None
307
+ pagerduty_integration_key: Optional[str] = field(default=None, repr=False)
308
+ smtp_host: Optional[str] = None
309
+ smtp_port: int = 587
310
+ email_from: str = "spanforge@localhost"
311
+ email_to: Sequence[str] = field(default_factory=list)
312
+ email_username: Optional[str] = field(default=None, repr=False)
313
+ email_password: Optional[str] = field(default=None, repr=False)
314
+ cooldown_seconds: int = 300
315
+
316
+ @classmethod
317
+ def from_env(cls) -> "AlertConfig":
318
+ """Construct an :class:`AlertConfig` by reading ``SPANFORGE_ALERT_*`` env vars."""
319
+ import os
320
+
321
+ email_to_raw = os.environ.get("SPANFORGE_ALERT_EMAIL_TO", "")
322
+ email_to = [a.strip() for a in email_to_raw.split(",") if a.strip()]
323
+
324
+ cooldown_raw = os.environ.get("SPANFORGE_ALERT_COOLDOWN_SECONDS", "300")
325
+ try:
326
+ cooldown = int(cooldown_raw)
327
+ except ValueError:
328
+ cooldown = 300
329
+
330
+ smtp_port_raw = os.environ.get("SPANFORGE_ALERT_SMTP_PORT", "587")
331
+ try:
332
+ smtp_port = int(smtp_port_raw)
333
+ except ValueError:
334
+ smtp_port = 587
335
+
336
+ return cls(
337
+ slack_webhook_url=os.environ.get("SPANFORGE_ALERT_SLACK_WEBHOOK"),
338
+ teams_webhook_url=os.environ.get("SPANFORGE_ALERT_TEAMS_WEBHOOK"),
339
+ pagerduty_integration_key=os.environ.get("SPANFORGE_ALERT_PAGERDUTY_KEY"),
340
+ smtp_host=os.environ.get("SPANFORGE_ALERT_SMTP_HOST"),
341
+ smtp_port=smtp_port,
342
+ email_from=os.environ.get("SPANFORGE_ALERT_EMAIL_FROM", "spanforge@localhost"),
343
+ email_to=email_to,
344
+ email_username=os.environ.get("SPANFORGE_ALERT_EMAIL_USERNAME"),
345
+ email_password=os.environ.get("SPANFORGE_ALERT_EMAIL_PASSWORD"),
346
+ cooldown_seconds=cooldown,
347
+ )
348
+
349
+ def build_manager(self) -> "AlertManager":
350
+ """Create an :class:`AlertManager` from this config."""
351
+ alerters: list[Alerter] = []
352
+ if self.slack_webhook_url:
353
+ alerters.append(SlackAlerter(webhook_url=self.slack_webhook_url))
354
+ if self.teams_webhook_url:
355
+ alerters.append(TeamsAlerter(webhook_url=self.teams_webhook_url))
356
+ if self.pagerduty_integration_key:
357
+ alerters.append(
358
+ PagerDutyAlerter(integration_key=self.pagerduty_integration_key)
359
+ )
360
+ if self.smtp_host and self.email_to:
361
+ alerters.append(
362
+ EmailAlerter(
363
+ smtp_host=self.smtp_host,
364
+ smtp_port=self.smtp_port,
365
+ from_address=self.email_from,
366
+ to_addresses=self.email_to,
367
+ username=self.email_username,
368
+ password=self.email_password,
369
+ )
370
+ )
371
+ return AlertManager(alerters=alerters, cooldown_seconds=self.cooldown_seconds)
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # AlertManager
376
+ # ---------------------------------------------------------------------------
377
+
378
+
379
+ class AlertManager:
380
+ """Dispatch alerts to one or more :class:`Alerter` backends with deduplication.
381
+
382
+ The same ``alert_key`` will not trigger again within ``cooldown_seconds``
383
+ (per-key cooldown window), preventing alert storms.
384
+
385
+ Thread-safe.
386
+
387
+ Args:
388
+ alerters: List of :class:`Alerter` instances to notify.
389
+ cooldown_seconds: Minimum seconds between repeated firings of the same key.
390
+
391
+ Example::
392
+
393
+ manager = AlertManager(
394
+ alerters=[SlackAlerter(webhook_url="https://hooks.slack.com/...")],
395
+ cooldown_seconds=300,
396
+ )
397
+ manager.fire("budget_exceeded", "Daily spend exceeded $100", severity="critical")
398
+ """
399
+
400
+ def __init__(
401
+ self,
402
+ alerters: Optional[Sequence[Alerter]] = None,
403
+ cooldown_seconds: int = 300,
404
+ ) -> None:
405
+ self._alerters: list[Alerter] = list(alerters or [])
406
+ self._cooldown = cooldown_seconds
407
+ self._last_fired: dict[str, float] = {}
408
+ self._lock = threading.Lock()
409
+
410
+ # ------------------------------------------------------------------
411
+ # Public interface
412
+ # ------------------------------------------------------------------
413
+
414
+ def add_alerter(self, alerter: Alerter) -> None:
415
+ """Append *alerter* to the notification list."""
416
+ with self._lock:
417
+ self._alerters.append(alerter)
418
+
419
+ def fire(
420
+ self,
421
+ alert_key: str,
422
+ message: str,
423
+ title: Optional[str] = None,
424
+ severity: str = "warning",
425
+ ) -> bool:
426
+ """Fire an alert if the cooldown window has elapsed.
427
+
428
+ Args:
429
+ alert_key: Unique identifier for the alert type (used for deduplication).
430
+ message: Human-readable alert body.
431
+ title: Short alert title. Defaults to *alert_key*.
432
+ severity: One of ``"info"``, ``"warning"``, ``"critical"``.
433
+
434
+ Returns:
435
+ ``True`` if the alert was dispatched; ``False`` if suppressed by cooldown.
436
+ """
437
+ if not self._alerters:
438
+ return False
439
+
440
+ now = time.monotonic()
441
+ with self._lock:
442
+ last = self._last_fired.get(alert_key, 0.0)
443
+ if now - last < self._cooldown:
444
+ logger.debug(
445
+ "AlertManager: suppressed '%s' (cooldown %ss remaining)",
446
+ alert_key,
447
+ int(self._cooldown - (now - last)),
448
+ )
449
+ return False
450
+ self._last_fired[alert_key] = now
451
+ active_alerters = list(self._alerters)
452
+
453
+ resolved_title = title or alert_key.replace("_", " ").title()
454
+ for alerter in active_alerters:
455
+ try:
456
+ alerter.send(resolved_title, message, severity=severity)
457
+ except Exception: # noqa: BLE001
458
+ logger.exception("AlertManager: alerter %r raised an exception", alerter)
459
+ return True
460
+
461
+ def reset_cooldown(self, alert_key: str) -> None:
462
+ """Reset the cooldown for *alert_key*, allowing it to fire immediately."""
463
+ with self._lock:
464
+ self._last_fired.pop(alert_key, None)