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,291 @@
1
+ """OpenTelemetry SDK bridge for spanforge.
2
+
3
+ When the ``opentelemetry-sdk`` package is installed (``pip install
4
+ "spanforge[otel]"``) this module provides a first-class integration
5
+ that converts :class:`~spanforge.event.Event` objects into **real**
6
+ OpenTelemetry spans via the OTel Python SDK — rather than serialising the OTLP
7
+ wire format by hand.
8
+
9
+ This means:
10
+ * Spans flow through any configured ``TracerProvider`` (Jaeger, Zipkin, OTLP,
11
+ console, etc.) without a dedicated ``OTLPExporter`` endpoint.
12
+ * ``gen_ai.*`` semantic convention attributes are applied automatically.
13
+ * W3C ``traceparent`` / ``tracestate`` context propagation is hooked in via the
14
+ OTel SDK's standard :mod:`opentelemetry.propagate` interface.
15
+ * The bridge implements the :class:`~spanforge.stream.Exporter`
16
+ protocol so it is a drop-in replacement for
17
+ :class:`~spanforge.export.otlp.OTLPExporter` in any
18
+ :class:`~spanforge.stream.EventStream` pipeline.
19
+
20
+ Usage
21
+ -----
22
+ ::
23
+
24
+ from opentelemetry import trace
25
+ from opentelemetry.sdk.trace import TracerProvider
26
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor
27
+
28
+ # Configure the OTel SDK as usual.
29
+ provider = TracerProvider()
30
+ provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
31
+ trace.set_tracer_provider(provider)
32
+
33
+ # Create the bridge and use it like any other exporter.
34
+ from spanforge.export.otel_bridge import OTelBridgeExporter
35
+
36
+ bridge = OTelBridgeExporter()
37
+ await bridge.export(event)
38
+
39
+ Requirements
40
+ ------------
41
+ ``pip install "spanforge[otel]"`` — installs ``opentelemetry-sdk>=1.24``.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ from spanforge.export.otlp import _gen_ai_attributes
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Sequence
52
+
53
+ from spanforge.event import Event
54
+
55
+ __all__ = ["OTelBridgeExporter"]
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Lazy OTel SDK import guard
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _require_otel() -> Any: # noqa: ANN401
63
+ """Import the OTel SDK or raise a clear ImportError."""
64
+ try:
65
+ import opentelemetry # noqa: PLC0415
66
+ except ImportError as exc:
67
+ raise ImportError(
68
+ "opentelemetry-sdk is required for OTelBridgeExporter. "
69
+ "Install it: pip install \"spanforge[otel]\""
70
+ ) from exc
71
+ else:
72
+ return opentelemetry
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # SpanKind constants (mirrored to avoid importing SDK at module load time)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ _SPAN_KIND_INTERNAL = 0
80
+ _SPAN_KIND_CLIENT = 3
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # OTelBridgeExporter
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ class OTelBridgeExporter:
88
+ """Exporter that emits events as real OpenTelemetry SDK spans.
89
+
90
+ Converts each :class:`~spanforge.event.Event` into a completed
91
+ OTel span using the globally-configured ``TracerProvider``. All ``gen_ai.*``
92
+ semantic convention attributes are applied alongside the ``llm.*`` namespace
93
+ attributes so the events are visible in both OTel-native tooling (Grafana,
94
+ Honeycomb, Jaeger) and custom ``llm.*``-aware consumers.
95
+
96
+ Implements the :class:`~spanforge.stream.Exporter` protocol —
97
+ can be used anywhere an ``OTLPExporter`` is accepted.
98
+
99
+ Args:
100
+ tracer_name: Instrumentation scope name embedded in every span.
101
+ Defaults to ``"spanforge"``.
102
+ tracer_version: Instrumentation scope version. Defaults to ``"1.0"``.
103
+
104
+ Example::
105
+
106
+ bridge = OTelBridgeExporter()
107
+ await bridge.export(event)
108
+
109
+ # Or use in an EventStream pipeline:
110
+ await stream.drain(bridge)
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ tracer_name: str = "spanforge",
116
+ tracer_version: str = "1.0",
117
+ ) -> None:
118
+ _require_otel()
119
+ self._tracer_name = tracer_name
120
+ self._tracer_version = tracer_version
121
+
122
+ # ------------------------------------------------------------------
123
+ # Internal helpers
124
+ # ------------------------------------------------------------------
125
+
126
+ def _get_tracer(self) -> Any: # noqa: ANN401
127
+ from opentelemetry import trace # noqa: PLC0415
128
+ return trace.get_tracer(self._tracer_name, self._tracer_version)
129
+
130
+ @staticmethod
131
+ def _apply_identity_fields(event: "Event", attrs: dict[str, Any]) -> None:
132
+ """Add optional identity fields to attrs if present on the event."""
133
+ if event.org_id is not None:
134
+ attrs["llm.org_id"] = event.org_id
135
+ if event.team_id is not None:
136
+ attrs["llm.team_id"] = event.team_id
137
+ if event.actor_id is not None:
138
+ attrs["llm.actor_id"] = event.actor_id
139
+ if event.session_id is not None:
140
+ attrs["llm.session_id"] = event.session_id
141
+ if event.tags is not None:
142
+ for tag_key, tag_val in event.tags.items():
143
+ attrs[f"llm.tag.{tag_key}"] = tag_val
144
+ if event.checksum is not None:
145
+ attrs["llm.checksum"] = event.checksum
146
+
147
+ @staticmethod
148
+ def _apply_payload_fields(event: "Event", attrs: dict[str, Any]) -> None:
149
+ """Flatten event payload (one level deep) into attrs."""
150
+ for k, v in event.payload.items():
151
+ if isinstance(v, (str, int, float, bool)):
152
+ attrs[f"llm.payload.{k}"] = v
153
+ elif isinstance(v, dict):
154
+ for sub_k, sub_v in v.items():
155
+ if isinstance(sub_v, (str, int, float, bool)):
156
+ attrs[f"llm.payload.{k}.{sub_k}"] = sub_v
157
+
158
+ @staticmethod
159
+ def _apply_gen_ai_fields(event: "Event", attrs: dict[str, Any]) -> None:
160
+ """Extract gen_ai.* semantic conventions from OTLP _kv dicts."""
161
+ for kv in _gen_ai_attributes(event):
162
+ key = kv["key"]
163
+ val_wrapper = kv["value"]
164
+ for type_key in ("stringValue", "intValue", "doubleValue", "boolValue"):
165
+ if type_key in val_wrapper:
166
+ raw = val_wrapper[type_key]
167
+ attrs[key] = int(raw) if type_key == "intValue" else raw
168
+ break
169
+
170
+ @staticmethod
171
+ def _build_otel_attributes(event: "Event") -> dict[str, Any]: # noqa: PLR0912
172
+ """Build a flat attribute dict suitable for the OTel SDK ``span.set_attributes()``."""
173
+ attrs: dict[str, Any] = {
174
+ "llm.schema_version": event.schema_version,
175
+ "llm.event_id": event.event_id,
176
+ "llm.event_type": event.event_type,
177
+ "llm.source": event.source,
178
+ }
179
+ OTelBridgeExporter._apply_identity_fields(event, attrs)
180
+ OTelBridgeExporter._apply_payload_fields(event, attrs)
181
+ OTelBridgeExporter._apply_gen_ai_fields(event, attrs)
182
+ return attrs
183
+
184
+ @staticmethod
185
+ def _resolve_span_context(event: Event) -> Any | None: # noqa: ANN401
186
+ """Build an OTel ``SpanContext`` from the event's trace/parent fields."""
187
+ if event.trace_id is None:
188
+ return None
189
+ try:
190
+ from opentelemetry.trace import ( # noqa: PLC0415
191
+ NonRecordingSpan,
192
+ SpanContext,
193
+ TraceFlags,
194
+ )
195
+ except ImportError:
196
+ return None
197
+
198
+ try:
199
+ trace_id_int = int(event.trace_id, 16)
200
+ parent_span_id = event.parent_span_id or event.span_id
201
+ if not parent_span_id:
202
+ return None
203
+ span_id_int = int(parent_span_id, 16)
204
+ except (ValueError, TypeError):
205
+ return None
206
+
207
+ return NonRecordingSpan(
208
+ SpanContext(
209
+ trace_id=trace_id_int,
210
+ span_id=span_id_int,
211
+ is_remote=True,
212
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
213
+ )
214
+ )
215
+
216
+ # ------------------------------------------------------------------
217
+ # Single-event export
218
+ # ------------------------------------------------------------------
219
+
220
+ async def export(self, event: Event) -> None: # NOSONAR
221
+ """Export a single event as a completed OTel span.
222
+
223
+ The span is started and immediately ended using the event's
224
+ ``timestamp`` and ``duration_ms`` (if available) to reconstruct
225
+ the correct wall-clock times.
226
+
227
+ Args:
228
+ event: The event to emit as an OTel span.
229
+ """
230
+ from opentelemetry import context as otel_context # noqa: PLC0415
231
+ from opentelemetry import trace # noqa: PLC0415
232
+ from opentelemetry.trace import SpanKind, use_span # noqa: PLC0415
233
+
234
+ tracer = self._get_tracer()
235
+ attributes = self._build_otel_attributes(event)
236
+
237
+ # Attach the parent span context if the event carries trace linkage.
238
+ parent_span = self._resolve_span_context(event)
239
+ ctx = otel_context.get_current()
240
+ if parent_span is not None:
241
+ ctx = trace.set_span_in_context(parent_span, ctx)
242
+
243
+ # Determine SpanKind: LLM calls are CLIENT; internal ops are INTERNAL.
244
+ span_kind = SpanKind.CLIENT
245
+
246
+ span = tracer.start_span(
247
+ name=event.event_type,
248
+ context=ctx,
249
+ kind=span_kind,
250
+ attributes=attributes,
251
+ )
252
+
253
+ # Record error if the payload indicates failure.
254
+ status_val = event.payload.get("status", "ok")
255
+ error_msg = event.payload.get("error")
256
+
257
+ with use_span(span, record_exception=False, end_on_exit=False):
258
+ if status_val in ("error", "timeout"):
259
+ from opentelemetry.trace import StatusCode # noqa: PLC0415
260
+ message = error_msg or ("Operation timed out" if status_val == "timeout" else "")
261
+ span.set_status(StatusCode.ERROR, message)
262
+ else:
263
+ from opentelemetry.trace import StatusCode # noqa: PLC0415
264
+ span.set_status(StatusCode.OK)
265
+
266
+ span.end()
267
+
268
+ # ------------------------------------------------------------------
269
+ # Batch export (Exporter protocol)
270
+ # ------------------------------------------------------------------
271
+
272
+ async def export_batch(self, events: Sequence[Event]) -> None:
273
+ """Export a sequence of events as OTel spans.
274
+
275
+ Implements the :class:`~spanforge.stream.Exporter` protocol.
276
+
277
+ Args:
278
+ events: Sequence of events to export.
279
+ """
280
+ for event in events:
281
+ await self.export(event)
282
+
283
+ # ------------------------------------------------------------------
284
+ # Repr
285
+ # ------------------------------------------------------------------
286
+
287
+ def __repr__(self) -> str:
288
+ return (
289
+ f"OTelBridgeExporter(tracer_name={self._tracer_name!r}, "
290
+ f"tracer_version={self._tracer_version!r})"
291
+ )