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/normalizer.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""spanforge.normalizer — ProviderNormalizer Protocol and GenericNormalizer.
|
|
2
|
+
|
|
3
|
+
Defines the :class:`ProviderNormalizer` structural protocol (RFC-0001 §10.4)
|
|
4
|
+
that provider-specific integration modules must satisfy, plus a
|
|
5
|
+
:class:`GenericNormalizer` fallback that handles OpenAI-compatible,
|
|
6
|
+
Anthropic-compatible, and raw ``dict`` response shapes without requiring
|
|
7
|
+
any vendored SDK.
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
::
|
|
12
|
+
|
|
13
|
+
from spanforge.normalizer import GenericNormalizer
|
|
14
|
+
|
|
15
|
+
normalizer = GenericNormalizer()
|
|
16
|
+
token_usage, model_info, cost = normalizer.normalize_response(raw_response)
|
|
17
|
+
|
|
18
|
+
RFC reference
|
|
19
|
+
-------------
|
|
20
|
+
RFC-0001-SPANFORGE §10.4 — Provider Normalizer interface mandate.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
from spanforge.namespaces.trace import CostBreakdown, ModelInfo, TokenUsage
|
|
28
|
+
|
|
29
|
+
__all__: list[str] = ["ProviderNormalizer", "GenericNormalizer"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Protocol
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class ProviderNormalizer(Protocol):
|
|
39
|
+
"""Structural protocol for provider-specific response normalizers.
|
|
40
|
+
|
|
41
|
+
Any object implementing this single-method interface can be used as a
|
|
42
|
+
drop-in normalizer within the SpanForge instrumentation pipeline. No
|
|
43
|
+
base class is required — structural (duck-typed) conformance is enough.
|
|
44
|
+
|
|
45
|
+
Implementors
|
|
46
|
+
------------
|
|
47
|
+
* :class:`GenericNormalizer` — OpenAI-compatible + Anthropic-compatible
|
|
48
|
+
shapes; zero-dependency fallback.
|
|
49
|
+
* ``spanforge.integrations.openai.OpenAINormalizer`` (when available)
|
|
50
|
+
* ``spanforge.integrations.anthropic.AnthropicNormalizer`` (when available)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def normalize_response(
|
|
54
|
+
self,
|
|
55
|
+
response: object,
|
|
56
|
+
) -> tuple[TokenUsage, ModelInfo, CostBreakdown | None]:
|
|
57
|
+
"""Extract :class:`~spanforge.namespaces.trace.TokenUsage`,
|
|
58
|
+
:class:`~spanforge.namespaces.trace.ModelInfo`, and optionally
|
|
59
|
+
:class:`~spanforge.namespaces.trace.CostBreakdown` from a raw LLM
|
|
60
|
+
provider response object.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
response:
|
|
65
|
+
Raw response object or dict from a provider SDK call.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
tuple[TokenUsage, ModelInfo, CostBreakdown | None]
|
|
70
|
+
A 3-tuple of typed value objects. ``CostBreakdown`` will be
|
|
71
|
+
``None`` when pricing data is unavailable.
|
|
72
|
+
"""
|
|
73
|
+
... # pragma: no cover — Protocol method, never called directly.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Generic fallback implementation
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
_UNKNOWN = "_custom"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get(obj: Any, *keys: str, default: Any = None) -> Any:
|
|
84
|
+
"""Attribute-then-dict key lookup — tolerates both objects and dicts."""
|
|
85
|
+
for key in keys:
|
|
86
|
+
if obj is None:
|
|
87
|
+
return default
|
|
88
|
+
if isinstance(obj, dict):
|
|
89
|
+
obj = obj.get(key)
|
|
90
|
+
else:
|
|
91
|
+
obj = getattr(obj, key, None)
|
|
92
|
+
return obj if obj is not None else default
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class GenericNormalizer:
|
|
96
|
+
"""Zero-dependency fallback normalizer for common LLM response shapes.
|
|
97
|
+
|
|
98
|
+
Supports three structural layouts without requiring any provider SDK:
|
|
99
|
+
|
|
100
|
+
1. **OpenAI-compatible** — ``response.usage.{prompt_tokens,
|
|
101
|
+
completion_tokens, total_tokens}``, ``response.model``.
|
|
102
|
+
2. **Anthropic-compatible** — ``response.usage.{input_tokens,
|
|
103
|
+
output_tokens}``, ``response.model``.
|
|
104
|
+
3. **Raw dict** — any dict with keys from either layout above.
|
|
105
|
+
|
|
106
|
+
When neither layout matches, sensible zero-value defaults are returned
|
|
107
|
+
so the caller always gets a valid :class:`~spanforge.namespaces.trace.TokenUsage`
|
|
108
|
+
regardless of the provider response shape.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def normalize_response(
|
|
112
|
+
self,
|
|
113
|
+
response: object,
|
|
114
|
+
) -> tuple[TokenUsage, ModelInfo, CostBreakdown | None]:
|
|
115
|
+
"""Normalise *response* into typed SpanForge value objects.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
response:
|
|
120
|
+
Raw provider response — may be a dataclass, SDK response object,
|
|
121
|
+
or plain ``dict``.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
tuple[TokenUsage, ModelInfo, CostBreakdown | None]
|
|
126
|
+
Typed value objects; ``CostBreakdown`` is always ``None`` (pricing
|
|
127
|
+
data requires a :class:`~spanforge.namespaces.trace.PricingTier`
|
|
128
|
+
which this generic normalizer does not possess).
|
|
129
|
+
"""
|
|
130
|
+
usage = _get(response, "usage")
|
|
131
|
+
|
|
132
|
+
# ---------- token counts ----------
|
|
133
|
+
# OpenAI layout: prompt_tokens / completion_tokens / total_tokens
|
|
134
|
+
# Anthropic layout: input_tokens / output_tokens
|
|
135
|
+
input_tokens: int = int(
|
|
136
|
+
_get(usage, "prompt_tokens", default=0)
|
|
137
|
+
or _get(usage, "input_tokens", default=0)
|
|
138
|
+
or 0
|
|
139
|
+
)
|
|
140
|
+
output_tokens: int = int(
|
|
141
|
+
_get(usage, "completion_tokens", default=0)
|
|
142
|
+
or _get(usage, "output_tokens", default=0)
|
|
143
|
+
or 0
|
|
144
|
+
)
|
|
145
|
+
total_tokens: int = int(
|
|
146
|
+
_get(usage, "total_tokens", default=0)
|
|
147
|
+
or (input_tokens + output_tokens)
|
|
148
|
+
)
|
|
149
|
+
cached_tokens: int = int(
|
|
150
|
+
_get(usage, "cached_tokens", default=0)
|
|
151
|
+
or _get(usage, "cache_read_input_tokens", default=0)
|
|
152
|
+
or 0
|
|
153
|
+
)
|
|
154
|
+
cache_creation_tokens: int = int(
|
|
155
|
+
_get(usage, "cache_creation_input_tokens", default=0) or 0
|
|
156
|
+
)
|
|
157
|
+
reasoning_tokens: int = int(
|
|
158
|
+
_get(usage, "reasoning_tokens", default=0) or 0
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
token_usage = TokenUsage(
|
|
162
|
+
input_tokens=input_tokens,
|
|
163
|
+
output_tokens=output_tokens,
|
|
164
|
+
total_tokens=total_tokens,
|
|
165
|
+
cached_tokens=cached_tokens if cached_tokens else None,
|
|
166
|
+
cache_creation_tokens=cache_creation_tokens if cache_creation_tokens else None,
|
|
167
|
+
reasoning_tokens=reasoning_tokens if reasoning_tokens else None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# ---------- model info ----------
|
|
171
|
+
model_name: str = str(
|
|
172
|
+
_get(response, "model", default="")
|
|
173
|
+
or _get(response, "model_id", default="")
|
|
174
|
+
or "unknown"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
model_info = ModelInfo(
|
|
178
|
+
system=_UNKNOWN,
|
|
179
|
+
name=model_name,
|
|
180
|
+
response_model=model_name,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return token_usage, model_info, None
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""spanforge.presidio_backend — Optional Presidio-powered PII detection backend.
|
|
2
|
+
|
|
3
|
+
Wraps Microsoft Presidio AnalyzerEngine to provide entity recognition that
|
|
4
|
+
is more accurate than regex-only scanning. Falls back gracefully if the
|
|
5
|
+
``presidio-analyzer`` package is not installed.
|
|
6
|
+
|
|
7
|
+
Install with::
|
|
8
|
+
|
|
9
|
+
pip install "spanforge[presidio]"
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from spanforge.presidio_backend import presidio_scan_payload, is_available
|
|
14
|
+
|
|
15
|
+
if is_available():
|
|
16
|
+
result = presidio_scan_payload({"message": "My SSN is 123-45-6789"})
|
|
17
|
+
print(result.clean) # False
|
|
18
|
+
|
|
19
|
+
The result is a standard :class:`~spanforge.redact.PIIScanResult`, fully
|
|
20
|
+
compatible with the built-in regex scanner.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from spanforge.redact import PIIScanHit, PIIScanResult
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"is_available",
|
|
32
|
+
"presidio_scan_payload",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Availability check
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_available() -> bool:
|
|
41
|
+
"""Return ``True`` if the ``presidio-analyzer`` package is importable."""
|
|
42
|
+
try:
|
|
43
|
+
import presidio_analyzer # type: ignore[import-untyped] # noqa: PLC0415, F401
|
|
44
|
+
return True
|
|
45
|
+
except ImportError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Map Presidio entity types to SpanForge PII labels / sensitivity
|
|
50
|
+
_ENTITY_MAP: dict[str, tuple[str, str]] = {
|
|
51
|
+
"CREDIT_CARD": ("credit_card", "high"),
|
|
52
|
+
"CRYPTO": ("crypto_address", "medium"),
|
|
53
|
+
"EMAIL_ADDRESS": ("email", "medium"),
|
|
54
|
+
"IBAN_CODE": ("iban", "high"),
|
|
55
|
+
"IP_ADDRESS": ("ip_address", "low"),
|
|
56
|
+
"LOCATION": ("location", "low"),
|
|
57
|
+
"PERSON": ("person_name", "medium"),
|
|
58
|
+
"PHONE_NUMBER": ("phone", "medium"),
|
|
59
|
+
"US_SSN": ("ssn", "high"),
|
|
60
|
+
"UK_NHS": ("uk_nhs", "high"),
|
|
61
|
+
"US_DRIVER_LICENSE": ("us_driver_license", "high"),
|
|
62
|
+
"US_PASSPORT": ("us_passport", "high"),
|
|
63
|
+
"IN_AADHAAR": ("aadhaar", "high"),
|
|
64
|
+
"IN_PAN": ("pan", "high"),
|
|
65
|
+
"NRP": ("nationality", "low"),
|
|
66
|
+
"MEDICAL_LICENSE": ("medical_license", "medium"),
|
|
67
|
+
"URL": ("url", "low"),
|
|
68
|
+
"DATE_TIME": ("date_time", "low"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Public API
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def presidio_scan_payload(
|
|
78
|
+
payload: dict[str, Any],
|
|
79
|
+
*,
|
|
80
|
+
language: str = "en",
|
|
81
|
+
score_threshold: float = 0.5,
|
|
82
|
+
max_depth: int = 10,
|
|
83
|
+
) -> PIIScanResult:
|
|
84
|
+
"""Scan a payload dict for PII using Microsoft Presidio.
|
|
85
|
+
|
|
86
|
+
Walks the payload recursively (up to *max_depth*), analysing every string
|
|
87
|
+
value with the Presidio ``AnalyzerEngine``.
|
|
88
|
+
|
|
89
|
+
**Security**: detected values are never returned — only the entity type,
|
|
90
|
+
path, count, and sensitivity level.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
payload: The dictionary to scan.
|
|
94
|
+
language: Language code for analysis (default: ``"en"``).
|
|
95
|
+
score_threshold: Minimum Presidio confidence score (default: 0.5).
|
|
96
|
+
max_depth: Maximum nesting depth (default: 10).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A :class:`~spanforge.redact.PIIScanResult` summarising detections.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ImportError: If ``presidio-analyzer`` is not installed.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
from presidio_analyzer import AnalyzerEngine # type: ignore[import-untyped] # noqa: PLC0415
|
|
106
|
+
except ImportError as exc:
|
|
107
|
+
raise ImportError(
|
|
108
|
+
"The 'presidio-analyzer' package is required for the Presidio backend.\n"
|
|
109
|
+
"Install it with: pip install 'spanforge[presidio]'"
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
analyzer = AnalyzerEngine()
|
|
113
|
+
hits: list[PIIScanHit] = []
|
|
114
|
+
scanned = 0
|
|
115
|
+
|
|
116
|
+
def _walk(obj: Any, path: str, depth: int) -> None: # noqa: ANN401
|
|
117
|
+
nonlocal scanned
|
|
118
|
+
if depth > max_depth:
|
|
119
|
+
return
|
|
120
|
+
if isinstance(obj, str):
|
|
121
|
+
scanned += 1
|
|
122
|
+
results = analyzer.analyze(
|
|
123
|
+
text=obj,
|
|
124
|
+
language=language,
|
|
125
|
+
score_threshold=score_threshold,
|
|
126
|
+
)
|
|
127
|
+
# Group by entity type
|
|
128
|
+
entity_counts: dict[str, int] = {}
|
|
129
|
+
for r in results:
|
|
130
|
+
entity_counts[r.entity_type] = entity_counts.get(r.entity_type, 0) + 1
|
|
131
|
+
for entity_type, count in entity_counts.items():
|
|
132
|
+
label, sensitivity = _ENTITY_MAP.get(
|
|
133
|
+
entity_type, (entity_type.lower(), "medium")
|
|
134
|
+
)
|
|
135
|
+
hits.append(PIIScanHit(
|
|
136
|
+
pii_type=label,
|
|
137
|
+
path=path,
|
|
138
|
+
match_count=count,
|
|
139
|
+
sensitivity=sensitivity,
|
|
140
|
+
))
|
|
141
|
+
elif isinstance(obj, Mapping):
|
|
142
|
+
for k, v in obj.items():
|
|
143
|
+
_walk(v, f"{path}.{k}" if path else str(k), depth + 1)
|
|
144
|
+
elif isinstance(obj, (list, tuple)):
|
|
145
|
+
for i, v in enumerate(obj):
|
|
146
|
+
_walk(v, f"{path}[{i}]", depth + 1)
|
|
147
|
+
|
|
148
|
+
_walk(payload, "", 0)
|
|
149
|
+
return PIIScanResult(hits=hits, scanned=scanned)
|
spanforge/processor.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""spanforge.processor — Span processor pipeline (RFC-0001 §18).
|
|
2
|
+
|
|
3
|
+
Span processors let users hook into the span lifecycle **before** and
|
|
4
|
+
**after** a span is exported. Common uses:
|
|
5
|
+
|
|
6
|
+
* Attribute enrichment (e.g. add ``k8s.pod_name`` to every span)
|
|
7
|
+
* Redaction of sensitive fields (complementing built-in :class:`~spanforge.redact.RedactionPolicy`)
|
|
8
|
+
* Custom metrics counters / latency histograms
|
|
9
|
+
* Distributed context propagation helpers
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from spanforge import configure
|
|
14
|
+
from spanforge.processor import SpanProcessor, ProcessorChain
|
|
15
|
+
|
|
16
|
+
class EnrichProcessor(SpanProcessor):
|
|
17
|
+
def on_start(self, span) -> None:
|
|
18
|
+
span.set_attribute("service.region", "us-east-1")
|
|
19
|
+
|
|
20
|
+
def on_end(self, span) -> None:
|
|
21
|
+
# span is already finalised with status / duration
|
|
22
|
+
if span.status == "error":
|
|
23
|
+
span.set_attribute("alert.triggered", True)
|
|
24
|
+
|
|
25
|
+
configure(span_processors=[EnrichProcessor()])
|
|
26
|
+
|
|
27
|
+
Processors receive the *live* :class:`~spanforge._span.Span` object.
|
|
28
|
+
Mutations made in ``on_start`` are visible to user code inside the ``with``
|
|
29
|
+
block. Mutations made in ``on_end`` appear in the exported payload.
|
|
30
|
+
|
|
31
|
+
Thread-safety
|
|
32
|
+
-------------
|
|
33
|
+
Processors are called from the thread that owns the span context manager, so
|
|
34
|
+
they run in the same thread/task as the user code. Processors MUST NOT
|
|
35
|
+
block the event loop; long-running work should be dispatched to a background
|
|
36
|
+
thread or asyncio task.
|
|
37
|
+
|
|
38
|
+
Error handling
|
|
39
|
+
--------------
|
|
40
|
+
Exceptions propagating from a processor are silently caught so that a buggy
|
|
41
|
+
processor never aborts user code. Errors are logged at ``WARNING`` level.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
import threading
|
|
48
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from spanforge._span import Span
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"NoopSpanProcessor",
|
|
55
|
+
"ProcessorChain",
|
|
56
|
+
"SpanProcessor",
|
|
57
|
+
"add_processor",
|
|
58
|
+
"clear_processors",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
_proc_logger = logging.getLogger("spanforge.processor")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Protocol
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@runtime_checkable
|
|
70
|
+
class SpanProcessor(Protocol):
|
|
71
|
+
"""Protocol implemented by all span processors.
|
|
72
|
+
|
|
73
|
+
Both methods are optional — a processor that only enriches on start can
|
|
74
|
+
omit ``on_end``, and vice-versa. The default no-op implementations
|
|
75
|
+
defined in this protocol mean partial implementations work correctly.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def on_start(self, span: "Span") -> None:
|
|
79
|
+
"""Called synchronously immediately after the span is created.
|
|
80
|
+
|
|
81
|
+
The span has been pushed onto the context stack and its start time
|
|
82
|
+
recorded. Attributes may be freely added or mutated here.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
span: The newly created :class:`~spanforge._span.Span` (mutable).
|
|
86
|
+
"""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
def on_end(self, span: "Span") -> None:
|
|
90
|
+
"""Called synchronously after the span is finalised but before export.
|
|
91
|
+
|
|
92
|
+
``span.end_ns``, ``span.duration_ms``, and ``span.status`` are all
|
|
93
|
+
set by the time this method runs. Attributes may still be mutated
|
|
94
|
+
and will appear in the exported :class:`~spanforge.namespaces.trace.SpanPayload`.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
span: The finalised :class:`~spanforge._span.Span` (still mutable).
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# No-op implementation (default)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class NoopSpanProcessor:
|
|
108
|
+
"""Span processor that does nothing. Used as the default."""
|
|
109
|
+
|
|
110
|
+
def on_start(self, span: "Span") -> None:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def on_end(self, span: "Span") -> None:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Processor chain
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ProcessorChain:
|
|
123
|
+
"""An ordered chain of :class:`SpanProcessor` implementations.
|
|
124
|
+
|
|
125
|
+
Processors are called in insertion order for ``on_start`` and in the
|
|
126
|
+
**same** order for ``on_end``. Errors are caught per-processor so a
|
|
127
|
+
bug in one processor does not prevent subsequent processors from running.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
processors: Initial list of processors.
|
|
131
|
+
|
|
132
|
+
Example::
|
|
133
|
+
|
|
134
|
+
chain = ProcessorChain([EnrichProcessor(), RedactProcessor()])
|
|
135
|
+
chain.on_start(span)
|
|
136
|
+
# ... later ...
|
|
137
|
+
chain.on_end(span)
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, processors: list[Any] | None = None) -> None:
|
|
141
|
+
self._processors: list[Any] = list(processors or [])
|
|
142
|
+
self._lock = threading.Lock()
|
|
143
|
+
|
|
144
|
+
def add(self, processor: Any) -> None: # noqa: ANN401
|
|
145
|
+
"""Append *processor* to the chain."""
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._processors.append(processor)
|
|
148
|
+
|
|
149
|
+
def remove(self, processor: Any) -> None: # noqa: ANN401
|
|
150
|
+
"""Remove *processor* from the chain (no-op if not present)."""
|
|
151
|
+
with self._lock:
|
|
152
|
+
try:
|
|
153
|
+
self._processors.remove(processor)
|
|
154
|
+
except ValueError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def clear(self) -> None:
|
|
158
|
+
"""Remove all processors from the chain."""
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._processors.clear()
|
|
161
|
+
|
|
162
|
+
def on_start(self, span: "Span") -> None:
|
|
163
|
+
"""Fire ``on_start`` on all processors in order."""
|
|
164
|
+
with self._lock:
|
|
165
|
+
procs = list(self._processors) # snapshot to avoid holding lock during callbacks
|
|
166
|
+
for proc in procs:
|
|
167
|
+
try:
|
|
168
|
+
proc.on_start(span)
|
|
169
|
+
except Exception as exc: # NOSONAR
|
|
170
|
+
_proc_logger.warning(
|
|
171
|
+
"SpanProcessor.on_start error in %r: %s", type(proc).__name__, exc
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def on_end(self, span: "Span") -> None:
|
|
175
|
+
"""Fire ``on_end`` on all processors in order."""
|
|
176
|
+
with self._lock:
|
|
177
|
+
procs = list(self._processors) # snapshot to avoid holding lock during callbacks
|
|
178
|
+
for proc in procs:
|
|
179
|
+
try:
|
|
180
|
+
proc.on_end(span)
|
|
181
|
+
except Exception as exc: # NOSONAR
|
|
182
|
+
_proc_logger.warning(
|
|
183
|
+
"SpanProcessor.on_end error in %r: %s", type(proc).__name__, exc
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def __len__(self) -> int:
|
|
187
|
+
with self._lock:
|
|
188
|
+
return len(self._processors)
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
with self._lock:
|
|
192
|
+
names = [type(p).__name__ for p in self._processors]
|
|
193
|
+
return f"ProcessorChain({names!r})"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Module-level helpers — called from _span.py
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_on_start(span: "Span") -> None:
|
|
202
|
+
"""Fire ``on_start`` on all processors registered in the active config."""
|
|
203
|
+
try:
|
|
204
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
205
|
+
processors = get_config().span_processors
|
|
206
|
+
except Exception: # NOSONAR
|
|
207
|
+
return
|
|
208
|
+
for proc in processors:
|
|
209
|
+
try:
|
|
210
|
+
proc.on_start(span)
|
|
211
|
+
except Exception as exc: # NOSONAR
|
|
212
|
+
_proc_logger.warning(
|
|
213
|
+
"SpanProcessor.on_start error in %r: %s", type(proc).__name__, exc
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _run_on_end(span: "Span") -> None:
|
|
218
|
+
"""Fire ``on_end`` on all processors registered in the active config."""
|
|
219
|
+
try:
|
|
220
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
221
|
+
processors = get_config().span_processors
|
|
222
|
+
except Exception: # NOSONAR
|
|
223
|
+
return
|
|
224
|
+
for proc in processors:
|
|
225
|
+
try:
|
|
226
|
+
proc.on_end(span)
|
|
227
|
+
except Exception as exc: # NOSONAR
|
|
228
|
+
_proc_logger.warning(
|
|
229
|
+
"SpanProcessor.on_end error in %r: %s", type(proc).__name__, exc
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def add_processor(processor: Any) -> None: # noqa: ANN401
|
|
234
|
+
"""Append *processor* to the global span processor list in the active config.
|
|
235
|
+
|
|
236
|
+
Convenience wrapper around ``configure(span_processors=[...])``.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
processor: Any object implementing :class:`SpanProcessor` protocol.
|
|
240
|
+
|
|
241
|
+
Example::
|
|
242
|
+
|
|
243
|
+
from spanforge.processor import add_processor, SpanProcessor
|
|
244
|
+
|
|
245
|
+
class Enricher(SpanProcessor):
|
|
246
|
+
def on_start(self, span): span.set_attribute("region", "eu-west-1")
|
|
247
|
+
def on_end(self, span): pass
|
|
248
|
+
|
|
249
|
+
add_processor(Enricher())
|
|
250
|
+
"""
|
|
251
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
252
|
+
get_config().span_processors.append(processor)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def clear_processors() -> None:
|
|
256
|
+
"""Remove all span processors from the active config."""
|
|
257
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
258
|
+
get_config().span_processors.clear()
|