forgesight-otel 0.1.0__tar.gz

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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # venv / tooling
11
+ .venv/
12
+ venv/
13
+ .uv/
14
+ uv.lock
15
+
16
+ # test / type / lint caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ coverage.xml
23
+ htmlcov/
24
+
25
+ # secrets / local env (never commit)
26
+ .env
27
+ .env.*
28
+
29
+ # editor / OS
30
+ .DS_Store
31
+ .idea/
32
+ .vscode/
33
+
34
+ # local-only session working state (per the workspace pipeline)
35
+ .claude/state/
36
+
37
+ # local-only launch planning (not part of the published repo)
38
+ /launch/
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: forgesight-otel
3
+ Version: 0.1.0
4
+ Summary: ForgeSight OpenTelemetry exporter — maps records to OTLP spans via the GenAI semantic conventions.
5
+ Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
6
+ Project-URL: Repository, https://github.com/Scaffoldic/forgesight
7
+ Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
8
+ Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
9
+ Author: kjoshi
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai-agents,forgesight,genai,observability,opentelemetry,otlp
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: System :: Monitoring
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: forgesight-core
24
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
25
+ Requires-Dist: opentelemetry-sdk>=1.27
26
+ Provides-Extra: grpc
27
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.27; extra == 'grpc'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # forgesight-otel
31
+
32
+ The OpenTelemetry exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
33
+ Maps ForgeSight `Record`s onto OTLP spans using the OpenTelemetry **GenAI semantic
34
+ conventions** — so anything that ingests OTLP (Datadog, Honeycomb, Jaeger, Grafana
35
+ Tempo, SigNoz, New Relic, Arize Phoenix) works with no additional package.
36
+
37
+ ```bash
38
+ pip install forgesight-otel
39
+ ```
40
+
41
+ ```python
42
+ import forgesight
43
+ from forgesight_otel import OTelExporter
44
+
45
+ forgesight.configure(exporters=[OTelExporter(endpoint="http://otel-collector:4318")])
46
+ ```
47
+
48
+ Or enable by name via config (`exporters: [{name: otel, config: {...}}]`) — it
49
+ registers under the `forgesight.exporters` entry point.
50
+
51
+ - Provider discriminator: `gen_ai.provider.name` (legacy `gen_ai.system` opt-in).
52
+ - Cost: emitted as the extension attribute `forgesight.usage.cost_usd` (OTel defines
53
+ no cost attribute).
54
+ - Prompt/response content is **off by default** (`capture_content=True` to opt in).
55
+ - gRPC transport: `pip install forgesight-otel[grpc]` + `protocol="grpc"`.
56
+
57
+ ## License
58
+
59
+ Apache-2.0
@@ -0,0 +1,30 @@
1
+ # forgesight-otel
2
+
3
+ The OpenTelemetry exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
4
+ Maps ForgeSight `Record`s onto OTLP spans using the OpenTelemetry **GenAI semantic
5
+ conventions** — so anything that ingests OTLP (Datadog, Honeycomb, Jaeger, Grafana
6
+ Tempo, SigNoz, New Relic, Arize Phoenix) works with no additional package.
7
+
8
+ ```bash
9
+ pip install forgesight-otel
10
+ ```
11
+
12
+ ```python
13
+ import forgesight
14
+ from forgesight_otel import OTelExporter
15
+
16
+ forgesight.configure(exporters=[OTelExporter(endpoint="http://otel-collector:4318")])
17
+ ```
18
+
19
+ Or enable by name via config (`exporters: [{name: otel, config: {...}}]`) — it
20
+ registers under the `forgesight.exporters` entry point.
21
+
22
+ - Provider discriminator: `gen_ai.provider.name` (legacy `gen_ai.system` opt-in).
23
+ - Cost: emitted as the extension attribute `forgesight.usage.cost_usd` (OTel defines
24
+ no cost attribute).
25
+ - Prompt/response content is **off by default** (`capture_content=True` to opt in).
26
+ - gRPC transport: `pip install forgesight-otel[grpc]` + `protocol="grpc"`.
27
+
28
+ ## License
29
+
30
+ Apache-2.0
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "forgesight-otel"
3
+ version = "0.1.0"
4
+ description = "ForgeSight OpenTelemetry exporter — maps records to OTLP spans via the GenAI semantic conventions."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "kjoshi" }]
9
+ keywords = ["observability", "opentelemetry", "otlp", "ai-agents", "genai", "forgesight"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Information Technology",
14
+ "Topic :: System :: Monitoring",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ # Integration package: api/core + the OTel SDK + OTLP/http exporter. grpc is an extra.
23
+ dependencies = [
24
+ "forgesight-core",
25
+ "opentelemetry-sdk>=1.27",
26
+ "opentelemetry-exporter-otlp-proto-http>=1.27",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ grpc = ["opentelemetry-exporter-otlp-proto-grpc>=1.27"]
31
+
32
+ [project.entry-points."forgesight.exporters"]
33
+ otel = "forgesight_otel.exporter:OTelExporter"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/Scaffoldic/forgesight"
37
+ Repository = "https://github.com/Scaffoldic/forgesight"
38
+ Issues = "https://github.com/Scaffoldic/forgesight/issues"
39
+ Changelog = "https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/forgesight_otel"]
47
+
48
+ [tool.uv.sources]
49
+ forgesight-core = { workspace = true }
@@ -0,0 +1,19 @@
1
+ """ForgeSight OpenTelemetry exporter — OTLP spans via the GenAI semantic conventions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .exporter import OTelExporter
6
+ from .propagation import extract, inject
7
+ from .semconv import SEMCONV_COMMIT, SEMCONV_VERSION, SemConvMapper
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "SEMCONV_COMMIT",
13
+ "SEMCONV_VERSION",
14
+ "OTelExporter",
15
+ "SemConvMapper",
16
+ "__version__",
17
+ "extract",
18
+ "inject",
19
+ ]
@@ -0,0 +1,150 @@
1
+ """``OTelExporter`` — a ForgeSight ``TelemetryExporter`` that ships OTLP spans.
2
+
3
+ It runs on the export worker (feat-003), never the hot path. Each :class:`Record` is
4
+ turned into an OTel :class:`~opentelemetry.sdk.trace.ReadableSpan` (carrying ForgeSight's
5
+ own trace/span ids) and handed to an OTLP span exporter. ``export`` never raises (P6):
6
+ on any failure it returns ``ExportResult.FAILURE``.
7
+
8
+ For tests, inject a ``span_exporter`` (e.g. OTel's ``InMemorySpanExporter``); in
9
+ production the OTLP exporter is built lazily from ``endpoint``/``protocol``/``headers``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from urllib.parse import urlsplit, urlunsplit
16
+
17
+ from opentelemetry.sdk.resources import Resource
18
+ from opentelemetry.sdk.trace import ReadableSpan
19
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
20
+ from opentelemetry.sdk.util.instrumentation import InstrumentationScope
21
+ from opentelemetry.trace import SpanContext, TraceFlags
22
+ from opentelemetry.trace.status import Status, StatusCode
23
+
24
+ from forgesight_api import ExportResult, Record, RunStatus
25
+
26
+ from .semconv import FORGESIGHT_SEMCONV_VERSION, SEMCONV_VERSION, SemConvMapper
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ _DEFAULT_SERVICE_NAME = "forgesight-agent"
31
+ _SAMPLED = TraceFlags(TraceFlags.SAMPLED)
32
+ _OK_STATUSES = frozenset({RunStatus.OK, RunStatus.RUNNING})
33
+
34
+
35
+ class OTelExporter:
36
+ """Maps ForgeSight records → OTLP spans via the GenAI semantic conventions."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ endpoint: str | None = None,
42
+ protocol: str = "http/protobuf",
43
+ service_name: str = _DEFAULT_SERVICE_NAME,
44
+ capture_content: bool = False,
45
+ emit_legacy_system: bool = False,
46
+ headers: dict[str, str] | None = None,
47
+ resource_attributes: dict[str, str] | None = None,
48
+ span_exporter: SpanExporter | None = None,
49
+ ) -> None:
50
+ self._mapper = SemConvMapper()
51
+ self._capture_content = capture_content
52
+ self._emit_legacy_system = emit_legacy_system
53
+ res: dict[str, str] = {
54
+ "service.name": service_name,
55
+ FORGESIGHT_SEMCONV_VERSION: SEMCONV_VERSION,
56
+ }
57
+ if resource_attributes:
58
+ res.update(resource_attributes)
59
+ self._resource = Resource.create(res)
60
+ self._scope = InstrumentationScope("forgesight", __version__)
61
+ self._span_exporter = span_exporter or self._build_span_exporter(
62
+ endpoint, protocol, headers
63
+ )
64
+
65
+ # --- TelemetryExporter Protocol --------------------------------------
66
+ def export(self, records: Sequence[Record]) -> ExportResult:
67
+ try:
68
+ spans = [self._to_readable_span(r) for r in records]
69
+ result = self._span_exporter.export(spans)
70
+ except Exception: # defence in depth — export must never raise (P6)
71
+ return ExportResult.FAILURE
72
+ return ExportResult.SUCCESS if result is SpanExportResult.SUCCESS else ExportResult.FAILURE
73
+
74
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
75
+ return self._span_exporter.force_flush(timeout_millis)
76
+
77
+ def shutdown(self, timeout_millis: int = 30_000) -> None:
78
+ self._span_exporter.shutdown()
79
+
80
+ # --- internals --------------------------------------------------------
81
+ def _to_readable_span(self, record: Record) -> ReadableSpan:
82
+ trace_id = int(record.trace_id, 16)
83
+ context = SpanContext(
84
+ trace_id=trace_id,
85
+ span_id=int(record.span_id, 16),
86
+ is_remote=False,
87
+ trace_flags=_SAMPLED,
88
+ )
89
+ parent = None
90
+ if record.parent_span_id is not None:
91
+ parent = SpanContext(
92
+ trace_id=trace_id,
93
+ span_id=int(record.parent_span_id, 16),
94
+ is_remote=False,
95
+ trace_flags=_SAMPLED,
96
+ )
97
+ attributes = self._mapper.attributes(
98
+ record,
99
+ capture_content=self._capture_content,
100
+ emit_legacy_system=self._emit_legacy_system,
101
+ )
102
+ return ReadableSpan(
103
+ name=self._mapper.span_name(record),
104
+ context=context,
105
+ parent=parent,
106
+ resource=self._resource,
107
+ attributes=attributes,
108
+ kind=self._mapper.span_kind(record),
109
+ status=_status(record.status),
110
+ start_time=record.start_unix_nanos,
111
+ end_time=record.end_unix_nanos,
112
+ instrumentation_scope=self._scope,
113
+ )
114
+
115
+ @staticmethod
116
+ def _build_span_exporter(
117
+ endpoint: str | None, protocol: str, headers: dict[str, str] | None
118
+ ) -> SpanExporter:
119
+ if protocol in ("http", "http/protobuf"):
120
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
121
+
122
+ return OTLPSpanExporter(endpoint=_http_traces_endpoint(endpoint), headers=headers)
123
+ if protocol == "grpc": # pragma: no cover - optional [grpc] extra
124
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-not-found]
125
+ OTLPSpanExporter as GrpcExporter,
126
+ )
127
+
128
+ return GrpcExporter(endpoint=endpoint, headers=headers) # type: ignore[no-any-return]
129
+ raise ValueError(f"unknown protocol {protocol!r}; expected 'grpc' or 'http/protobuf'")
130
+
131
+
132
+ def _http_traces_endpoint(endpoint: str | None) -> str | None:
133
+ """For OTLP/HTTP, append the ``/v1/traces`` signal path when the caller gave only a base
134
+ URL. The OTLP/HTTP exporter does NOT append it when ``endpoint`` is set explicitly, so
135
+ ``http://host:4318`` would 404; a path already present (a custom collector route) is kept.
136
+ ``None`` is returned unchanged so the OTel env-var defaults still apply."""
137
+ if endpoint is None:
138
+ return None
139
+ parsed = urlsplit(endpoint)
140
+ if parsed.path in ("", "/"):
141
+ return urlunsplit(parsed._replace(path="/v1/traces"))
142
+ return endpoint
143
+
144
+
145
+ def _status(status: RunStatus) -> Status:
146
+ if status is RunStatus.OK:
147
+ return Status(StatusCode.OK)
148
+ if status in _OK_STATUSES: # RUNNING (shouldn't reach export) ⇒ unset
149
+ return Status(StatusCode.UNSET)
150
+ return Status(StatusCode.ERROR, description=status.value)
@@ -0,0 +1,45 @@
1
+ """W3C TraceContext propagation helpers for cross-process / cross-agent hops.
2
+
3
+ Used by A2A (feat-014) and MCP (feat-016) integrations to stitch one end-to-end trace
4
+ across processes: the caller injects ``traceparent``/``tracestate`` from a ForgeSight
5
+ trace/span id, the callee extracts them and opens its span as a child.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from opentelemetry.trace import (
11
+ NonRecordingSpan,
12
+ SpanContext,
13
+ TraceFlags,
14
+ get_current_span,
15
+ set_span_in_context,
16
+ )
17
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
18
+
19
+ _PROP = TraceContextTextMapPropagator()
20
+ _SAMPLED = TraceFlags(TraceFlags.SAMPLED)
21
+
22
+
23
+ def inject(trace_id: str, span_id: str, carrier: dict[str, str] | None = None) -> dict[str, str]:
24
+ """Inject ``traceparent``/``tracestate`` for the given ids into a carrier dict."""
25
+ out: dict[str, str] = {} if carrier is None else carrier
26
+ context = set_span_in_context(
27
+ NonRecordingSpan(
28
+ SpanContext(
29
+ trace_id=int(trace_id, 16),
30
+ span_id=int(span_id, 16),
31
+ is_remote=False,
32
+ trace_flags=_SAMPLED,
33
+ )
34
+ )
35
+ )
36
+ _PROP.inject(out, context=context)
37
+ return out
38
+
39
+
40
+ def extract(carrier: dict[str, str]) -> tuple[str, str] | None:
41
+ """Extract ``(trace_id_hex, span_id_hex)`` from a carrier, or ``None`` if absent."""
42
+ span_context = get_current_span(_PROP.extract(carrier)).get_span_context()
43
+ if not span_context.is_valid:
44
+ return None
45
+ return format(span_context.trace_id, "032x"), format(span_context.span_id, "016x")
File without changes
@@ -0,0 +1,207 @@
1
+ """The single source of truth for ForgeSight's OTLP wire format.
2
+
3
+ Maps a :class:`~forgesight_api.Record` onto a span name, an OTel ``SpanKind``, and the
4
+ GenAI semantic-convention attribute set, per ``docs/design/otel-semantic-conventions.md``.
5
+ Re-pinning the spec changes only this module (P5, ADR-0004).
6
+
7
+ The conventions live in ``open-telemetry/semantic-conventions-genai`` and are all at
8
+ ``Development`` stability with no tagged release, so we pin to a commit and stamp the
9
+ version on every span's Resource.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections.abc import Mapping
16
+
17
+ from opentelemetry.trace import SpanKind
18
+ from opentelemetry.util.types import AttributeValue
19
+
20
+ from forgesight_api import Kind, Record, RunStatus
21
+
22
+ # --- pinning ---------------------------------------------------------------
23
+ SEMCONV_COMMIT = "open-telemetry/semantic-conventions-genai@main"
24
+ SEMCONV_VERSION = "genai-dev-2026-06"
25
+
26
+ # --- attribute keys (locked to the design doc) -----------------------------
27
+ GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
28
+ GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
29
+ GEN_AI_SYSTEM = "gen_ai.system" # legacy; opt-in only
30
+ GEN_AI_AGENT_NAME = "gen_ai.agent.name"
31
+ GEN_AI_AGENT_VERSION = "gen_ai.agent.version"
32
+ GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
33
+ GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
34
+ GEN_AI_REQUEST_PREFIX = "gen_ai.request."
35
+ GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
36
+ GEN_AI_RESPONSE_ID = "gen_ai.response.id"
37
+ GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
38
+ GEN_AI_RESPONSE_TTFC = "gen_ai.response.time_to_first_chunk"
39
+ GEN_AI_USAGE_INPUT = "gen_ai.usage.input_tokens"
40
+ GEN_AI_USAGE_OUTPUT = "gen_ai.usage.output_tokens"
41
+ GEN_AI_USAGE_CACHE_READ = "gen_ai.usage.cache_read.input_tokens"
42
+ GEN_AI_USAGE_CACHE_CREATION = "gen_ai.usage.cache_creation.input_tokens"
43
+ GEN_AI_USAGE_REASONING = "gen_ai.usage.reasoning.output_tokens"
44
+ GEN_AI_TOOL_NAME = "gen_ai.tool.name"
45
+ GEN_AI_TOOL_TYPE = "gen_ai.tool.type"
46
+ GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id"
47
+ GEN_AI_TOOL_DESCRIPTION = "gen_ai.tool.description"
48
+ GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
49
+ GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
50
+ GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
51
+ MCP_METHOD_NAME = "mcp.method.name"
52
+ MCP_SESSION_ID = "mcp.session.id"
53
+ MCP_PROTOCOL_VERSION = "mcp.protocol.version"
54
+ ERROR_TYPE = "error.type"
55
+
56
+ # extensions (namespaced — OTel defines none of these)
57
+ FORGESIGHT_RUN_ID = "forgesight.run.id"
58
+ FORGESIGHT_PARENT_RUN_ID = "forgesight.parent.run_id"
59
+ FORGESIGHT_COST_USD = "forgesight.usage.cost_usd"
60
+ FORGESIGHT_SEMCONV_VERSION = "forgesight.semconv_version"
61
+
62
+ # operation.name values
63
+ OP_INVOKE_AGENT = "invoke_agent"
64
+ OP_INVOKE_WORKFLOW = "invoke_workflow"
65
+ OP_CHAT = "chat"
66
+ OP_EXECUTE_TOOL = "execute_tool"
67
+
68
+ _MCP_TOOLS_CALL = "tools/call"
69
+ # structured run fields that feat-002 stashes in Record.attributes → mapped to gen_ai.*
70
+ _STRUCTURED = {
71
+ "agent.version": GEN_AI_AGENT_VERSION,
72
+ "context.id": GEN_AI_CONVERSATION_ID,
73
+ "parent.run_id": FORGESIGHT_PARENT_RUN_ID,
74
+ }
75
+ _OK_STATUSES = frozenset({RunStatus.OK, RunStatus.RUNNING})
76
+
77
+
78
+ def _coerce(value: object) -> AttributeValue:
79
+ """Coerce an arbitrary value to a valid OTel attribute value."""
80
+ if isinstance(value, str | bool | int | float):
81
+ return value
82
+ if isinstance(value, list | tuple):
83
+ return [str(item) for item in value]
84
+ return str(value)
85
+
86
+
87
+ class SemConvMapper:
88
+ """Pure Record → (span name, kind, attributes) mapping. No I/O, no OTel SDK state."""
89
+
90
+ def span_name(self, record: Record) -> str:
91
+ if record.kind is Kind.WORKFLOW:
92
+ return f"{OP_INVOKE_WORKFLOW} {record.name}"
93
+ if record.kind is Kind.AGENT:
94
+ return f"{OP_INVOKE_AGENT} {record.name}"
95
+ if record.kind is Kind.LLM:
96
+ return f"{OP_CHAT} {record.name}"
97
+ if record.kind is Kind.TOOL:
98
+ return f"{OP_EXECUTE_TOOL} {record.name}"
99
+ if record.kind is Kind.MCP and record.mcp is not None:
100
+ if record.mcp.method == _MCP_TOOLS_CALL and record.mcp.tool:
101
+ return f"{_MCP_TOOLS_CALL} {record.mcp.tool}"
102
+ return record.mcp.method
103
+ return record.name # STEP (custom name)
104
+
105
+ def span_kind(self, record: Record) -> SpanKind:
106
+ if record.kind in (Kind.LLM, Kind.MCP):
107
+ return SpanKind.CLIENT
108
+ return SpanKind.INTERNAL
109
+
110
+ def attributes(
111
+ self, record: Record, *, capture_content: bool = False, emit_legacy_system: bool = False
112
+ ) -> dict[str, AttributeValue]:
113
+ attrs: dict[str, AttributeValue] = {FORGESIGHT_RUN_ID: record.run_id}
114
+ self._map_metadata(record.attributes, attrs)
115
+ if record.kind is Kind.AGENT:
116
+ attrs[GEN_AI_OPERATION_NAME] = OP_INVOKE_AGENT
117
+ attrs[GEN_AI_AGENT_NAME] = record.name
118
+ elif record.kind is Kind.WORKFLOW:
119
+ attrs[GEN_AI_OPERATION_NAME] = OP_INVOKE_WORKFLOW
120
+ elif record.kind is Kind.LLM and record.llm is not None:
121
+ self._map_llm(record, attrs, capture_content, emit_legacy_system)
122
+ elif record.kind is Kind.TOOL and record.tool is not None:
123
+ attrs[GEN_AI_OPERATION_NAME] = OP_EXECUTE_TOOL
124
+ attrs[GEN_AI_TOOL_NAME] = record.tool.name
125
+ attrs[GEN_AI_TOOL_TYPE] = record.tool.tool_type
126
+ if record.tool.call_id is not None:
127
+ attrs[GEN_AI_TOOL_CALL_ID] = record.tool.call_id
128
+ if record.tool.description is not None:
129
+ attrs[GEN_AI_TOOL_DESCRIPTION] = record.tool.description
130
+ elif record.kind is Kind.MCP and record.mcp is not None:
131
+ self._map_mcp(record, attrs)
132
+ if record.error is not None:
133
+ attrs[ERROR_TYPE] = record.error.error_type
134
+ if record.error.code is not None:
135
+ attrs["error.code"] = record.error.code
136
+ elif record.status not in _OK_STATUSES:
137
+ attrs[ERROR_TYPE] = record.status.value
138
+ return attrs
139
+
140
+ # --- helpers ----------------------------------------------------------
141
+ def _map_metadata(self, source: Mapping[str, object], attrs: dict[str, AttributeValue]) -> None:
142
+ for key, value in source.items():
143
+ mapped = _STRUCTURED.get(key)
144
+ attrs[mapped if mapped is not None else key] = _coerce(value)
145
+
146
+ def _map_llm(
147
+ self,
148
+ record: Record,
149
+ attrs: dict[str, AttributeValue],
150
+ capture_content: bool,
151
+ emit_legacy_system: bool,
152
+ ) -> None:
153
+ llm = record.llm
154
+ assert llm is not None
155
+ attrs[GEN_AI_OPERATION_NAME] = OP_CHAT
156
+ attrs[GEN_AI_PROVIDER_NAME] = llm.provider
157
+ if emit_legacy_system:
158
+ attrs[GEN_AI_SYSTEM] = llm.provider
159
+ attrs[GEN_AI_REQUEST_MODEL] = llm.request_model
160
+ if llm.response_model is not None:
161
+ attrs[GEN_AI_RESPONSE_MODEL] = llm.response_model
162
+ if llm.response_id is not None:
163
+ attrs[GEN_AI_RESPONSE_ID] = llm.response_id
164
+ usage = llm.usage
165
+ attrs[GEN_AI_USAGE_INPUT] = usage.input
166
+ attrs[GEN_AI_USAGE_OUTPUT] = usage.output
167
+ if usage.cache_read:
168
+ attrs[GEN_AI_USAGE_CACHE_READ] = usage.cache_read
169
+ if usage.cache_creation:
170
+ attrs[GEN_AI_USAGE_CACHE_CREATION] = usage.cache_creation
171
+ if usage.reasoning:
172
+ attrs[GEN_AI_USAGE_REASONING] = usage.reasoning
173
+ if llm.finish_reasons:
174
+ attrs[GEN_AI_RESPONSE_FINISH_REASONS] = list(llm.finish_reasons)
175
+ if llm.time_to_first_chunk_ms is not None:
176
+ attrs[GEN_AI_RESPONSE_TTFC] = llm.time_to_first_chunk_ms / 1000.0
177
+ if llm.cost_usd is not None:
178
+ attrs[FORGESIGHT_COST_USD] = llm.cost_usd
179
+ for key, value in llm.params.items():
180
+ attrs[f"{GEN_AI_REQUEST_PREFIX}{key}"] = _coerce(value)
181
+ if capture_content and llm.content is not None:
182
+ self._map_content(llm.content, attrs)
183
+
184
+ @staticmethod
185
+ def _map_content(content: object, attrs: dict[str, AttributeValue]) -> None:
186
+ # content is the experimental forgesight_api.Content container (P7-gated).
187
+ for field, key in (
188
+ ("input_messages", GEN_AI_INPUT_MESSAGES),
189
+ ("output_messages", GEN_AI_OUTPUT_MESSAGES),
190
+ ("system_instructions", GEN_AI_SYSTEM_INSTRUCTIONS),
191
+ ):
192
+ value = getattr(content, field, None)
193
+ if value is not None:
194
+ attrs[key] = json.dumps(value, default=str)
195
+
196
+ def _map_mcp(self, record: Record, attrs: dict[str, AttributeValue]) -> None:
197
+ mcp = record.mcp
198
+ assert mcp is not None
199
+ attrs[MCP_METHOD_NAME] = mcp.method
200
+ if mcp.session_id is not None:
201
+ attrs[MCP_SESSION_ID] = mcp.session_id
202
+ if mcp.protocol_version is not None:
203
+ attrs[MCP_PROTOCOL_VERSION] = mcp.protocol_version
204
+ if mcp.method == _MCP_TOOLS_CALL:
205
+ attrs[GEN_AI_OPERATION_NAME] = OP_EXECUTE_TOOL
206
+ if mcp.tool is not None:
207
+ attrs[GEN_AI_TOOL_NAME] = mcp.tool
@@ -0,0 +1,147 @@
1
+ """Tests for OTelExporter: ReadableSpan construction, fault isolation, e2e."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ import pytest
8
+ from opentelemetry.sdk.trace import ReadableSpan
9
+ from opentelemetry.sdk.trace.export import SpanExportResult
10
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
11
+ from opentelemetry.trace import SpanKind
12
+ from opentelemetry.trace.status import StatusCode
13
+
14
+ from forgesight_api import ExportResult, Kind, LLMCall, Record, RunStatus, TokenUsage
15
+ from forgesight_core import configure, reset_runtime, telemetry
16
+ from forgesight_otel import OTelExporter
17
+ from forgesight_otel.exporter import _http_traces_endpoint, _status
18
+
19
+ TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"
20
+
21
+
22
+ def _llm_record(span: str, parent: str | None) -> Record:
23
+ return Record(
24
+ kind=Kind.LLM,
25
+ run_id="01J9Z3K7P8QF2R5V6W7X8Y9Z0A",
26
+ trace_id=TRACE,
27
+ span_id=span,
28
+ parent_span_id=parent,
29
+ name="claude-sonnet-4-5",
30
+ status=RunStatus.OK,
31
+ start_unix_nanos=1_000_000,
32
+ end_unix_nanos=3_000_000,
33
+ llm=LLMCall(
34
+ provider="anthropic",
35
+ request_model="claude-sonnet-4-5",
36
+ usage=TokenUsage(input=100, output=50),
37
+ cost_usd=0.01,
38
+ ),
39
+ )
40
+
41
+
42
+ def test_export_builds_readable_spans_with_our_ids() -> None:
43
+ sink = InMemorySpanExporter()
44
+ exporter = OTelExporter(span_exporter=sink, service_name="t")
45
+ result = exporter.export([_llm_record("00f067aa0ba902b7", "0011223344556677")])
46
+ assert result is ExportResult.SUCCESS
47
+ spans = sink.get_finished_spans()
48
+ assert len(spans) == 1
49
+ span = spans[0]
50
+ assert span.name == "chat claude-sonnet-4-5"
51
+ assert span.kind is SpanKind.CLIENT
52
+ assert span.context is not None
53
+ assert format(span.context.trace_id, "032x") == TRACE
54
+ assert format(span.context.span_id, "016x") == "00f067aa0ba902b7"
55
+ assert span.parent is not None
56
+ assert format(span.parent.span_id, "016x") == "0011223344556677"
57
+ assert span.attributes is not None
58
+ assert span.attributes["gen_ai.provider.name"] == "anthropic"
59
+ assert span.attributes["forgesight.usage.cost_usd"] == 0.01
60
+ assert span.resource.attributes["forgesight.semconv_version"]
61
+
62
+
63
+ def test_export_returns_failure_when_sink_returns_failure() -> None:
64
+ class FailSink(InMemorySpanExporter):
65
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: # type: ignore[override]
66
+ return SpanExportResult.FAILURE
67
+
68
+ exporter = OTelExporter(span_exporter=FailSink())
69
+ assert exporter.export([_llm_record("00f067aa0ba902b7", None)]) is ExportResult.FAILURE
70
+
71
+
72
+ def test_export_never_raises() -> None:
73
+ class BoomSink(InMemorySpanExporter):
74
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: # type: ignore[override]
75
+ raise RuntimeError("otlp down")
76
+
77
+ exporter = OTelExporter(span_exporter=BoomSink())
78
+ assert exporter.export([_llm_record("00f067aa0ba902b7", None)]) is ExportResult.FAILURE
79
+
80
+
81
+ def test_error_record_sets_error_span_status() -> None:
82
+ sink = InMemorySpanExporter()
83
+ exporter = OTelExporter(span_exporter=sink)
84
+ rec = Record(
85
+ kind=Kind.AGENT,
86
+ run_id="01J9Z3K7P8QF2R5V6W7X8Y9Z0A",
87
+ trace_id=TRACE,
88
+ span_id="00f067aa0ba902b7",
89
+ parent_span_id=None,
90
+ name="classifier",
91
+ status=RunStatus.ERROR,
92
+ start_unix_nanos=1,
93
+ end_unix_nanos=2,
94
+ )
95
+ exporter.export([rec])
96
+ span = sink.get_finished_spans()[0]
97
+ assert span.status.status_code is StatusCode.ERROR
98
+
99
+
100
+ def test_status_mapping() -> None:
101
+ assert _status(RunStatus.OK).status_code is StatusCode.OK
102
+ assert _status(RunStatus.RUNNING).status_code is StatusCode.UNSET
103
+ assert _status(RunStatus.GUARDRAIL).status_code is StatusCode.ERROR
104
+
105
+
106
+ def test_builds_http_exporter_and_shuts_down() -> None:
107
+ exporter = OTelExporter(protocol="http/protobuf", endpoint="http://localhost:4318")
108
+ assert exporter.force_flush() is True
109
+ exporter.shutdown()
110
+
111
+
112
+ def test_unknown_protocol_raises() -> None:
113
+ with pytest.raises(ValueError, match="unknown protocol"):
114
+ OTelExporter(protocol="carrier-pigeon")
115
+
116
+
117
+ def test_end_to_end_through_runtime() -> None:
118
+ sink = InMemorySpanExporter()
119
+ configure(exporters=[OTelExporter(span_exporter=sink)], sync_export=True)
120
+ try:
121
+ with telemetry.agent_run("issue-classifier", version="1.2.0") as run:
122
+ run.set_metadata(team="platform")
123
+ with run.step("react-1"), run.llm_call("anthropic", "claude-sonnet-4-5") as call:
124
+ call.record_usage(input=10, output=5)
125
+ names = sorted(s.name for s in sink.get_finished_spans())
126
+ assert names == ["chat claude-sonnet-4-5", "invoke_agent issue-classifier", "react-1"]
127
+ agent_span = next(s for s in sink.get_finished_spans() if s.name.startswith("invoke_agent"))
128
+ assert agent_span.attributes is not None
129
+ assert agent_span.attributes["team"] == "platform"
130
+ assert agent_span.attributes["gen_ai.agent.version"] == "1.2.0"
131
+ finally:
132
+ reset_runtime()
133
+
134
+
135
+ def test_http_traces_endpoint_appends_signal_path() -> None:
136
+ # a base URL gets /v1/traces appended (OTLP/HTTP does not append it when endpoint is set)
137
+ assert _http_traces_endpoint("http://localhost:4318") == "http://localhost:4318/v1/traces"
138
+ assert _http_traces_endpoint("http://localhost:4318/") == "http://localhost:4318/v1/traces"
139
+ assert _http_traces_endpoint("https://otlp.example.com") == "https://otlp.example.com/v1/traces"
140
+ # an explicit path (a custom collector route) is left untouched
141
+ assert (
142
+ _http_traces_endpoint("http://localhost:4318/v1/traces")
143
+ == "http://localhost:4318/v1/traces"
144
+ )
145
+ assert _http_traces_endpoint("http://collector/custom/path") == "http://collector/custom/path"
146
+ # None defers to the OTel env-var defaults
147
+ assert _http_traces_endpoint(None) is None
@@ -0,0 +1,28 @@
1
+ """Tests for W3C TraceContext inject/extract roundtrip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from forgesight_otel import extract, inject
6
+
7
+ TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"
8
+ SPAN = "00f067aa0ba902b7"
9
+
10
+
11
+ def test_inject_then_extract_roundtrips() -> None:
12
+ carrier = inject(TRACE, SPAN)
13
+ assert "traceparent" in carrier
14
+ assert TRACE in carrier["traceparent"]
15
+ result = extract(carrier)
16
+ assert result == (TRACE, SPAN)
17
+
18
+
19
+ def test_extract_empty_carrier_is_none() -> None:
20
+ assert extract({}) is None
21
+
22
+
23
+ def test_inject_into_existing_carrier() -> None:
24
+ carrier = {"x-custom": "keep"}
25
+ out = inject(TRACE, SPAN, carrier)
26
+ assert out is carrier
27
+ assert out["x-custom"] == "keep"
28
+ assert "traceparent" in out
@@ -0,0 +1,226 @@
1
+ """Table-driven tests for the Record → GenAI semconv mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import MappingProxyType
6
+
7
+ import pytest
8
+ from opentelemetry.trace import SpanKind
9
+
10
+ from forgesight_api import (
11
+ Content,
12
+ Kind,
13
+ LLMCall,
14
+ MCPCall,
15
+ Record,
16
+ RunStatus,
17
+ TokenUsage,
18
+ ToolCall,
19
+ )
20
+ from forgesight_otel.semconv import (
21
+ ERROR_TYPE,
22
+ FORGESIGHT_COST_USD,
23
+ FORGESIGHT_RUN_ID,
24
+ GEN_AI_AGENT_NAME,
25
+ GEN_AI_AGENT_VERSION,
26
+ GEN_AI_CONVERSATION_ID,
27
+ GEN_AI_INPUT_MESSAGES,
28
+ GEN_AI_OPERATION_NAME,
29
+ GEN_AI_PROVIDER_NAME,
30
+ GEN_AI_REQUEST_MODEL,
31
+ GEN_AI_RESPONSE_FINISH_REASONS,
32
+ GEN_AI_SYSTEM,
33
+ GEN_AI_TOOL_NAME,
34
+ GEN_AI_USAGE_CACHE_READ,
35
+ GEN_AI_USAGE_INPUT,
36
+ MCP_METHOD_NAME,
37
+ SemConvMapper,
38
+ )
39
+
40
+ MAPPER = SemConvMapper()
41
+ TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"
42
+ SPAN = "00f067aa0ba902b7"
43
+
44
+
45
+ def _record(kind: Kind, name: str, **kw: object) -> Record:
46
+ return Record(
47
+ kind=kind,
48
+ run_id="01J9Z3K7P8QF2R5V6W7X8Y9Z0A",
49
+ trace_id=TRACE,
50
+ span_id=SPAN,
51
+ parent_span_id=kw.pop("parent_span_id", None), # type: ignore[arg-type]
52
+ name=name,
53
+ status=kw.pop("status", RunStatus.OK), # type: ignore[arg-type]
54
+ start_unix_nanos=1_000_000,
55
+ end_unix_nanos=3_000_000,
56
+ attributes=MappingProxyType(kw.pop("attributes", {})), # type: ignore[arg-type]
57
+ llm=kw.pop("llm", None), # type: ignore[arg-type]
58
+ tool=kw.pop("tool", None), # type: ignore[arg-type]
59
+ mcp=kw.pop("mcp", None), # type: ignore[arg-type]
60
+ error=kw.pop("error", None), # type: ignore[arg-type]
61
+ )
62
+
63
+
64
+ def test_span_names_and_kinds() -> None:
65
+ cases = [
66
+ (_record(Kind.WORKFLOW, "nightly"), "invoke_workflow nightly", SpanKind.INTERNAL),
67
+ (_record(Kind.AGENT, "classifier"), "invoke_agent classifier", SpanKind.INTERNAL),
68
+ (_record(Kind.STEP, "react-1"), "react-1", SpanKind.INTERNAL),
69
+ (
70
+ _record(Kind.LLM, "claude", llm=LLMCall(provider="anthropic", request_model="claude")),
71
+ "chat claude",
72
+ SpanKind.CLIENT,
73
+ ),
74
+ (
75
+ _record(Kind.TOOL, "search", tool=ToolCall(name="search")),
76
+ "execute_tool search",
77
+ SpanKind.INTERNAL,
78
+ ),
79
+ (
80
+ _record(
81
+ Kind.MCP, "tools/call", mcp=MCPCall(server="f", method="tools/call", tool="rd")
82
+ ),
83
+ "tools/call rd",
84
+ SpanKind.CLIENT,
85
+ ),
86
+ (
87
+ _record(Kind.MCP, "tools/list", mcp=MCPCall(server="f", method="tools/list")),
88
+ "tools/list",
89
+ SpanKind.CLIENT,
90
+ ),
91
+ ]
92
+ for record, name, kind in cases:
93
+ assert MAPPER.span_name(record) == name
94
+ assert MAPPER.span_kind(record) == kind
95
+
96
+
97
+ def test_agent_attributes_and_structured_metadata() -> None:
98
+ rec = _record(
99
+ Kind.AGENT,
100
+ "classifier",
101
+ attributes={"agent.version": "1.2.0", "context.id": "sess-9", "team": "platform"},
102
+ )
103
+ attrs = MAPPER.attributes(rec)
104
+ assert attrs[GEN_AI_OPERATION_NAME] == "invoke_agent"
105
+ assert attrs[GEN_AI_AGENT_NAME] == "classifier"
106
+ assert attrs[GEN_AI_AGENT_VERSION] == "1.2.0"
107
+ assert attrs[GEN_AI_CONVERSATION_ID] == "sess-9"
108
+ assert attrs["team"] == "platform" # business metadata passes through
109
+ assert attrs[FORGESIGHT_RUN_ID] == rec.run_id
110
+
111
+
112
+ def test_llm_attributes_cost_is_extension_not_gen_ai() -> None:
113
+ llm = LLMCall(
114
+ provider="anthropic",
115
+ request_model="claude-sonnet-4-5",
116
+ usage=TokenUsage(input=100, output=50, cache_read=10),
117
+ cost_usd=0.0123,
118
+ finish_reasons=("stop",),
119
+ )
120
+ attrs = MAPPER.attributes(_record(Kind.LLM, "claude-sonnet-4-5", llm=llm))
121
+ assert attrs[GEN_AI_PROVIDER_NAME] == "anthropic"
122
+ assert attrs[GEN_AI_REQUEST_MODEL] == "claude-sonnet-4-5"
123
+ assert attrs[GEN_AI_USAGE_INPUT] == 100
124
+ assert attrs[GEN_AI_USAGE_CACHE_READ] == 10
125
+ assert attrs[GEN_AI_RESPONSE_FINISH_REASONS] == ["stop"]
126
+ assert attrs[FORGESIGHT_COST_USD] == 0.0123
127
+ assert "gen_ai.usage.cost" not in attrs
128
+ assert "gen_ai.usage.cost_usd" not in attrs
129
+ assert GEN_AI_SYSTEM not in attrs # legacy off by default
130
+
131
+
132
+ def test_legacy_system_opt_in() -> None:
133
+ llm = LLMCall(provider="openai", request_model="gpt")
134
+ attrs = MAPPER.attributes(_record(Kind.LLM, "gpt", llm=llm), emit_legacy_system=True)
135
+ assert attrs[GEN_AI_SYSTEM] == "openai"
136
+
137
+
138
+ def test_content_gating() -> None:
139
+ llm = LLMCall(
140
+ provider="anthropic",
141
+ request_model="m",
142
+ content=Content(input_messages=[{"role": "user", "text": "hi"}]),
143
+ )
144
+ off = MAPPER.attributes(_record(Kind.LLM, "m", llm=llm), capture_content=False)
145
+ assert GEN_AI_INPUT_MESSAGES not in off
146
+ on = MAPPER.attributes(_record(Kind.LLM, "m", llm=llm), capture_content=True)
147
+ assert "user" in str(on[GEN_AI_INPUT_MESSAGES])
148
+
149
+
150
+ def test_mcp_tools_call_maps_to_execute_tool() -> None:
151
+ mcp = MCPCall(server="files", method="tools/call", tool="read_file", session_id="s1")
152
+ attrs = MAPPER.attributes(_record(Kind.MCP, "tools/call", mcp=mcp))
153
+ assert attrs[MCP_METHOD_NAME] == "tools/call"
154
+ assert attrs[GEN_AI_OPERATION_NAME] == "execute_tool"
155
+ assert attrs[GEN_AI_TOOL_NAME] == "read_file"
156
+
157
+
158
+ def test_tool_attributes() -> None:
159
+ tool = ToolCall(name="search", tool_type="function", call_id="c1", description="web search")
160
+ attrs = MAPPER.attributes(_record(Kind.TOOL, "search", tool=tool))
161
+ assert attrs[GEN_AI_TOOL_NAME] == "search"
162
+ assert attrs[GEN_AI_OPERATION_NAME] == "execute_tool"
163
+
164
+
165
+ def test_error_status_sets_error_type() -> None:
166
+ attrs = MAPPER.attributes(_record(Kind.AGENT, "a", status=RunStatus.ERROR))
167
+ assert attrs[ERROR_TYPE] == "error"
168
+ budget = MAPPER.attributes(_record(Kind.AGENT, "a", status=RunStatus.BUDGET_EXCEEDED))
169
+ assert budget[ERROR_TYPE] == "budget_exceeded"
170
+
171
+
172
+ @pytest.mark.parametrize(
173
+ ("value", "expected"),
174
+ [
175
+ (1, 1),
176
+ (1.5, 1.5),
177
+ (True, True),
178
+ ("s", "s"),
179
+ (("a", "b"), ["a", "b"]),
180
+ ({"x": 1}, "{'x': 1}"),
181
+ ],
182
+ )
183
+ def test_coerce_via_metadata(value: object, expected: object) -> None:
184
+ attrs = MAPPER.attributes(_record(Kind.STEP, "s", attributes={"k": value}))
185
+ assert attrs["k"] == expected
186
+
187
+
188
+ def test_llm_full_optional_fields() -> None:
189
+ llm = LLMCall(
190
+ provider="anthropic",
191
+ request_model="m",
192
+ response_model="m-2",
193
+ response_id="r1",
194
+ usage=TokenUsage(input=1, output=2, cache_creation=3, reasoning=4),
195
+ time_to_first_chunk_ms=120.0,
196
+ params={"temperature": 0.2},
197
+ )
198
+ attrs = MAPPER.attributes(_record(Kind.LLM, "m", llm=llm))
199
+ assert attrs["gen_ai.response.model"] == "m-2"
200
+ assert attrs["gen_ai.response.id"] == "r1"
201
+ assert attrs["gen_ai.usage.cache_creation.input_tokens"] == 3
202
+ assert attrs["gen_ai.usage.reasoning.output_tokens"] == 4
203
+ assert attrs["gen_ai.response.time_to_first_chunk"] == 0.12
204
+ assert attrs["gen_ai.request.temperature"] == 0.2
205
+
206
+
207
+ def test_mcp_session_and_protocol_attributes() -> None:
208
+ mcp = MCPCall(server="f", method="tools/list", session_id="s1", protocol_version="2025-06-18")
209
+ attrs = MAPPER.attributes(_record(Kind.MCP, "tools/list", mcp=mcp))
210
+ assert attrs["mcp.session.id"] == "s1"
211
+ assert attrs["mcp.protocol.version"] == "2025-06-18"
212
+
213
+
214
+ def test_error_info_maps_to_error_type_and_code() -> None:
215
+ from forgesight_api import ErrorInfo
216
+
217
+ rec = _record(
218
+ Kind.LLM,
219
+ "m",
220
+ status=RunStatus.ERROR,
221
+ llm=LLMCall(provider="anthropic", request_model="m"),
222
+ error=ErrorInfo(error_type="RateLimitError", message="429", code="rate_limited"),
223
+ )
224
+ attrs = MAPPER.attributes(rec)
225
+ assert attrs[ERROR_TYPE] == "RateLimitError" # exception class, not the status value
226
+ assert attrs["error.code"] == "rate_limited"