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
@@ -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()