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.
- forgesight_otel-0.1.0/.gitignore +38 -0
- forgesight_otel-0.1.0/PKG-INFO +59 -0
- forgesight_otel-0.1.0/README.md +30 -0
- forgesight_otel-0.1.0/pyproject.toml +49 -0
- forgesight_otel-0.1.0/src/forgesight_otel/__init__.py +19 -0
- forgesight_otel-0.1.0/src/forgesight_otel/exporter.py +150 -0
- forgesight_otel-0.1.0/src/forgesight_otel/propagation.py +45 -0
- forgesight_otel-0.1.0/src/forgesight_otel/py.typed +0 -0
- forgesight_otel-0.1.0/src/forgesight_otel/semconv.py +207 -0
- forgesight_otel-0.1.0/tests/test_exporter.py +147 -0
- forgesight_otel-0.1.0/tests/test_propagation.py +28 -0
- forgesight_otel-0.1.0/tests/test_semconv.py +226 -0
|
@@ -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"
|