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,817 @@
1
+ """OTLP-compatible JSON exporter for spanforge events.
2
+
3
+ Produces OTLP/JSON payloads (spans *or* log records) that can be forwarded to
4
+ any OTLP collector (Datadog, Grafana Tempo, Honeycomb, Elastic, Splunk, …).
5
+
6
+ **No opentelemetry-sdk dependency** — this module builds the OTLP wire format
7
+ from the stdlib only. If you already have the OTel SDK installed you can pipe
8
+ the output through the SDK's exporters as a dict; the schema is 1-to-1.
9
+
10
+ Format selection
11
+ ----------------
12
+ * Event **with** ``trace_id`` → OTLP *span* (``resourceSpans``).
13
+ * Event **without** ``trace_id`` → OTLP *log record* (``resourceLogs``).
14
+
15
+ Performance
16
+ -----------
17
+ Serialisation of 500 events is well under 200 ms (target: < 200 ms) because
18
+ every field mapping is a pure Python dict operation with no I/O on the hot path.
19
+ Network I/O is isolated in :meth:`OTLPExporter._send` and runs in a thread-pool
20
+ executor so the event loop is never blocked.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import concurrent.futures
27
+ import contextvars
28
+ import hashlib
29
+ import ipaddress
30
+ import json
31
+ import socket
32
+ import urllib.error
33
+ import urllib.parse
34
+ import urllib.request
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime, timezone
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ from spanforge.exceptions import ExportError
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Sequence
43
+
44
+ from spanforge.event import Event
45
+
46
+ __all__ = ["OTLPExporter", "ResourceAttributes", "extract_trace_context", "make_traceparent"]
47
+
48
+ # Scope name embedded in every OTLP payload (instrumentation scope).
49
+ _SCOPE_NAME = "spanforge"
50
+
51
+ # Hex-string lengths for W3C TraceContext IDs.
52
+ _TRACE_ID_HEX_LEN = 32
53
+ _SPAN_ID_HEX_LEN = 16
54
+
55
+ _FINISH_REASONS_KEY = "gen_ai.response.finish_reasons"
56
+ _TRACEPARENT_PARTS_COUNT = 4
57
+
58
+
59
+ def _is_private_ip_literal(host: str) -> bool:
60
+ """Return ``True`` if *host* is a private/loopback/link-local **literal** IP.
61
+
62
+ .. warning::
63
+ **SSRF limitation** — DNS hostnames are **not** resolved by this check.
64
+ A hostname such as ``"localhost"`` or ``"internal.corp"`` is *not*
65
+ blocked here. Only literal IPv4/IPv6 addresses are evaluated.
66
+ Use ``allow_private_endpoints=True`` in non-production environments when
67
+ targeting private endpoints by name.
68
+ """
69
+ try:
70
+ addr = ipaddress.ip_address(host)
71
+ except ValueError:
72
+ return False
73
+ return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast
74
+
75
+
76
+ def _validate_http_url(
77
+ url: str,
78
+ param_name: str = "url",
79
+ *,
80
+ allow_private_addresses: bool = False,
81
+ ) -> None:
82
+ """Raise *ValueError* if *url* is not a valid ``http://`` or ``https://`` URL."""
83
+ parsed = urllib.parse.urlparse(url)
84
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
85
+ raise ValueError(
86
+ f"{param_name} must be a valid http:// or https:// URL; got {url!r}"
87
+ )
88
+ if not allow_private_addresses:
89
+ host = parsed.hostname or ""
90
+ if _is_private_ip_literal(host):
91
+ raise ValueError(
92
+ f"{param_name} resolves to a private/loopback/link-local IP address "
93
+ f"({host!r}). Set allow_private_addresses=True to permit this."
94
+ )
95
+ # DNS-based SSRF check — best-effort; DNS failure is non-fatal.
96
+ if host and not _is_private_ip_literal(host):
97
+ try:
98
+ resolved = socket.gethostbyname(host)
99
+ addr = ipaddress.ip_address(resolved)
100
+ if addr.is_private or addr.is_loopback or addr.is_link_local:
101
+ raise ValueError(
102
+ f"{param_name} hostname {host!r} resolves to a private/loopback/"
103
+ f"link-local address ({resolved}). "
104
+ "Set allow_private_addresses=True to permit this."
105
+ )
106
+ except OSError: # DNS failure — allow through
107
+ pass
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Resource attributes
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class ResourceAttributes:
117
+ """OTel resource attributes attached to every exported payload.
118
+
119
+ Attributes:
120
+ service_name: Value for the ``service.name`` resource attr.
121
+ deployment_environment: Value for ``deployment.environment``.
122
+ extra: Additional arbitrary resource attributes.
123
+
124
+ Example::
125
+
126
+ res = ResourceAttributes(
127
+ service_name="my-service",
128
+ deployment_environment="staging",
129
+ extra={"k8s.namespace": "default"},
130
+ )
131
+ """
132
+
133
+ service_name: str
134
+ deployment_environment: str = "production"
135
+ extra: dict[str, str] = field(default_factory=dict)
136
+
137
+ def to_otlp(self) -> list[dict[str, Any]]:
138
+ """Return a list of OTLP ``KeyValue`` dicts for the resource."""
139
+ attrs: list[dict[str, Any]] = [
140
+ _kv("service.name", self.service_name),
141
+ # deployment.environment.name supersedes deployment.environment (semconv 1.21+)
142
+ _kv("deployment.environment.name", self.deployment_environment),
143
+ ]
144
+ for k, v in self.extra.items():
145
+ attrs.append(_kv(k, v))
146
+ return attrs
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # OTLP wire-format helpers
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def _kv(key: str, value: Any) -> dict[str, Any]: # noqa: ANN401
155
+ """Build an OTLP ``{key, value}`` attribute dict."""
156
+ return {"key": key, "value": _otlp_value(value)}
157
+
158
+
159
+ def _otlp_value(v: Any) -> dict[str, Any]: # noqa: ANN401
160
+ """Wrap a Python scalar in the appropriate OTLP ``AnyValue`` dict."""
161
+ if isinstance(v, bool):
162
+ return {"boolValue": v}
163
+ if isinstance(v, int):
164
+ # OTLP int64 is encoded as a JSON string to preserve precision.
165
+ return {"intValue": str(v)}
166
+ if isinstance(v, float):
167
+ return {"doubleValue": v}
168
+ return {"stringValue": str(v)}
169
+
170
+
171
+ def _ts_to_unix_nano(ts: str) -> int:
172
+ """Convert an ISO-8601 UTC timestamp string to nanoseconds since epoch.
173
+
174
+ Supports both ``Z`` and ``+00:00`` suffixes. Microsecond precision is
175
+ preserved; fractional nanoseconds are truncated.
176
+
177
+ Args:
178
+ ts: ISO-8601 UTC string, e.g. ``"2024-01-15T12:34:56.789012Z"``.
179
+
180
+ Returns:
181
+ Integer nanoseconds since the Unix epoch.
182
+ """
183
+ normalised = ts.replace("Z", "+00:00")
184
+ dt = datetime.fromisoformat(normalised)
185
+ if dt.tzinfo is None:
186
+ dt = dt.replace(tzinfo=timezone.utc)
187
+ epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
188
+ delta = dt - epoch
189
+ # total_seconds() gives float with microsecond resolution; scale to ns.
190
+ return int(delta.total_seconds() * 1_000_000_000)
191
+
192
+
193
+ def _derive_span_id(event_id: str) -> str:
194
+ """Derive a valid 16-hex-char span ID from a ULID event ID.
195
+
196
+ Used as a fallback when the event carries no explicit ``span_id``.
197
+ The derivation is deterministic so the same event always maps to the
198
+ same synthetic span ID.
199
+
200
+ Args:
201
+ event_id: A 26-character ULID string.
202
+
203
+ Returns:
204
+ 16-character lower-case hex string.
205
+ """
206
+ return hashlib.sha256(event_id.encode("utf-8")).hexdigest()[:16]
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # OpenTelemetry semantic convention helpers
211
+ # ---------------------------------------------------------------------------
212
+
213
+ # OTLP StatusCode integers (google.rpc.Status / OTLP spec).
214
+ _STATUS_CODE_OK = 1
215
+ _STATUS_CODE_ERROR = 2
216
+
217
+
218
+ def _gen_ai_model_attrs(model: dict[str, Any], attrs: list[dict[str, Any]]) -> None:
219
+ """Append gen_ai.system / gen_ai.request.model attrs from model dict."""
220
+ provider = model.get("provider")
221
+ if provider:
222
+ attrs.append(_kv("gen_ai.system", str(provider)))
223
+ name = model.get("name")
224
+ if name:
225
+ attrs.append(_kv("gen_ai.request.model", str(name)))
226
+ version = model.get("version")
227
+ if version:
228
+ attrs.append(_kv("gen_ai.request.model_version", str(version)))
229
+
230
+
231
+ def _gen_ai_token_attrs(token_usage: dict[str, Any], attrs: list[dict[str, Any]]) -> None:
232
+ """Append gen_ai usage token attrs from token_usage dict."""
233
+ prompt_tokens = token_usage.get("prompt_tokens")
234
+ if prompt_tokens is not None:
235
+ attrs.append(_kv("gen_ai.usage.input_tokens", int(prompt_tokens)))
236
+ completion_tokens = token_usage.get("completion_tokens")
237
+ if completion_tokens is not None:
238
+ attrs.append(_kv("gen_ai.usage.output_tokens", int(completion_tokens)))
239
+
240
+
241
+ def _gen_ai_attributes(event: Event) -> list[dict[str, Any]]:
242
+ """Build ``gen_ai.*`` OpenTelemetry GenAI semantic convention attributes.
243
+
244
+ Maps model info, token usage, and operation metadata from the event payload
245
+ to the standard OTel GenAI semconv namespace (semconv 1.27+).
246
+
247
+ See: https://opentelemetry.io/docs/specs/semconv/gen-ai/
248
+
249
+ Args:
250
+ event: The event whose payload is inspected.
251
+
252
+ Returns:
253
+ A (possibly empty) list of OTLP ``KeyValue`` dicts.
254
+ """
255
+ attrs: list[dict[str, Any]] = []
256
+ payload = event.payload
257
+
258
+ span_name = payload.get("span_name")
259
+ if span_name:
260
+ attrs.append(_kv("gen_ai.operation.name", str(span_name)))
261
+
262
+ model = payload.get("model")
263
+ if isinstance(model, dict):
264
+ _gen_ai_model_attrs(model, attrs)
265
+
266
+ token_usage = payload.get("token_usage")
267
+ if isinstance(token_usage, dict):
268
+ _gen_ai_token_attrs(token_usage, attrs)
269
+
270
+ status = payload.get("status")
271
+ error = payload.get("error")
272
+ if status == "error" or error:
273
+ attrs.append(_kv(_FINISH_REASONS_KEY, "error"))
274
+ elif status == "timeout":
275
+ attrs.append(_kv(_FINISH_REASONS_KEY, "timeout"))
276
+ elif status == "ok":
277
+ attrs.append(_kv(_FINISH_REASONS_KEY, "stop"))
278
+
279
+ return attrs
280
+
281
+
282
+ def _map_span_status(event: Event) -> dict[str, Any]:
283
+ """Map event payload ``status`` to an OTLP ``SpanStatus`` dict.
284
+
285
+ ``"error"`` and ``"timeout"`` outcomes yield ``STATUS_CODE_ERROR`` (2).
286
+ Everything else yields ``STATUS_CODE_OK`` (1). An error message is
287
+ included when the payload carries an ``error`` field.
288
+
289
+ Args:
290
+ event: The event carrying a ``status`` and optional ``error`` field.
291
+
292
+ Returns:
293
+ An OTLP ``{code, [message]}`` status dict.
294
+ """
295
+ payload = event.payload
296
+ status = payload.get("status", "ok")
297
+ if status in ("error", "timeout"):
298
+ result: dict[str, Any] = {"code": _STATUS_CODE_ERROR}
299
+ error_msg = payload.get("error")
300
+ if error_msg:
301
+ result["message"] = str(error_msg)
302
+ elif status == "timeout":
303
+ result["message"] = "Operation timed out"
304
+ return result
305
+ return {"code": _STATUS_CODE_OK}
306
+
307
+
308
+ def _compute_end_nano(start_nano: int, event: Event) -> int:
309
+ """Compute ``endTimeUnixNano`` from start time plus payload ``duration_ms``.
310
+
311
+ If ``duration_ms`` is absent or cannot be parsed, falls back to
312
+ ``start_nano`` (zero-duration span — should only happen for events without
313
+ timing information such as ``span.started`` events).
314
+
315
+ Args:
316
+ start_nano: Span start time in nanoseconds since Unix epoch.
317
+ event: Event that may carry a ``duration_ms`` payload field.
318
+
319
+ Returns:
320
+ End time in nanoseconds since Unix epoch.
321
+ """
322
+ duration_ms = event.payload.get("duration_ms")
323
+ if duration_ms is not None:
324
+ try:
325
+ return start_nano + int(float(duration_ms) * 1_000_000)
326
+ except (TypeError, ValueError):
327
+ pass
328
+ return start_nano
329
+
330
+
331
+ def _flatten_payload(
332
+ payload: dict[str, Any],
333
+ prefix: str = "llm.payload",
334
+ ) -> list[dict[str, Any]]:
335
+ """Recursively flatten a nested dict to OTLP attribute key-value pairs.
336
+
337
+ Nested keys are joined with ``"."`` (dot notation).
338
+
339
+ Args:
340
+ payload: The dict to flatten.
341
+ prefix: Key prefix for all emitted attributes.
342
+
343
+ Returns:
344
+ A list of OTLP ``KeyValue`` dicts.
345
+ """
346
+ result: list[dict[str, Any]] = []
347
+ for k, v in payload.items():
348
+ full_key = f"{prefix}.{k}"
349
+ if isinstance(v, dict):
350
+ result.extend(_flatten_payload(v, full_key))
351
+ else:
352
+ result.append(_kv(full_key, v))
353
+ return result
354
+
355
+
356
+ def _event_to_attributes(event: Event) -> list[dict[str, Any]]:
357
+ """Build the full OTLP attribute list for an :class:`~spanforge.event.Event`.
358
+
359
+ Envelope metadata, identity, tags, integrity fields, and payload are all
360
+ mapped to well-known ``llm.*`` namespace attributes.
361
+ """
362
+ attrs: list[dict[str, Any]] = [
363
+ _kv("llm.schema_version", event.schema_version),
364
+ _kv("llm.event_id", event.event_id),
365
+ _kv("llm.event_type", event.event_type),
366
+ _kv("llm.source", event.source),
367
+ ]
368
+
369
+ # Identity fields
370
+ if event.org_id is not None:
371
+ attrs.append(_kv("llm.org_id", event.org_id))
372
+ if event.team_id is not None:
373
+ attrs.append(_kv("llm.team_id", event.team_id))
374
+ if event.actor_id is not None:
375
+ attrs.append(_kv("llm.actor_id", event.actor_id))
376
+ if event.session_id is not None:
377
+ attrs.append(_kv("llm.session_id", event.session_id))
378
+
379
+ # Tags
380
+ if event.tags is not None:
381
+ for tag_key, tag_val in event.tags.items():
382
+ attrs.append(_kv(f"llm.tag.{tag_key}", tag_val))
383
+
384
+ # Integrity / audit chain fields
385
+ if event.checksum is not None:
386
+ attrs.append(_kv("llm.checksum", event.checksum))
387
+ if event.signature is not None:
388
+ attrs.append(_kv("llm.signature", event.signature))
389
+ if event.prev_id is not None:
390
+ attrs.append(_kv("llm.prev_id", event.prev_id))
391
+
392
+ # Flatten payload fields into span/log attributes.
393
+ attrs.extend(_flatten_payload(event.payload))
394
+
395
+ # OpenTelemetry GenAI semantic conventions (semconv 1.27+)
396
+ # These sit alongside the llm.* namespace so both ecosystems work.
397
+ attrs.extend(_gen_ai_attributes(event))
398
+
399
+ return attrs
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # OTLPExporter
404
+ # ---------------------------------------------------------------------------
405
+
406
+
407
+ class OTLPExporter:
408
+ """Async exporter that serialises spanforge events to the OTLP/JSON format.
409
+
410
+ Events that carry a ``trace_id`` are emitted as **OTLP spans**
411
+ (``resourceSpans``). Events without a ``trace_id`` are emitted as **OTLP
412
+ log records** (``resourceLogs``).
413
+
414
+ HTTP transport uses :func:`urllib.request.urlopen` inside a thread-pool
415
+ executor so the async event loop is never blocked.
416
+
417
+ Args:
418
+ endpoint: Full OTLP HTTP URL, e.g.
419
+ ``"http://otel-collector:4318/v1/traces"``.
420
+ headers: Optional extra HTTP request headers (e.g. API keys).
421
+ resource_attrs: :class:`ResourceAttributes` attached to every payload.
422
+ timeout: HTTP request timeout in seconds (default 5.0).
423
+ batch_size: Maximum events per :meth:`export_batch` call (default
424
+ 500). Larger batches are split automatically.
425
+
426
+ Example::
427
+
428
+ exporter = OTLPExporter(
429
+ endpoint="http://localhost:4318/v1/traces", # NOSONAR
430
+ resource_attrs=ResourceAttributes(service_name="llm-trace"),
431
+ )
432
+ await exporter.export(event)
433
+ """
434
+
435
+ def __init__( # noqa: PLR0913
436
+ self,
437
+ endpoint: str,
438
+ *,
439
+ headers: dict[str, str] | None = None,
440
+ resource_attrs: ResourceAttributes | None = None,
441
+ timeout: float = 5.0,
442
+ batch_size: int = 500,
443
+ allow_private_addresses: bool = False,
444
+ max_workers: int = 4,
445
+ ) -> None:
446
+ if not endpoint:
447
+ raise ValueError("endpoint must be a non-empty string")
448
+ _validate_http_url(endpoint, "endpoint", allow_private_addresses=allow_private_addresses)
449
+ if timeout <= 0:
450
+ raise ValueError("timeout must be positive")
451
+ if batch_size < 1:
452
+ raise ValueError("batch_size must be >= 1")
453
+ if max_workers < 1:
454
+ raise ValueError("max_workers must be >= 1")
455
+ self._endpoint = endpoint
456
+ self._headers: dict[str, str] = dict(headers) if headers else {}
457
+ self._resource_attrs: ResourceAttributes = resource_attrs or ResourceAttributes(
458
+ service_name="spanforge"
459
+ )
460
+ self._timeout = timeout
461
+ self._batch_size = batch_size
462
+ self._executor = concurrent.futures.ThreadPoolExecutor(
463
+ max_workers=max_workers,
464
+ thread_name_prefix="spanforge-otlp",
465
+ )
466
+
467
+ # ------------------------------------------------------------------
468
+ # Sync mapping API (pure, no I/O — safe to call in hot loops)
469
+ # ------------------------------------------------------------------
470
+
471
+ def to_otlp_span(self, event: Event) -> dict[str, Any]:
472
+ """Map a single event to an OTLP span dict.
473
+
474
+ If the event has no ``span_id``, a deterministic synthetic ID is derived
475
+ from the ``event_id``. If the event has no ``trace_id``, a zero-filled
476
+ placeholder is used (``"00…0"``).
477
+
478
+ Args:
479
+ event: The :class:`~spanforge.event.Event` to map.
480
+
481
+ Returns:
482
+ An OTLP-compatible span dict.
483
+ """
484
+ ts_nano = _ts_to_unix_nano(event.timestamp)
485
+ end_nano = _compute_end_nano(ts_nano, event)
486
+ span_id = event.span_id or _derive_span_id(event.event_id)
487
+ trace_id = event.trace_id or ("0" * 32)
488
+
489
+ span: dict[str, Any] = {
490
+ "traceId": trace_id,
491
+ "spanId": span_id,
492
+ "name": event.event_type,
493
+ # SPAN_KIND_CLIENT (3) — LLM calls are outgoing client requests.
494
+ "kind": 3,
495
+ "startTimeUnixNano": str(ts_nano),
496
+ "endTimeUnixNano": str(end_nano),
497
+ "attributes": _event_to_attributes(event),
498
+ "status": _map_span_status(event),
499
+ # Bit 0 set = sampled (W3C TraceContext §7.1.2)
500
+ "traceFlags": 1,
501
+ }
502
+ if event.parent_span_id is not None:
503
+ span["parentSpanId"] = event.parent_span_id
504
+
505
+ return span
506
+
507
+ def to_otlp_log(self, event: Event) -> dict[str, Any]:
508
+ """Map a single event to an OTLP log record dict.
509
+
510
+ Args:
511
+ event: The :class:`~spanforge.event.Event` to map.
512
+
513
+ Returns:
514
+ An OTLP-compatible log record dict.
515
+ """
516
+ ts_nano = _ts_to_unix_nano(event.timestamp)
517
+
518
+ record: dict[str, Any] = {
519
+ "timeUnixNano": str(ts_nano),
520
+ "observedTimeUnixNano": str(ts_nano),
521
+ "severityNumber": 9, # SEVERITY_NUMBER_INFO
522
+ "severityText": "INFO",
523
+ "body": {"stringValue": event.event_type},
524
+ "attributes": _event_to_attributes(event),
525
+ }
526
+ # Include tracing context even for log records if present.
527
+ if event.trace_id is not None:
528
+ record["traceId"] = event.trace_id
529
+ if event.span_id is not None:
530
+ record["spanId"] = event.span_id
531
+
532
+ return record
533
+
534
+ # ------------------------------------------------------------------
535
+ # Async export API
536
+ # ------------------------------------------------------------------
537
+
538
+ async def export(self, event: Event) -> dict[str, Any]:
539
+ """Export a single event as an OTLP payload and HTTP POST it.
540
+
541
+ Span vs log selection is automatic: events with a ``trace_id`` become
542
+ spans; all others become log records.
543
+
544
+ Args:
545
+ event: The event to export.
546
+
547
+ Returns:
548
+ The OTLP span or log record dict that was sent.
549
+
550
+ Raises:
551
+ ExportError: If the HTTP request fails.
552
+ """
553
+ if event.trace_id is not None:
554
+ record = self.to_otlp_span(event)
555
+ payload = self._wrap_spans([record])
556
+ else:
557
+ record = self.to_otlp_log(event)
558
+ payload = self._wrap_logs([record])
559
+
560
+ await self._send(payload)
561
+ return record
562
+
563
+ async def export_batch(self, events: Sequence[Event]) -> list[dict[str, Any]]:
564
+ """Export a sequence of events, batching spans and logs separately.
565
+
566
+ Spans and log records are split into two HTTP requests so each request
567
+ targets the correct OTLP endpoint format.
568
+
569
+ Args:
570
+ events: Sequence of events (at most :attr:`batch_size` per call;
571
+ larger sequences should be chunked by the caller).
572
+
573
+ Returns:
574
+ List of OTLP record dicts (spans first, then log records, in
575
+ original insertion order within each group).
576
+
577
+ Raises:
578
+ ExportError: If any HTTP request fails.
579
+ """
580
+ spans: list[dict[str, Any]] = []
581
+ logs: list[dict[str, Any]] = []
582
+ # Preserve per-type insertion order for the returned list.
583
+ records: list[dict[str, Any]] = []
584
+
585
+ for event in events:
586
+ if event.trace_id is not None:
587
+ r = self.to_otlp_span(event)
588
+ spans.append(r)
589
+ else:
590
+ r = self.to_otlp_log(event)
591
+ logs.append(r)
592
+ records.append(r)
593
+
594
+ if spans:
595
+ for i in range(0, len(spans), self._batch_size):
596
+ await self._send(self._wrap_spans(spans[i : i + self._batch_size]))
597
+ if logs:
598
+ for i in range(0, len(logs), self._batch_size):
599
+ await self._send(self._wrap_logs(logs[i : i + self._batch_size]))
600
+
601
+ return records
602
+
603
+ # ------------------------------------------------------------------
604
+ # OTLP envelope helpers
605
+ # ------------------------------------------------------------------
606
+
607
+ def _wrap_spans(self, spans: list[dict[str, Any]]) -> dict[str, Any]:
608
+ """Wrap span records in a ``resourceSpans`` OTLP envelope."""
609
+ return {
610
+ "resourceSpans": [
611
+ {
612
+ "resource": {"attributes": self._resource_attrs.to_otlp()},
613
+ "scopeSpans": [
614
+ {
615
+ "scope": {"name": _SCOPE_NAME},
616
+ "spans": spans,
617
+ }
618
+ ],
619
+ }
620
+ ]
621
+ }
622
+
623
+ def _wrap_logs(self, logs: list[dict[str, Any]]) -> dict[str, Any]:
624
+ """Wrap log records in a ``resourceLogs`` OTLP envelope."""
625
+ return {
626
+ "resourceLogs": [
627
+ {
628
+ "resource": {"attributes": self._resource_attrs.to_otlp()},
629
+ "scopeLogs": [
630
+ {
631
+ "scope": {"name": _SCOPE_NAME},
632
+ "logRecords": logs,
633
+ }
634
+ ],
635
+ }
636
+ ]
637
+ }
638
+
639
+ # ------------------------------------------------------------------
640
+ # HTTP transport (executor-based, non-blocking)
641
+ # ------------------------------------------------------------------
642
+
643
+ async def _send(self, payload: dict[str, Any]) -> None:
644
+ """Serialise *payload* to JSON and POST it to :attr:`_endpoint`.
645
+
646
+ Runs in a thread-pool executor so the async event loop is not blocked
647
+ during network I/O.
648
+
649
+ Args:
650
+ payload: A fully-built OTLP envelope dict.
651
+
652
+ Raises:
653
+ ExportError: On HTTP 4xx/5xx or network errors.
654
+ EgressViolationError: If the endpoint is blocked by egress policy.
655
+ """
656
+ from spanforge.egress import check_egress # noqa: PLC0415
657
+
658
+ check_egress(self._endpoint, backend="otlp")
659
+ body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
660
+ request_headers = {"Content-Type": "application/json", **self._headers}
661
+ endpoint = self._endpoint
662
+ timeout = self._timeout
663
+
664
+ def _do_request() -> None:
665
+ req = urllib.request.Request( # noqa: S310 # NOSONAR
666
+ url=endpoint,
667
+ data=body,
668
+ headers=request_headers,
669
+ method="POST",
670
+ )
671
+ try:
672
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 # NOSONAR
673
+ resp.read()
674
+ except urllib.error.HTTPError as exc:
675
+ raise ExportError(
676
+ "otlp",
677
+ f"HTTP {exc.code}: {exc.reason}",
678
+ ) from exc
679
+ except OSError as exc:
680
+ raise ExportError("otlp", str(exc)) from exc
681
+
682
+ loop = asyncio.get_running_loop()
683
+ # Propagate contextvars into the executor thread so active span/tracer
684
+ # context is visible to any code running inside _do_request.
685
+ ctx = contextvars.copy_context()
686
+ await loop.run_in_executor(self._executor, ctx.run, _do_request)
687
+
688
+ # ------------------------------------------------------------------
689
+ # Repr
690
+ # ------------------------------------------------------------------
691
+
692
+ def __repr__(self) -> str:
693
+ # Scrub credentials from endpoint URL before display (H6).
694
+ # urlparse fields are scheme/netloc/path/params/query/fragment;
695
+ # username & password are derived properties, not fields. Rebuild
696
+ # the netloc without any embedded user-info component.
697
+ parsed = urllib.parse.urlparse(self._endpoint)
698
+ host = parsed.hostname or ""
699
+ port = f":{parsed.port}" if parsed.port else ""
700
+ safe = parsed._replace(netloc=f"{host}{port}")
701
+ return (
702
+ f"OTLPExporter(endpoint={urllib.parse.urlunparse(safe)!r}, "
703
+ f"batch_size={self._batch_size!r})"
704
+ )
705
+
706
+
707
+ # ---------------------------------------------------------------------------
708
+ # W3C TraceContext utilities (RFC 9429)
709
+ # ---------------------------------------------------------------------------
710
+
711
+
712
+ def make_traceparent(
713
+ trace_id: str,
714
+ span_id: str,
715
+ *,
716
+ sampled: bool = True,
717
+ ) -> str:
718
+ """Build a W3C ``traceparent`` header value.
719
+
720
+ Produces a version-``00`` ``traceparent`` string that can be injected into
721
+ outgoing HTTP requests to propagate distributed trace context.
722
+
723
+ Args:
724
+ trace_id: 32 lowercase hex characters (OTel trace ID).
725
+ span_id: 16 lowercase hex characters (current span ID).
726
+ sampled: Whether the trace is sampled. Sets the ``sampled`` flag
727
+ (W3C TraceContext §7.1.2). Defaults to ``True``.
728
+
729
+ Returns:
730
+ A ``traceparent`` header value, e.g.
731
+ ``"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"``.
732
+
733
+ Raises:
734
+ ValueError: If ``trace_id`` or ``span_id`` do not match the required
735
+ format.
736
+
737
+ Example::
738
+
739
+ headers["traceparent"] = make_traceparent(
740
+ event.trace_id, event.span_id
741
+ )
742
+ """
743
+ if len(trace_id) != _TRACE_ID_HEX_LEN or not all(c in "0123456789abcdef" for c in trace_id):
744
+ raise ValueError(
745
+ f"trace_id must be 32 lowercase hex characters; got {trace_id!r}"
746
+ )
747
+ if len(span_id) != _SPAN_ID_HEX_LEN or not all(c in "0123456789abcdef" for c in span_id):
748
+ raise ValueError(
749
+ f"span_id must be 16 lowercase hex characters; got {span_id!r}"
750
+ )
751
+ flags = "01" if sampled else "00"
752
+ return f"00-{trace_id}-{span_id}-{flags}"
753
+
754
+
755
+ def extract_trace_context( # noqa: PLR0911
756
+ headers: dict[str, str],
757
+ ) -> dict[str, Any] | None:
758
+ """Extract W3C TraceContext from a ``traceparent`` / ``tracestate`` header dict.
759
+
760
+ Parses the incoming ``traceparent`` header (case-insensitive key lookup)
761
+ and returns the extracted trace context. Returns ``None`` if the header
762
+ is absent or malformed.
763
+
764
+ Args:
765
+ headers: A dict of HTTP headers (keys are matched case-insensitively).
766
+
767
+ Returns:
768
+ A dict with keys ``trace_id``, ``span_id``, ``sampled`` (bool), and
769
+ optionally ``tracestate`` (str) if that header is present; or ``None``
770
+ if no valid ``traceparent`` was found.
771
+
772
+ Example::
773
+
774
+ ctx = extract_trace_context(request.headers)
775
+ if ctx:
776
+ event = Event(
777
+ event_type=...,
778
+ source=...,
779
+ payload=...,
780
+ trace_id=ctx["trace_id"],
781
+ parent_span_id=ctx["span_id"],
782
+ )
783
+ """
784
+ # Case-insensitive header lookup.
785
+ lower_headers = {k.lower(): v for k, v in headers.items()}
786
+ traceparent = lower_headers.get("traceparent")
787
+ if not traceparent:
788
+ return None
789
+
790
+ parts = traceparent.strip().split("-")
791
+ if len(parts) != _TRACEPARENT_PARTS_COUNT:
792
+ return None
793
+ version, trace_id, parent_span_id, trace_flags_hex = parts
794
+ # Only version 00 is supported (future versions may have more parts).
795
+ if version != "00":
796
+ return None
797
+ if len(trace_id) != _TRACE_ID_HEX_LEN or len(parent_span_id) != _SPAN_ID_HEX_LEN:
798
+ return None
799
+ if not all(c in "0123456789abcdef" for c in trace_id):
800
+ return None
801
+ if not all(c in "0123456789abcdef" for c in parent_span_id):
802
+ return None
803
+
804
+ try:
805
+ flags_int = int(trace_flags_hex, 16)
806
+ except ValueError:
807
+ return None
808
+
809
+ result: dict[str, Any] = {
810
+ "trace_id": trace_id,
811
+ "span_id": parent_span_id,
812
+ "sampled": bool(flags_int & 0x01),
813
+ }
814
+ tracestate = lower_headers.get("tracestate")
815
+ if tracestate:
816
+ result["tracestate"] = tracestate
817
+ return result