spanforge 1.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 (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -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,469 @@
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 os
37
+ import smtplib
38
+ import ssl
39
+ import threading
40
+ import time
41
+ import urllib.error
42
+ import urllib.request
43
+ from dataclasses import dataclass, field
44
+ from email.mime.text import MIMEText
45
+ from typing import TYPE_CHECKING, Any
46
+
47
+ if TYPE_CHECKING:
48
+ from collections.abc import Sequence
49
+
50
+ __all__ = [
51
+ "AlertConfig",
52
+ "AlertManager",
53
+ "Alerter",
54
+ "EmailAlerter",
55
+ "PagerDutyAlerter",
56
+ "SlackAlerter",
57
+ "TeamsAlerter",
58
+ ]
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Base protocol
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class Alerter:
68
+ """Abstract base class for alerters. Subclasses must implement :meth:`send`."""
69
+
70
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
71
+ """Send an alert notification."""
72
+ raise NotImplementedError
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Concrete alerters
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ @dataclass
81
+ class SlackAlerter(Alerter):
82
+ """Send a message to a Slack Incoming Webhook.
83
+
84
+ Args:
85
+ webhook_url: Slack Incoming Webhook URL.
86
+ channel: Optional channel override (e.g. ``"#alerts"``).
87
+ username: Bot display name.
88
+ icon_emoji: Emoji icon for the bot message.
89
+ timeout: HTTP request timeout in seconds.
90
+ """
91
+
92
+ webhook_url: str
93
+ channel: str | None = None
94
+ username: str = "spanforge"
95
+ icon_emoji: str = ":robot_face:"
96
+ timeout: int = 10
97
+
98
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
99
+ """Send alert via Slack Incoming Webhook."""
100
+ colour = {"info": "#36a64f", "warning": "#ffcc00", "critical": "#ff0000"}.get(
101
+ severity, "#36a64f"
102
+ )
103
+ payload: dict[str, Any] = {
104
+ "username": self.username,
105
+ "icon_emoji": self.icon_emoji,
106
+ "attachments": [
107
+ {
108
+ "color": colour,
109
+ "title": title,
110
+ "text": message,
111
+ "footer": "spanforge",
112
+ }
113
+ ],
114
+ }
115
+ if self.channel:
116
+ payload["channel"] = self.channel
117
+
118
+ data = json.dumps(payload).encode()
119
+ req = urllib.request.Request(
120
+ self.webhook_url,
121
+ data=data,
122
+ headers={"Content-Type": "application/json"},
123
+ method="POST",
124
+ )
125
+ try:
126
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
127
+ if resp.status not in (200, 204):
128
+ logger.warning("SlackAlerter: unexpected status %s", resp.status)
129
+ except urllib.error.URLError as exc:
130
+ logger.warning("SlackAlerter: request failed: %s", exc)
131
+
132
+
133
+ @dataclass
134
+ class TeamsAlerter(Alerter):
135
+ """Send an Adaptive Card to a Microsoft Teams Incoming Webhook.
136
+
137
+ Args:
138
+ webhook_url: Teams channel Incoming Webhook URL.
139
+ timeout: HTTP request timeout in seconds.
140
+ """
141
+
142
+ webhook_url: str
143
+ timeout: int = 10
144
+
145
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
146
+ """Send alert via Microsoft Teams Incoming Webhook."""
147
+ colour = {"info": "Good", "warning": "Warning", "critical": "Attention"}.get(
148
+ severity, "Warning"
149
+ )
150
+ payload = {
151
+ "type": "message",
152
+ "attachments": [
153
+ {
154
+ "contentType": "application/vnd.microsoft.card.adaptive",
155
+ "content": {
156
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
157
+ "type": "AdaptiveCard",
158
+ "version": "1.3",
159
+ "body": [
160
+ {
161
+ "type": "TextBlock",
162
+ "text": title,
163
+ "weight": "Bolder",
164
+ "size": "Medium",
165
+ "color": colour,
166
+ },
167
+ {"type": "TextBlock", "text": message, "wrap": True},
168
+ ],
169
+ },
170
+ }
171
+ ],
172
+ }
173
+ data = json.dumps(payload).encode()
174
+ req = urllib.request.Request(
175
+ self.webhook_url,
176
+ data=data,
177
+ headers={"Content-Type": "application/json"},
178
+ method="POST",
179
+ )
180
+ try:
181
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
182
+ if resp.status not in (200, 202):
183
+ logger.warning("TeamsAlerter: unexpected status %s", resp.status)
184
+ except urllib.error.URLError as exc:
185
+ logger.warning("TeamsAlerter: request failed: %s", exc)
186
+
187
+
188
+ @dataclass
189
+ class PagerDutyAlerter(Alerter):
190
+ """Trigger a PagerDuty incident via the Events API v2.
191
+
192
+ Args:
193
+ integration_key: PagerDuty integration/routing key (32-char hex string).
194
+ source: Source field in the PD event.
195
+ timeout: HTTP request timeout in seconds.
196
+ """
197
+
198
+ integration_key: str = field(repr=False)
199
+ source: str = "spanforge"
200
+ timeout: int = 10
201
+
202
+ _PD_URL = "https://events.pagerduty.com/v2/enqueue"
203
+
204
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
205
+ """Trigger a PagerDuty incident via the Events API v2."""
206
+ pd_severity = {"info": "info", "warning": "warning", "critical": "critical"}.get(
207
+ severity, "warning"
208
+ )
209
+ payload = {
210
+ "routing_key": self.integration_key,
211
+ "event_action": "trigger",
212
+ "payload": {
213
+ "summary": title,
214
+ "source": self.source,
215
+ "severity": pd_severity,
216
+ "custom_details": {"message": message},
217
+ },
218
+ }
219
+ data = json.dumps(payload).encode()
220
+ req = urllib.request.Request(
221
+ self._PD_URL,
222
+ data=data,
223
+ headers={"Content-Type": "application/json"},
224
+ method="POST",
225
+ )
226
+ try:
227
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
228
+ if resp.status not in (200, 202):
229
+ logger.warning("PagerDutyAlerter: unexpected status %s", resp.status)
230
+ except urllib.error.URLError as exc:
231
+ logger.warning("PagerDutyAlerter: request failed: %s", exc)
232
+
233
+
234
+ @dataclass
235
+ class EmailAlerter(Alerter):
236
+ """Send alert emails via SMTP.
237
+
238
+ Uses STARTTLS when ``use_tls=True`` (default). Credentials are taken from
239
+ the ``username`` / ``password`` fields; pass ``None`` for unauthenticated
240
+ SMTP (e.g. internal relay).
241
+
242
+ Args:
243
+ smtp_host: SMTP server hostname.
244
+ smtp_port: SMTP server port (default: 587 for STARTTLS).
245
+ from_address: Sender address.
246
+ to_addresses: List of recipient addresses.
247
+ subject_prefix: Prefix for the email subject line.
248
+ username: SMTP auth username; ``None`` skips AUTH.
249
+ password: SMTP auth password; ``None`` skips AUTH.
250
+ use_tls: Use STARTTLS (default: True).
251
+ timeout: Connection timeout in seconds.
252
+ """
253
+
254
+ smtp_host: str
255
+ smtp_port: int = 587
256
+ from_address: str = "spanforge@localhost"
257
+ to_addresses: Sequence[str] = field(default_factory=list)
258
+ subject_prefix: str = "[spanforge]"
259
+ username: str | None = field(default=None, repr=False)
260
+ password: str | None = field(default=None, repr=False)
261
+ use_tls: bool = True
262
+ timeout: int = 10
263
+
264
+ def send(self, title: str, message: str, severity: str = "warning") -> None:
265
+ """Send alert email via SMTP."""
266
+ if not self.to_addresses:
267
+ logger.warning("EmailAlerter: no recipients configured, skipping")
268
+ return
269
+
270
+ subject = f"{self.subject_prefix} [{severity.upper()}] {title}"
271
+ body = f"{title}\n\nSeverity: {severity}\n\n{message}"
272
+ msg = MIMEText(body, "plain", "utf-8")
273
+ msg["Subject"] = subject
274
+ msg["From"] = self.from_address
275
+ msg["To"] = ", ".join(self.to_addresses)
276
+
277
+ context = ssl.create_default_context() if self.use_tls else None
278
+ try:
279
+ with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=self.timeout) as smtp:
280
+ if self.use_tls and context:
281
+ smtp.starttls(context=context)
282
+ if self.username and self.password:
283
+ smtp.login(self.username, self.password)
284
+ smtp.sendmail(self.from_address, list(self.to_addresses), msg.as_string())
285
+ except (smtplib.SMTPException, OSError) as exc:
286
+ logger.warning("EmailAlerter: send failed: %s", exc)
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # AlertConfig (thin data container for env-var driven configuration)
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ @dataclass
295
+ class AlertConfig:
296
+ """Data class holding alert configuration loaded from environment variables.
297
+
298
+ Typically accessed via :attr:`spanforge.config.SpanForgeConfig.alert_config`.
299
+
300
+ Environment variable mapping::
301
+
302
+ SPANFORGE_ALERT_SLACK_WEBHOOK → slack_webhook_url
303
+ SPANFORGE_ALERT_TEAMS_WEBHOOK → teams_webhook_url
304
+ SPANFORGE_ALERT_PAGERDUTY_KEY → pagerduty_integration_key
305
+ SPANFORGE_ALERT_SMTP_HOST → smtp_host
306
+ SPANFORGE_ALERT_SMTP_PORT → smtp_port (int)
307
+ SPANFORGE_ALERT_EMAIL_FROM → email_from
308
+ SPANFORGE_ALERT_EMAIL_TO → email_to (comma-separated)
309
+ SPANFORGE_ALERT_EMAIL_USERNAME → email_username
310
+ SPANFORGE_ALERT_EMAIL_PASSWORD → email_password
311
+ SPANFORGE_ALERT_COOLDOWN_SECONDS → cooldown_seconds (int, default 300)
312
+ """
313
+
314
+ slack_webhook_url: str | None = None
315
+ teams_webhook_url: str | None = None
316
+ pagerduty_integration_key: str | None = field(default=None, repr=False)
317
+ smtp_host: str | None = None
318
+ smtp_port: int = 587
319
+ email_from: str = "spanforge@localhost"
320
+ email_to: Sequence[str] = field(default_factory=list)
321
+ email_username: str | None = field(default=None, repr=False)
322
+ email_password: str | None = field(default=None, repr=False)
323
+ cooldown_seconds: int = 300
324
+
325
+ @classmethod
326
+ def from_env(cls) -> AlertConfig:
327
+ """Construct an :class:`AlertConfig` by reading ``SPANFORGE_ALERT_*`` env vars."""
328
+ email_to_raw = os.environ.get("SPANFORGE_ALERT_EMAIL_TO", "")
329
+ email_to = [a.strip() for a in email_to_raw.split(",") if a.strip()]
330
+
331
+ cooldown_raw = os.environ.get("SPANFORGE_ALERT_COOLDOWN_SECONDS", "300")
332
+ try:
333
+ cooldown = int(cooldown_raw)
334
+ except ValueError:
335
+ cooldown = 300
336
+
337
+ smtp_port_raw = os.environ.get("SPANFORGE_ALERT_SMTP_PORT", "587")
338
+ try:
339
+ smtp_port = int(smtp_port_raw)
340
+ except ValueError:
341
+ smtp_port = 587
342
+
343
+ return cls(
344
+ slack_webhook_url=os.environ.get("SPANFORGE_ALERT_SLACK_WEBHOOK"),
345
+ teams_webhook_url=os.environ.get("SPANFORGE_ALERT_TEAMS_WEBHOOK"),
346
+ pagerduty_integration_key=os.environ.get("SPANFORGE_ALERT_PAGERDUTY_KEY"),
347
+ smtp_host=os.environ.get("SPANFORGE_ALERT_SMTP_HOST"),
348
+ smtp_port=smtp_port,
349
+ email_from=os.environ.get("SPANFORGE_ALERT_EMAIL_FROM", "spanforge@localhost"),
350
+ email_to=email_to,
351
+ email_username=os.environ.get("SPANFORGE_ALERT_EMAIL_USERNAME"),
352
+ email_password=os.environ.get("SPANFORGE_ALERT_EMAIL_PASSWORD"),
353
+ cooldown_seconds=cooldown,
354
+ )
355
+
356
+ def build_manager(self) -> AlertManager:
357
+ """Create an :class:`AlertManager` from this config."""
358
+ alerters: list[Alerter] = []
359
+ if self.slack_webhook_url:
360
+ alerters.append(SlackAlerter(webhook_url=self.slack_webhook_url))
361
+ if self.teams_webhook_url:
362
+ alerters.append(TeamsAlerter(webhook_url=self.teams_webhook_url))
363
+ if self.pagerduty_integration_key:
364
+ alerters.append(PagerDutyAlerter(integration_key=self.pagerduty_integration_key))
365
+ if self.smtp_host and self.email_to:
366
+ alerters.append(
367
+ EmailAlerter(
368
+ smtp_host=self.smtp_host,
369
+ smtp_port=self.smtp_port,
370
+ from_address=self.email_from,
371
+ to_addresses=self.email_to,
372
+ username=self.email_username,
373
+ password=self.email_password,
374
+ )
375
+ )
376
+ return AlertManager(alerters=alerters, cooldown_seconds=self.cooldown_seconds)
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # AlertManager
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ class AlertManager:
385
+ """Dispatch alerts to one or more :class:`Alerter` backends with deduplication.
386
+
387
+ The same ``alert_key`` will not trigger again within ``cooldown_seconds``
388
+ (per-key cooldown window), preventing alert storms.
389
+
390
+ Thread-safe.
391
+
392
+ Args:
393
+ alerters: List of :class:`Alerter` instances to notify.
394
+ cooldown_seconds: Minimum seconds between repeated firings of the same key.
395
+
396
+ Example::
397
+
398
+ manager = AlertManager(
399
+ alerters=[SlackAlerter(webhook_url="https://hooks.slack.com/...")],
400
+ cooldown_seconds=300,
401
+ )
402
+ manager.fire("budget_exceeded", "Daily spend exceeded $100", severity="critical")
403
+ """
404
+
405
+ def __init__(
406
+ self,
407
+ alerters: Sequence[Alerter] | None = None,
408
+ cooldown_seconds: int = 300,
409
+ ) -> None:
410
+ self._alerters: list[Alerter] = list(alerters or [])
411
+ self._cooldown = cooldown_seconds
412
+ self._last_fired: dict[str, float] = {}
413
+ self._lock = threading.Lock()
414
+
415
+ # ------------------------------------------------------------------
416
+ # Public interface
417
+ # ------------------------------------------------------------------
418
+
419
+ def add_alerter(self, alerter: Alerter) -> None:
420
+ """Append *alerter* to the notification list."""
421
+ with self._lock:
422
+ self._alerters.append(alerter)
423
+
424
+ def fire(
425
+ self,
426
+ alert_key: str,
427
+ message: str,
428
+ title: str | None = None,
429
+ severity: str = "warning",
430
+ ) -> bool:
431
+ """Fire an alert if the cooldown window has elapsed.
432
+
433
+ Args:
434
+ alert_key: Unique identifier for the alert type (used for deduplication).
435
+ message: Human-readable alert body.
436
+ title: Short alert title. Defaults to *alert_key*.
437
+ severity: One of ``"info"``, ``"warning"``, ``"critical"``.
438
+
439
+ Returns:
440
+ ``True`` if the alert was dispatched; ``False`` if suppressed by cooldown.
441
+ """
442
+ if not self._alerters:
443
+ return False
444
+
445
+ now = time.monotonic()
446
+ with self._lock:
447
+ last = self._last_fired.get(alert_key, 0.0)
448
+ if now - last < self._cooldown:
449
+ logger.debug(
450
+ "AlertManager: suppressed '%s' (cooldown %ss remaining)",
451
+ alert_key,
452
+ int(self._cooldown - (now - last)),
453
+ )
454
+ return False
455
+ self._last_fired[alert_key] = now
456
+ active_alerters = list(self._alerters)
457
+
458
+ resolved_title = title or alert_key.replace("_", " ").title()
459
+ for alerter in active_alerters:
460
+ try:
461
+ alerter.send(resolved_title, message, severity=severity)
462
+ except Exception:
463
+ logger.exception("AlertManager: alerter %r raised an exception", alerter)
464
+ return True
465
+
466
+ def reset_cooldown(self, alert_key: str) -> None:
467
+ """Reset the cooldown for *alert_key*, allowing it to fire immediately."""
468
+ with self._lock:
469
+ self._last_fired.pop(alert_key, None)