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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- 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)
|