ratel-ai-telemetry 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,8 @@
1
+ # Virtualenv + build/test tool caches
2
+ .venv/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+
7
+ # Wheel/sdist output
8
+ dist/
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ratel-ai-telemetry` (the Python telemetry helper) are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-07-06
10
+
11
+ ### Added
12
+
13
+ - The telemetry helper (ADR-0015): the full `ratel.*` vocabulary as constants (attribute keys, span/event names, `gen_ai.*` interop keys, and the `Origin`/`SearchTarget`/`AuthOutcome` value enums) pinned to OpenTelemetry semconv `gen_ai` v1.42.0.
14
+ - `init()` sugar over the OpenTelemetry Python SDK: wires an OTLP `http/protobuf` exporter to `RATEL_URL` (or `endpoint=`/`headers=`) with a `service.name` resource and batch processor, and returns the provider as a shutdown handle. `content_capture_mode()` reads the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` gate (default off). OTel deps pinned below 1.42 to keep Python 3.9 support.
15
+ - Shared contract-against-the-pin conformance suite (`../conformance/fixtures.json`): spans built from the constants through the real SDK must emit the exact pinned keys.
16
+ - Usage example in the README (runnable end-to-end in `examples/telemetry-python`).
17
+ - `ratel_span_processor()` / `ratel_span_exporter()` + the default `ratel_signal_filter`: a composable OTLP span-processor for multi-provider coexistence. OpenTelemetry's model is one provider with many span-processors, so a partner already running one (e.g. Langfuse + the Vercel AI SDK) calls `provider.add_span_processor(ratel_span_processor(...))` to dual-export to Ratel — forwarding only the `gen_ai.*` / `ratel.*` signal (overridable via `span_filter`), so the framework's `ai.*` wrapper noise stays out of Ratel. Resolvable top-level via the lazy accessor (a plain `import ratel_ai_telemetry` still pulls no OTel).
18
+ - A regression guard that a plain `import ratel_ai_telemetry` pulls no OpenTelemetry SDK.
19
+
20
+ ### Changed
21
+
22
+ - The OpenTelemetry SDK is an optional `[otlp]` extra: importing `ratel_ai_telemetry` pulls no OTel SDK (ADR-0015), so the SDK (emit), the server (read), and edge/serverless emitters take the `ratel.*` vocabulary weight-free. `init()` lives in the `ratel_ai_telemetry.otlp` submodule (behind the extra) and raises a clear "install `ratel-ai-telemetry[otlp]`" error when it is absent; a lazy top-level accessor keeps `from ratel_ai_telemetry import init` working.
23
+ - `init()` refactored onto `ratel_span_processor` (still exports every span — it owns the provider) and now raises, pointing at `ratel_span_processor`, when a `TracerProvider` is already registered globally, instead of silently no-op'ing.
24
+ - Released as an independent PyPI unit under the `telemetry-py-v*` tag prefix.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Agentified
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: ratel-ai-telemetry
3
+ Version: 0.1.0
4
+ Summary: Ratel telemetry conventions — the ratel.* overlay on OpenTelemetry gen_ai spans. init() helper and constants for the Ratel context engineering platform.
5
+ Project-URL: Homepage, https://github.com/ratel-ai/ratel
6
+ Project-URL: Repository, https://github.com/ratel-ai/ratel
7
+ Project-URL: Issues, https://github.com/ratel-ai/ratel/issues
8
+ Author: Agentified
9
+ License: MIT License
10
+
11
+ Copyright (c) 2025 Agentified
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE.md
31
+ Keywords: ai-agents,gen-ai,observability,opentelemetry,ratel,telemetry
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
35
+ Classifier: Topic :: System :: Monitoring
36
+ Requires-Python: >=3.9
37
+ Provides-Extra: dev
38
+ Requires-Dist: mypy>=1.11; extra == 'dev'
39
+ Requires-Dist: opentelemetry-api<1.42,>=1.41.0; extra == 'dev'
40
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<1.42,>=1.41.0; extra == 'dev'
41
+ Requires-Dist: opentelemetry-sdk<1.42,>=1.41.0; extra == 'dev'
42
+ Requires-Dist: pytest>=8.0; extra == 'dev'
43
+ Requires-Dist: ruff>=0.6; extra == 'dev'
44
+ Provides-Extra: otlp
45
+ Requires-Dist: opentelemetry-api<1.42,>=1.41.0; extra == 'otlp'
46
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<1.42,>=1.41.0; extra == 'otlp'
47
+ Requires-Dist: opentelemetry-sdk<1.42,>=1.41.0; extra == 'otlp'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # `ratel-ai-telemetry` (Python)
51
+
52
+ The `ratel.*` telemetry vocabulary for Python: the constants that codify the Tier 2 overlay
53
+ of [`../CONVENTIONS.md`](../CONVENTIONS.md) (attribute keys, span/event names, the
54
+ `Origin`/`SearchTarget`/`AuthOutcome` value enums, the pinned semconv version). **Importing
55
+ the constants pulls no OpenTelemetry SDK** — the vocabulary stays weight-free for the SDK
56
+ (emit side), the cloud (read side), and edge/serverless emitters
57
+ ([ADR-0007](../../../docs/adr/0007-telemetry-two-streams.md)). `init()` — turnkey OTLP
58
+ exporter sugar over the standard OTel Python SDK — lives in the `ratel_ai_telemetry.otlp`
59
+ submodule behind the optional `[otlp]` extra.
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ from opentelemetry import trace
65
+ from ratel_ai_telemetry import EXECUTE_TOOL, GEN_AI_OPERATION_NAME, GEN_AI_TOOL_NAME, RATEL_ORIGIN, Origin
66
+
67
+ # Emit a standard gen_ai `execute_tool` span enriched with the ratel.* overlay,
68
+ # on your own OTel provider — the constants alone, no extra needed.
69
+ span = trace.get_tracer("my-agent").start_span(
70
+ EXECUTE_TOOL,
71
+ attributes={
72
+ GEN_AI_OPERATION_NAME: EXECUTE_TOOL,
73
+ GEN_AI_TOOL_NAME: "send_email",
74
+ RATEL_ORIGIN: Origin.AGENT.value,
75
+ },
76
+ )
77
+ span.end()
78
+ ```
79
+
80
+ Want turnkey OTLP export to Ratel? Install `ratel-ai-telemetry[otlp]` and call `init()`:
81
+
82
+ ```python
83
+ from ratel_ai_telemetry.otlp import init # also importable as `from ratel_ai_telemetry import init`
84
+
85
+ provider = init(api_key="sk-...") # wires the OTLP exporter to RATEL_URL (or endpoint=/headers=)
86
+ # ... emit spans ...
87
+ provider.shutdown() # flush the exporter on exit
88
+ ```
89
+
90
+ `init()` owns the global provider, so it is the turnkey path for a greenfield app; it raises —
91
+ pointing at `ratel_span_processor` — rather than silently no-op'ing if a provider is already
92
+ registered. A complete, offline-runnable version (console exporter + a `ratel.search` →
93
+ `execute_tool` trace) is in [`examples/telemetry-python`](../../../examples/telemetry-python/README.md).
94
+
95
+ ### Coexisting with another provider (Langfuse, the Vercel AI SDK, ...)
96
+
97
+ OpenTelemetry's model is one provider with many span-processors. When a partner already owns the
98
+ provider, add `ratel_span_processor` to it instead of calling `init()` — Ratel ingests only the
99
+ `gen_ai.*` / `ratel.*` signal (the default `ratel_signal_filter`), dropping the framework's `ai.*`
100
+ wrapper noise:
101
+
102
+ ```python
103
+ from opentelemetry.sdk.trace import TracerProvider
104
+ from ratel_ai_telemetry.otlp import ratel_span_processor
105
+
106
+ provider = TracerProvider()
107
+ provider.add_span_processor(existing_langfuse_processor) # keeps every span
108
+ provider.add_span_processor(ratel_span_processor(api_key="sk-...")) # gen_ai.*/ratel.* only
109
+ ```
110
+
111
+ Pass `span_filter=lambda _s: True` (or your own predicate) to override the default;
112
+ `ratel_span_exporter()` is the bare OTLP exporter if you want to wire your own processor.
113
+ Note that per-span filtering can orphan the AI SDK's `ai.*` wrapper from its `gen_ai.*` child;
114
+ send everything (or tail-sample) when you need full-trace fidelity rather than just the
115
+ gen_ai/ratel metrics.
116
+
117
+ ## Package shape
118
+
119
+ - Distribution name: `ratel-ai-telemetry`; import name: `ratel_ai_telemetry`
120
+ - Pure Python (hatchling build, no Rust extension); OTel-free constants, `init()` behind the `[otlp]` extra
121
+ - Targets Python >=3.9 (the `[otlp]` OTel deps are pinned below 1.42, the last line supporting 3.9)
122
+ - Released under the `telemetry-py-v*` tag prefix ([ADR-0008](../../../docs/adr/0008-release-engineering.md))
123
+ - MIT ([ADR-0009](../../../docs/adr/0009-licensing.md))
124
+
125
+ ## Build & test
126
+
127
+ From this directory (needs [uv](https://docs.astral.sh/uv/)):
128
+
129
+ ```bash
130
+ uv venv --python 3.11 .venv
131
+ uv pip install --python .venv -e '.[dev]'
132
+ .venv/bin/ruff check . && .venv/bin/mypy ratel_ai_telemetry && .venv/bin/pytest
133
+ ```
134
+
135
+ Unlike the Python SDK there is no `maturin develop` step — the package is pure Python,
136
+ installed editable (`[dev]` pulls the `[otlp]` extra so the tests exercise the real SDK).
137
+ The tests cover the vocabulary (each constant asserted against the pin), `init()`'s
138
+ endpoint/auth resolution, its already-registered guard and the content-capture gate, the
139
+ `ratel_signal_filter` predicate and that `ratel_span_processor` forwards only the spans it
140
+ passes, a purity guard that importing the package pulls no OTel, and the shared
141
+ contract-against-the-pin conformance in
142
+ [`../conformance/`](../conformance/README.md) (spans built from these constants through the
143
+ real SDK must emit the exact pinned keys).
@@ -0,0 +1,94 @@
1
+ # `ratel-ai-telemetry` (Python)
2
+
3
+ The `ratel.*` telemetry vocabulary for Python: the constants that codify the Tier 2 overlay
4
+ of [`../CONVENTIONS.md`](../CONVENTIONS.md) (attribute keys, span/event names, the
5
+ `Origin`/`SearchTarget`/`AuthOutcome` value enums, the pinned semconv version). **Importing
6
+ the constants pulls no OpenTelemetry SDK** — the vocabulary stays weight-free for the SDK
7
+ (emit side), the cloud (read side), and edge/serverless emitters
8
+ ([ADR-0007](../../../docs/adr/0007-telemetry-two-streams.md)). `init()` — turnkey OTLP
9
+ exporter sugar over the standard OTel Python SDK — lives in the `ratel_ai_telemetry.otlp`
10
+ submodule behind the optional `[otlp]` extra.
11
+
12
+ ## Usage
13
+
14
+ ```python
15
+ from opentelemetry import trace
16
+ from ratel_ai_telemetry import EXECUTE_TOOL, GEN_AI_OPERATION_NAME, GEN_AI_TOOL_NAME, RATEL_ORIGIN, Origin
17
+
18
+ # Emit a standard gen_ai `execute_tool` span enriched with the ratel.* overlay,
19
+ # on your own OTel provider — the constants alone, no extra needed.
20
+ span = trace.get_tracer("my-agent").start_span(
21
+ EXECUTE_TOOL,
22
+ attributes={
23
+ GEN_AI_OPERATION_NAME: EXECUTE_TOOL,
24
+ GEN_AI_TOOL_NAME: "send_email",
25
+ RATEL_ORIGIN: Origin.AGENT.value,
26
+ },
27
+ )
28
+ span.end()
29
+ ```
30
+
31
+ Want turnkey OTLP export to Ratel? Install `ratel-ai-telemetry[otlp]` and call `init()`:
32
+
33
+ ```python
34
+ from ratel_ai_telemetry.otlp import init # also importable as `from ratel_ai_telemetry import init`
35
+
36
+ provider = init(api_key="sk-...") # wires the OTLP exporter to RATEL_URL (or endpoint=/headers=)
37
+ # ... emit spans ...
38
+ provider.shutdown() # flush the exporter on exit
39
+ ```
40
+
41
+ `init()` owns the global provider, so it is the turnkey path for a greenfield app; it raises —
42
+ pointing at `ratel_span_processor` — rather than silently no-op'ing if a provider is already
43
+ registered. A complete, offline-runnable version (console exporter + a `ratel.search` →
44
+ `execute_tool` trace) is in [`examples/telemetry-python`](../../../examples/telemetry-python/README.md).
45
+
46
+ ### Coexisting with another provider (Langfuse, the Vercel AI SDK, ...)
47
+
48
+ OpenTelemetry's model is one provider with many span-processors. When a partner already owns the
49
+ provider, add `ratel_span_processor` to it instead of calling `init()` — Ratel ingests only the
50
+ `gen_ai.*` / `ratel.*` signal (the default `ratel_signal_filter`), dropping the framework's `ai.*`
51
+ wrapper noise:
52
+
53
+ ```python
54
+ from opentelemetry.sdk.trace import TracerProvider
55
+ from ratel_ai_telemetry.otlp import ratel_span_processor
56
+
57
+ provider = TracerProvider()
58
+ provider.add_span_processor(existing_langfuse_processor) # keeps every span
59
+ provider.add_span_processor(ratel_span_processor(api_key="sk-...")) # gen_ai.*/ratel.* only
60
+ ```
61
+
62
+ Pass `span_filter=lambda _s: True` (or your own predicate) to override the default;
63
+ `ratel_span_exporter()` is the bare OTLP exporter if you want to wire your own processor.
64
+ Note that per-span filtering can orphan the AI SDK's `ai.*` wrapper from its `gen_ai.*` child;
65
+ send everything (or tail-sample) when you need full-trace fidelity rather than just the
66
+ gen_ai/ratel metrics.
67
+
68
+ ## Package shape
69
+
70
+ - Distribution name: `ratel-ai-telemetry`; import name: `ratel_ai_telemetry`
71
+ - Pure Python (hatchling build, no Rust extension); OTel-free constants, `init()` behind the `[otlp]` extra
72
+ - Targets Python >=3.9 (the `[otlp]` OTel deps are pinned below 1.42, the last line supporting 3.9)
73
+ - Released under the `telemetry-py-v*` tag prefix ([ADR-0008](../../../docs/adr/0008-release-engineering.md))
74
+ - MIT ([ADR-0009](../../../docs/adr/0009-licensing.md))
75
+
76
+ ## Build & test
77
+
78
+ From this directory (needs [uv](https://docs.astral.sh/uv/)):
79
+
80
+ ```bash
81
+ uv venv --python 3.11 .venv
82
+ uv pip install --python .venv -e '.[dev]'
83
+ .venv/bin/ruff check . && .venv/bin/mypy ratel_ai_telemetry && .venv/bin/pytest
84
+ ```
85
+
86
+ Unlike the Python SDK there is no `maturin develop` step — the package is pure Python,
87
+ installed editable (`[dev]` pulls the `[otlp]` extra so the tests exercise the real SDK).
88
+ The tests cover the vocabulary (each constant asserted against the pin), `init()`'s
89
+ endpoint/auth resolution, its already-registered guard and the content-capture gate, the
90
+ `ratel_signal_filter` predicate and that `ratel_span_processor` forwards only the spans it
91
+ passes, a purity guard that importing the package pulls no OTel, and the shared
92
+ contract-against-the-pin conformance in
93
+ [`../conformance/`](../conformance/README.md) (spans built from these constants through the
94
+ real SDK must emit the exact pinned keys).
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ratel-ai-telemetry"
7
+ version = "0.1.0"
8
+ description = "Ratel telemetry conventions — the ratel.* overlay on OpenTelemetry gen_ai spans. init() helper and constants for the Ratel context engineering platform."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE.md" }
11
+ authors = [{ name = "Agentified" }]
12
+ requires-python = ">=3.9"
13
+ keywords = [
14
+ "telemetry",
15
+ "opentelemetry",
16
+ "observability",
17
+ "gen-ai",
18
+ "ai-agents",
19
+ "ratel",
20
+ ]
21
+ classifiers = [
22
+ "Programming Language :: Python :: 3",
23
+ "Intended Audience :: Developers",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Topic :: System :: Monitoring",
26
+ ]
27
+ # The ratel.* vocabulary + the OTLP config/gate are pure Python and OTel-free
28
+ # (ADR-0007), so importing the constants pulls no OpenTelemetry SDK. The SDK is an
29
+ # optional [otlp] extra, needed only by init() (in ratel_ai_telemetry.otlp).
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ # init() is sugar over the standard OTel Python SDK (ADR-0007): no custom transport.
34
+ # Pinned to the 1.41.x line and capped below 1.42, which drops Python 3.9 support
35
+ # (this package targets >=3.9). semantic-conventions is not a direct dep: SERVICE_NAME
36
+ # resolves to the literal "service.name" from opentelemetry-sdk.
37
+ otlp = [
38
+ "opentelemetry-api>=1.41.0,<1.42",
39
+ "opentelemetry-sdk>=1.41.0,<1.42",
40
+ "opentelemetry-exporter-otlp-proto-http>=1.41.0,<1.42",
41
+ ]
42
+ # dev pulls the [otlp] extra so the init() + conformance tests exercise the real SDK.
43
+ dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.11", "ratel-ai-telemetry[otlp]"]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/ratel-ai/ratel"
47
+ Repository = "https://github.com/ratel-ai/ratel"
48
+ Issues = "https://github.com/ratel-ai/ratel/issues"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["ratel_ai_telemetry"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py39"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B", "W"]
59
+
60
+ # Runtime floor is 3.9; mypy's minimum supported target is 3.10, so we type-check
61
+ # against that (matches the Python SDK).
62
+ [tool.mypy]
63
+ python_version = "3.10"
64
+ strict = true
65
+ warn_unused_ignores = true
66
+
67
+ [tool.pytest.ini_options]
68
+ testpaths = ["tests"]
@@ -0,0 +1,229 @@
1
+ """`ratel-ai-telemetry` — the ratel.* telemetry vocabulary.
2
+
3
+ See the wire contract in ../CONVENTIONS.md. Emitting the vocabulary is done through
4
+ the standard OpenTelemetry Python SDK; this package adds no transport and no schema
5
+ (ADR-0007). These constants are the ratel.* overlay Ratel owns, plus the small subset
6
+ of gen_ai.* keys the overlay emits directly on the execute_tool span (borrowed verbatim
7
+ from OpenTelemetry, never renamed).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum
13
+ from typing import Any, Final
14
+
15
+ #: The pinned OpenTelemetry semantic-conventions version this vocabulary tracks
16
+ #: (the gen_ai group). The pin is the contract; consumers read against this exact
17
+ #: version, never "latest" (CONVENTIONS.md § The pin).
18
+ SEMCONV_VERSION: Final = "1.42.0"
19
+
20
+ #: The ecosystem instrumentation env var gating message/tool content capture.
21
+ #: Default off; honored by init() rather than a Ratel-invented flag
22
+ #: (CONVENTIONS.md § Capture gating). Values: legacy boolean, or the enum
23
+ #: NO_CONTENT (default) / SPAN_ONLY / EVENT_ONLY / SPAN_AND_EVENT.
24
+ CAPTURE_CONTENT_ENV: Final = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Span names (CONVENTIONS.md, Tier 2)
28
+ # ---------------------------------------------------------------------------
29
+
30
+ #: ratel.search — capability search (unifies tool-search and skill-search).
31
+ RATEL_SEARCH: Final = "ratel.search"
32
+
33
+ #: execute_tool — the gen_ai.operation.name value for a tool invocation.
34
+ #: Deliberately the standard OTel gen_ai operation, not a bespoke ratel.invoke span,
35
+ #: so a generic OTel backend already understands it (locked 2026-07-05). The invoke
36
+ #: is enriched with ratel.* attributes.
37
+ EXECUTE_TOOL: Final = "execute_tool"
38
+
39
+ #: ratel.skill.load — skill content load (get_skill_content).
40
+ RATEL_SKILL_LOAD: Final = "ratel.skill.load"
41
+
42
+ #: ratel.upstream.register — upstream-MCP ingest.
43
+ RATEL_UPSTREAM_REGISTER: Final = "ratel.upstream.register"
44
+
45
+ #: ratel.auth.flow — MCP auth flow.
46
+ RATEL_AUTH_FLOW: Final = "ratel.auth.flow"
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Span event names (CONVENTIONS.md)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ #: ratel.search.results — Opt-In event carrying hit ids + scores + per-stage BM25
53
+ #: timing; gated like content. The ratel.search span itself carries only counts.
54
+ RATEL_SEARCH_RESULTS: Final = "ratel.search.results"
55
+
56
+ #: gen_ai.client.inference.operation.details — the event that carries message text
57
+ #: and tool-call content (never span attributes). Borrowed from gen_ai (Tier 1).
58
+ GEN_AI_INFERENCE_DETAILS: Final = "gen_ai.client.inference.operation.details"
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # ratel.* attribute keys (CONVENTIONS.md, Tier 2)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ #: ratel.origin — direct library call vs agent-synthesized (shared attribute).
65
+ RATEL_ORIGIN: Final = "ratel.origin"
66
+
67
+ #: ratel.search.target — "tool" or "skill" (see SearchTarget).
68
+ RATEL_SEARCH_TARGET: Final = "ratel.search.target"
69
+
70
+ #: ratel.search.top_k — requested result count.
71
+ RATEL_SEARCH_TOP_K: Final = "ratel.search.top_k"
72
+
73
+ #: ratel.search.hit_count — results returned.
74
+ RATEL_SEARCH_HIT_COUNT: Final = "ratel.search.hit_count"
75
+
76
+ #: ratel.search.query — the search text (content, gated like message content).
77
+ RATEL_SEARCH_QUERY: Final = "ratel.search.query"
78
+
79
+ #: ratel.tool.args_size_bytes — argument payload size on the execute_tool span.
80
+ RATEL_TOOL_ARGS_SIZE_BYTES: Final = "ratel.tool.args_size_bytes"
81
+
82
+ #: ratel.upstream.server — upstream MCP server backing a tool / auth flow.
83
+ RATEL_UPSTREAM_SERVER: Final = "ratel.upstream.server"
84
+
85
+ #: ratel.upstream.transport — "stdio" / "http" / "sse" / ...
86
+ RATEL_UPSTREAM_TRANSPORT: Final = "ratel.upstream.transport"
87
+
88
+ #: ratel.upstream.tool_count — tools ingested on register.
89
+ RATEL_UPSTREAM_TOOL_COUNT: Final = "ratel.upstream.tool_count"
90
+
91
+ #: ratel.skill.id — skill loaded on the ratel.skill.load span.
92
+ RATEL_SKILL_ID: Final = "ratel.skill.id"
93
+
94
+ #: ratel.auth.outcome — "ok" / "refreshed" / "needs_auth" / "failed" (see AuthOutcome).
95
+ RATEL_AUTH_OUTCOME: Final = "ratel.auth.outcome"
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # gen_ai.* interop keys (CONVENTIONS.md, Tier 1 — borrowed verbatim)
99
+ #
100
+ # Only the subset the ratel.* overlay directly emits on the execute_tool span.
101
+ # These are OpenTelemetry's, not ours: exposed so callers avoid stringly-typing
102
+ # them, never renamed into ratel.*.
103
+ # ---------------------------------------------------------------------------
104
+
105
+ #: gen_ai.operation.name — set to EXECUTE_TOOL for a tool invocation.
106
+ GEN_AI_OPERATION_NAME: Final = "gen_ai.operation.name"
107
+
108
+ #: gen_ai.tool.name — the capability tool id.
109
+ GEN_AI_TOOL_NAME: Final = "gen_ai.tool.name"
110
+
111
+ #: gen_ai.tool.call.id — tool call id, when available.
112
+ GEN_AI_TOOL_CALL_ID: Final = "gen_ai.tool.call.id"
113
+
114
+ #: gen_ai.tool.call.arguments — tool arguments (Opt-In content, gated).
115
+ GEN_AI_TOOL_CALL_ARGUMENTS: Final = "gen_ai.tool.call.arguments"
116
+
117
+ #: gen_ai.tool.call.result — tool result (Opt-In content, gated).
118
+ GEN_AI_TOOL_CALL_RESULT: Final = "gen_ai.tool.call.result"
119
+
120
+
121
+ class Origin(str, Enum):
122
+ """Whether a ratel.* span was a direct library call or synthesized by the agent
123
+ inside its loop. Carried by ratel.origin; mirrors the local trace Origin (ADR-0007).
124
+
125
+ A str-Enum: each member equals its exact wire string, so it is usable directly
126
+ as an OTel attribute value.
127
+ """
128
+
129
+ DIRECT = "direct"
130
+ AGENT = "agent"
131
+
132
+
133
+ class SearchTarget(str, Enum):
134
+ """What a ratel.search span was searching. Carried by ratel.search.target; folds
135
+ capability-tool search and skill search into one span shape."""
136
+
137
+ TOOL = "tool"
138
+ SKILL = "skill"
139
+
140
+
141
+ class AuthOutcome(str, Enum):
142
+ """Outcome of an MCP auth flow. Carried by ratel.auth.outcome; NEEDS_AUTH is the
143
+ 401-driven AuthNeeds case (ADR-0007 auth_needs)."""
144
+
145
+ OK = "ok"
146
+ REFRESHED = "refreshed"
147
+ NEEDS_AUTH = "needs_auth"
148
+ FAILED = "failed"
149
+
150
+
151
+ # The OTLP exporter surface (init() + config/gate) lives in the opt-in .otlp
152
+ # submodule behind the [otlp] extra, so plain constant imports stay OTel-free
153
+ # (ADR-0007). A module-level __getattr__ lazily resolves it, so the ergonomic
154
+ # `from ratel_ai_telemetry import init` keeps working without eagerly importing it.
155
+ _OTLP_EXPORTS: Final = frozenset(
156
+ {
157
+ "init",
158
+ "resolve_otlp_config",
159
+ "content_capture_mode",
160
+ "ContentCapture",
161
+ "OtlpConfig",
162
+ "ENDPOINT_ENV",
163
+ "DEFAULT_SERVICE_NAME",
164
+ "ratel_signal_filter",
165
+ "ratel_span_exporter",
166
+ "ratel_span_processor",
167
+ "SpanFilter",
168
+ }
169
+ )
170
+
171
+
172
+ def __getattr__(name: str) -> Any:
173
+ """Lazily resolve the OTLP exporter surface from the opt-in .otlp submodule.
174
+
175
+ Keeps `from ratel_ai_telemetry import init` working while a plain
176
+ `import ratel_ai_telemetry` pulls no OpenTelemetry SDK. init() itself raises a
177
+ clear error if the [otlp] extra is not installed.
178
+ """
179
+ if name in _OTLP_EXPORTS:
180
+ from . import otlp
181
+
182
+ return getattr(otlp, name)
183
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
184
+
185
+
186
+ __all__ = [
187
+ "CAPTURE_CONTENT_ENV",
188
+ "EXECUTE_TOOL",
189
+ "GEN_AI_INFERENCE_DETAILS",
190
+ "GEN_AI_OPERATION_NAME",
191
+ "GEN_AI_TOOL_CALL_ARGUMENTS",
192
+ "GEN_AI_TOOL_CALL_ID",
193
+ "GEN_AI_TOOL_CALL_RESULT",
194
+ "GEN_AI_TOOL_NAME",
195
+ "RATEL_AUTH_FLOW",
196
+ "RATEL_AUTH_OUTCOME",
197
+ "RATEL_ORIGIN",
198
+ "RATEL_SEARCH",
199
+ "RATEL_SEARCH_HIT_COUNT",
200
+ "RATEL_SEARCH_QUERY",
201
+ "RATEL_SEARCH_RESULTS",
202
+ "RATEL_SEARCH_TARGET",
203
+ "RATEL_SEARCH_TOP_K",
204
+ "RATEL_SKILL_ID",
205
+ "RATEL_SKILL_LOAD",
206
+ "RATEL_TOOL_ARGS_SIZE_BYTES",
207
+ "RATEL_UPSTREAM_REGISTER",
208
+ "RATEL_UPSTREAM_SERVER",
209
+ "RATEL_UPSTREAM_TOOL_COUNT",
210
+ "RATEL_UPSTREAM_TRANSPORT",
211
+ "SEMCONV_VERSION",
212
+ "AuthOutcome",
213
+ "Origin",
214
+ "SearchTarget",
215
+ # The OTLP exporter surface, lazily resolved from `.otlp` via __getattr__ above
216
+ # (importing any of these — or `import *` — loads the submodule but still no OTel
217
+ # SDK; only wiring an exporter needs the [otlp] extra).
218
+ "init",
219
+ "resolve_otlp_config",
220
+ "content_capture_mode",
221
+ "ContentCapture",
222
+ "OtlpConfig",
223
+ "ENDPOINT_ENV",
224
+ "DEFAULT_SERVICE_NAME",
225
+ "ratel_signal_filter",
226
+ "ratel_span_exporter",
227
+ "ratel_span_processor",
228
+ "SpanFilter",
229
+ ]
@@ -0,0 +1,235 @@
1
+ """The OTLP exporter surface over the standard OpenTelemetry Python SDK.
2
+
3
+ Two entry points, no custom transport and no schema (ADR-0007, CONVENTIONS.md § init()
4
+ surface):
5
+
6
+ - ``init()`` — the turnkey greenfield path: wires an OTLP http/protobuf exporter and
7
+ registers a provider Ratel owns.
8
+ - ``ratel_span_processor()`` / ``ratel_span_exporter()`` — compose Ratel onto a provider a
9
+ partner already owns (Langfuse, the Vercel AI SDK, ...), since OpenTelemetry's
10
+ coexistence model is one provider with many span-processors.
11
+
12
+ The OpenTelemetry SDK is an optional ``[otlp]`` extra: this module imports it lazily, inside
13
+ the functions that need it, so the config/gate helpers (``resolve_otlp_config``,
14
+ ``content_capture_mode``, ``ratel_signal_filter``) and this whole submodule import OTel-free.
15
+ Only wiring an exporter needs the extra.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import dataclass
23
+ from enum import Enum
24
+ from typing import TYPE_CHECKING
25
+
26
+ from . import CAPTURE_CONTENT_ENV
27
+
28
+ if TYPE_CHECKING:
29
+ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
30
+ from opentelemetry.sdk.trace.export import SpanExporter
31
+
32
+ #: Env var whose value is the default OTLP endpoint when api_key= is used.
33
+ ENDPOINT_ENV = "RATEL_URL"
34
+
35
+ #: service.name used when the caller does not pass one.
36
+ DEFAULT_SERVICE_NAME = "ratel"
37
+
38
+ #: Raised when a function needs the OpenTelemetry SDK but the [otlp] extra is absent.
39
+ _EXTRA_HINT = (
40
+ "ratel telemetry needs the OpenTelemetry SDK. Install the extra: "
41
+ "pip install 'ratel-ai-telemetry[otlp]'."
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class OtlpConfig:
47
+ """Resolved exporter configuration; the pure core of init()."""
48
+
49
+ url: str
50
+ headers: dict[str, str]
51
+ service_name: str
52
+
53
+
54
+ def resolve_otlp_config(
55
+ *,
56
+ api_key: str | None = None,
57
+ endpoint: str | None = None,
58
+ headers: Mapping[str, str] | None = None,
59
+ service_name: str | None = None,
60
+ env: Mapping[str, str] | None = None,
61
+ ) -> OtlpConfig:
62
+ """Resolve init() options into concrete exporter config.
63
+
64
+ Accepts either api_key= (endpoint defaults to RATEL_URL, Authorization: Bearer)
65
+ or endpoint=/headers= (custom endpoint / collector). The forms compose: an
66
+ explicit endpoint wins over RATEL_URL, and api_key adds the Bearer header on top
67
+ of any headers. env is injectable so the precedence is testable without a network.
68
+ """
69
+ resolved_env = os.environ if env is None else env
70
+ url = endpoint if endpoint is not None else resolved_env.get(ENDPOINT_ENV)
71
+ if not url:
72
+ raise ValueError(
73
+ f"ratel telemetry init: no endpoint. Pass endpoint= or set {ENDPOINT_ENV} "
74
+ "(use api_key= for Bearer auth)."
75
+ )
76
+ resolved_headers: dict[str, str] = dict(headers or {})
77
+ if api_key:
78
+ resolved_headers["Authorization"] = f"Bearer {api_key}"
79
+ return OtlpConfig(
80
+ url=url,
81
+ headers=resolved_headers,
82
+ service_name=service_name or DEFAULT_SERVICE_NAME,
83
+ )
84
+
85
+
86
+ #: Predicate deciding whether a finished span is forwarded to Ratel.
87
+ SpanFilter = Callable[["ReadableSpan"], bool]
88
+
89
+
90
+ def ratel_signal_filter(span: ReadableSpan) -> bool:
91
+ """Default span filter: forward only signal-bearing spans — a ratel.* span name, or
92
+ any attribute key under gen_ai.* / ratel.*.
93
+
94
+ This is what lets Ratel share a provider with e.g. Langfuse + the Vercel AI SDK and
95
+ ingest only the gen_ai/ratel signal (the AI SDK's gen_ai.* spans + Ratel's own
96
+ ratel.search / execute_tool), dropping the framework's ai.* wrapper noise.
97
+ """
98
+ if span.name.startswith("ratel."):
99
+ return True
100
+ attributes = span.attributes or {}
101
+ return any(key.startswith("gen_ai.") or key.startswith("ratel.") for key in attributes)
102
+
103
+
104
+ def _accept_all_spans(_span: ReadableSpan) -> bool:
105
+ """The filter init() uses: it owns the provider, so it exports every span."""
106
+ return True
107
+
108
+
109
+ def ratel_span_exporter(
110
+ *,
111
+ api_key: str | None = None,
112
+ endpoint: str | None = None,
113
+ headers: Mapping[str, str] | None = None,
114
+ ) -> SpanExporter:
115
+ """Build the OTLP http/protobuf span exporter at the resolved Ratel endpoint.
116
+
117
+ The standalone exporter for callers wiring their own span-processor; ratel_span_processor
118
+ batches over it. Carries no resource — the caller's provider owns service.name. Needs the
119
+ [otlp] extra; raises a clear error otherwise.
120
+ """
121
+ try:
122
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
123
+ except ModuleNotFoundError as exc:
124
+ raise ModuleNotFoundError(_EXTRA_HINT) from exc
125
+ cfg = resolve_otlp_config(api_key=api_key, endpoint=endpoint, headers=headers)
126
+ return OTLPSpanExporter(endpoint=cfg.url, headers=dict(cfg.headers))
127
+
128
+
129
+ def ratel_span_processor(
130
+ *,
131
+ api_key: str | None = None,
132
+ endpoint: str | None = None,
133
+ headers: Mapping[str, str] | None = None,
134
+ span_filter: SpanFilter | None = None,
135
+ ) -> SpanProcessor:
136
+ """A BatchSpanProcessor over the Ratel OTLP exporter that forwards only the spans passing
137
+ span_filter (default ratel_signal_filter; pass ``lambda _s: True`` to forward everything).
138
+
139
+ Add it to your own provider (``provider.add_span_processor(ratel_span_processor(...))``) to
140
+ send Ratel telemetry alongside another provider — no global side effects, no resource.
141
+ Greenfield apps that want Ratel to own the provider should call init() instead. Needs the
142
+ [otlp] extra; raises a clear error otherwise.
143
+ """
144
+ try:
145
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
146
+ except ModuleNotFoundError as exc:
147
+ raise ModuleNotFoundError(_EXTRA_HINT) from exc
148
+ active_filter = span_filter or ratel_signal_filter
149
+ exporter = ratel_span_exporter(api_key=api_key, endpoint=endpoint, headers=headers)
150
+
151
+ class _RatelSpanProcessor(BatchSpanProcessor):
152
+ def on_end(self, span: ReadableSpan) -> None:
153
+ if active_filter(span):
154
+ super().on_end(span)
155
+
156
+ return _RatelSpanProcessor(exporter)
157
+
158
+
159
+ def init(
160
+ *,
161
+ api_key: str | None = None,
162
+ endpoint: str | None = None,
163
+ headers: Mapping[str, str] | None = None,
164
+ service_name: str | None = None,
165
+ ) -> TracerProvider:
166
+ """Wire an OTLP http/protobuf exporter + batch processor + service.name resource,
167
+ register it as the global tracer provider, and return it as the shutdown handle
168
+ (call provider.shutdown() / provider.force_flush()). Everything else is the
169
+ untouched OTel SDK.
170
+
171
+ init() owns the global provider, so it exports every span (unlike ratel_span_processor,
172
+ whose default gen_ai.*/ratel.* filter exists for sharing a provider). It raises — pointing
173
+ at ratel_span_processor — rather than silently no-op'ing, if a provider is already
174
+ registered globally. Needs the [otlp] extra; raises a clear error otherwise.
175
+ """
176
+ try:
177
+ from opentelemetry import trace
178
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
179
+ from opentelemetry.sdk.trace import TracerProvider
180
+ from opentelemetry.trace import ProxyTracerProvider
181
+ except ModuleNotFoundError as exc:
182
+ raise ModuleNotFoundError(_EXTRA_HINT) from exc
183
+ # Resolve first so a missing endpoint raises ValueError before the guard.
184
+ cfg = resolve_otlp_config(
185
+ api_key=api_key, endpoint=endpoint, headers=headers, service_name=service_name
186
+ )
187
+ # get_tracer_provider() returns the default ProxyTracerProvider until someone installs a
188
+ # real one; a non-proxy means another provider already owns the global.
189
+ if not isinstance(trace.get_tracer_provider(), ProxyTracerProvider):
190
+ raise RuntimeError(
191
+ "ratel telemetry init(): an OpenTelemetry TracerProvider is already registered "
192
+ "globally, so init() (the turnkey path that owns the provider) cannot take over. "
193
+ "To send Ratel telemetry alongside an existing provider (e.g. Langfuse + the Vercel "
194
+ "AI SDK), add ratel_span_processor(api_key=...) to that provider instead of init()."
195
+ )
196
+ provider = TracerProvider(resource=Resource.create({SERVICE_NAME: cfg.service_name}))
197
+ provider.add_span_processor(
198
+ ratel_span_processor(
199
+ api_key=api_key, endpoint=endpoint, headers=headers, span_filter=_accept_all_spans
200
+ )
201
+ )
202
+ trace.set_tracer_provider(provider)
203
+ return provider
204
+
205
+
206
+ class ContentCapture(str, Enum):
207
+ """Message/tool content capture modes for
208
+ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (CONVENTIONS.md § Capture
209
+ gating). Default off.
210
+ """
211
+
212
+ NO_CONTENT = "NO_CONTENT"
213
+ SPAN_ONLY = "SPAN_ONLY"
214
+ EVENT_ONLY = "EVENT_ONLY"
215
+ SPAN_AND_EVENT = "SPAN_AND_EVENT"
216
+
217
+
218
+ def content_capture_mode(env: Mapping[str, str] | None = None) -> ContentCapture:
219
+ """Parse the ecosystem content-capture gate. Defaults to NO_CONTENT when
220
+ unset/empty/unrecognized. The legacy boolean form maps true to full capture
221
+ (SPAN_AND_EVENT) and false to none.
222
+ """
223
+ resolved_env = os.environ if env is None else env
224
+ raw = resolved_env.get(CAPTURE_CONTENT_ENV)
225
+ if raw is None or raw.strip() == "":
226
+ return ContentCapture.NO_CONTENT
227
+ mapping = {
228
+ "NO_CONTENT": ContentCapture.NO_CONTENT,
229
+ "SPAN_ONLY": ContentCapture.SPAN_ONLY,
230
+ "EVENT_ONLY": ContentCapture.EVENT_ONLY,
231
+ "SPAN_AND_EVENT": ContentCapture.SPAN_AND_EVENT,
232
+ "TRUE": ContentCapture.SPAN_AND_EVENT,
233
+ "1": ContentCapture.SPAN_AND_EVENT,
234
+ }
235
+ return mapping.get(raw.strip().upper(), ContentCapture.NO_CONTENT)
File without changes
@@ -0,0 +1,22 @@
1
+ """Shared test fixtures for the telemetry helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from opentelemetry import trace as trace_api
7
+ from opentelemetry.util._once import Once
8
+
9
+
10
+ def _reset_trace_globals() -> None:
11
+ # init()'s loud-fail guard throws when a provider is already registered, and OTel's
12
+ # global tracer provider is process-wide set-once — so reset it around every test to
13
+ # keep cases (and other test modules) from leaking a provider into each other.
14
+ trace_api._TRACER_PROVIDER_SET_ONCE = Once()
15
+ trace_api._TRACER_PROVIDER = None
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def reset_trace_provider() -> object:
20
+ _reset_trace_globals()
21
+ yield
22
+ _reset_trace_globals()
@@ -0,0 +1,108 @@
1
+ """Contract-against-the-pin conformance, driven by the shared ../../conformance/fixtures.json.
2
+
3
+ Each fixture is built into a span from this helper's own ratel.* constants through the real
4
+ OTel SDK; the emitted span must match the fixture's expected wire name + attributes exactly.
5
+ The same fixtures drive the TS helper, so the two cannot drift.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import pytest
15
+ from opentelemetry.sdk.trace import TracerProvider
16
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
17
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
18
+
19
+ from ratel_ai_telemetry import (
20
+ EXECUTE_TOOL,
21
+ GEN_AI_INFERENCE_DETAILS,
22
+ GEN_AI_OPERATION_NAME,
23
+ GEN_AI_TOOL_CALL_ARGUMENTS,
24
+ GEN_AI_TOOL_CALL_ID,
25
+ GEN_AI_TOOL_CALL_RESULT,
26
+ GEN_AI_TOOL_NAME,
27
+ RATEL_AUTH_FLOW,
28
+ RATEL_AUTH_OUTCOME,
29
+ RATEL_ORIGIN,
30
+ RATEL_SEARCH,
31
+ RATEL_SEARCH_HIT_COUNT,
32
+ RATEL_SEARCH_QUERY,
33
+ RATEL_SEARCH_RESULTS,
34
+ RATEL_SEARCH_TARGET,
35
+ RATEL_SEARCH_TOP_K,
36
+ RATEL_SKILL_ID,
37
+ RATEL_SKILL_LOAD,
38
+ RATEL_TOOL_ARGS_SIZE_BYTES,
39
+ RATEL_UPSTREAM_REGISTER,
40
+ RATEL_UPSTREAM_SERVER,
41
+ RATEL_UPSTREAM_TOOL_COUNT,
42
+ RATEL_UPSTREAM_TRANSPORT,
43
+ SEMCONV_VERSION,
44
+ )
45
+
46
+ _FIXTURES = json.loads(
47
+ (Path(__file__).resolve().parents[2] / "conformance" / "fixtures.json").read_text()
48
+ )
49
+
50
+ # Logical span id -> the span-name constant under test.
51
+ SPAN_NAME = {
52
+ "execute_tool": EXECUTE_TOOL,
53
+ "ratel_search": RATEL_SEARCH,
54
+ "ratel_skill_load": RATEL_SKILL_LOAD,
55
+ "ratel_upstream_register": RATEL_UPSTREAM_REGISTER,
56
+ "ratel_auth_flow": RATEL_AUTH_FLOW,
57
+ }
58
+
59
+ # Logical attribute id -> the attribute-key constant under test.
60
+ ATTR_KEY = {
61
+ "gen_ai_operation_name": GEN_AI_OPERATION_NAME,
62
+ "gen_ai_tool_name": GEN_AI_TOOL_NAME,
63
+ "gen_ai_tool_call_id": GEN_AI_TOOL_CALL_ID,
64
+ "gen_ai_tool_call_arguments": GEN_AI_TOOL_CALL_ARGUMENTS,
65
+ "gen_ai_tool_call_result": GEN_AI_TOOL_CALL_RESULT,
66
+ "ratel_origin": RATEL_ORIGIN,
67
+ "ratel_tool_args_size_bytes": RATEL_TOOL_ARGS_SIZE_BYTES,
68
+ "ratel_upstream_server": RATEL_UPSTREAM_SERVER,
69
+ "ratel_search_target": RATEL_SEARCH_TARGET,
70
+ "ratel_search_top_k": RATEL_SEARCH_TOP_K,
71
+ "ratel_search_hit_count": RATEL_SEARCH_HIT_COUNT,
72
+ "ratel_search_query": RATEL_SEARCH_QUERY,
73
+ "ratel_skill_id": RATEL_SKILL_ID,
74
+ "ratel_upstream_transport": RATEL_UPSTREAM_TRANSPORT,
75
+ "ratel_upstream_tool_count": RATEL_UPSTREAM_TOOL_COUNT,
76
+ "ratel_auth_outcome": RATEL_AUTH_OUTCOME,
77
+ }
78
+
79
+ # Logical event id -> the event-name constant under test.
80
+ EVENT_NAME = {
81
+ "ratel_search_results": RATEL_SEARCH_RESULTS,
82
+ "gen_ai_inference_details": GEN_AI_INFERENCE_DETAILS,
83
+ }
84
+
85
+
86
+ def test_fixtures_share_the_pinned_semconv_version() -> None:
87
+ assert _FIXTURES["semconv_version"] == SEMCONV_VERSION
88
+
89
+
90
+ @pytest.mark.parametrize("fixture", _FIXTURES["fixtures"], ids=lambda f: f["name"])
91
+ def test_fixture_emits_pinned_keys(fixture: dict[str, Any]) -> None:
92
+ exporter = InMemorySpanExporter()
93
+ provider = TracerProvider()
94
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
95
+ tracer = provider.get_tracer("conformance")
96
+
97
+ span = tracer.start_span(SPAN_NAME[fixture["span"]])
98
+ for field, value in fixture["set"].items():
99
+ span.set_attribute(ATTR_KEY[field], value)
100
+ for event in fixture.get("add_events", []):
101
+ span.add_event(EVENT_NAME[event])
102
+ span.end()
103
+
104
+ emitted = exporter.get_finished_spans()
105
+ assert len(emitted) == 1
106
+ assert emitted[0].name == fixture["expect_name"]
107
+ assert dict(emitted[0].attributes or {}) == fixture["expect_attributes"]
108
+ assert [e.name for e in emitted[0].events] == fixture.get("expect_events", [])
@@ -0,0 +1,147 @@
1
+ """Contract-against-the-pin tests for the ratel.* telemetry vocabulary.
2
+
3
+ Each constant is asserted against the vocabulary pinned in ../CONVENTIONS.md.
4
+ """
5
+
6
+ from ratel_ai_telemetry import (
7
+ CAPTURE_CONTENT_ENV,
8
+ EXECUTE_TOOL,
9
+ GEN_AI_INFERENCE_DETAILS,
10
+ GEN_AI_OPERATION_NAME,
11
+ GEN_AI_TOOL_CALL_ARGUMENTS,
12
+ GEN_AI_TOOL_CALL_ID,
13
+ GEN_AI_TOOL_CALL_RESULT,
14
+ GEN_AI_TOOL_NAME,
15
+ RATEL_AUTH_FLOW,
16
+ RATEL_AUTH_OUTCOME,
17
+ RATEL_ORIGIN,
18
+ RATEL_SEARCH,
19
+ RATEL_SEARCH_HIT_COUNT,
20
+ RATEL_SEARCH_QUERY,
21
+ RATEL_SEARCH_RESULTS,
22
+ RATEL_SEARCH_TARGET,
23
+ RATEL_SEARCH_TOP_K,
24
+ RATEL_SKILL_ID,
25
+ RATEL_SKILL_LOAD,
26
+ RATEL_TOOL_ARGS_SIZE_BYTES,
27
+ RATEL_UPSTREAM_REGISTER,
28
+ RATEL_UPSTREAM_SERVER,
29
+ RATEL_UPSTREAM_TOOL_COUNT,
30
+ RATEL_UPSTREAM_TRANSPORT,
31
+ SEMCONV_VERSION,
32
+ AuthOutcome,
33
+ Origin,
34
+ SearchTarget,
35
+ )
36
+
37
+
38
+ def test_pins_the_otel_gen_ai_semconv_version() -> None:
39
+ assert SEMCONV_VERSION == "1.42.0"
40
+
41
+
42
+ def test_gates_content_capture_on_the_ecosystem_env_var() -> None:
43
+ assert CAPTURE_CONTENT_ENV == "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
44
+
45
+
46
+ def test_names_the_ratel_spans_per_the_pin() -> None:
47
+ assert RATEL_SEARCH == "ratel.search"
48
+ assert RATEL_SKILL_LOAD == "ratel.skill.load"
49
+ assert RATEL_UPSTREAM_REGISTER == "ratel.upstream.register"
50
+ assert RATEL_AUTH_FLOW == "ratel.auth.flow"
51
+
52
+
53
+ def test_names_the_span_events_per_the_pin() -> None:
54
+ assert RATEL_SEARCH_RESULTS == "ratel.search.results"
55
+ assert GEN_AI_INFERENCE_DETAILS == "gen_ai.client.inference.operation.details"
56
+
57
+
58
+ def test_tool_invocation_is_gen_ai_execute_tool_not_ratel_invoke() -> None:
59
+ assert EXECUTE_TOOL == "execute_tool"
60
+ assert EXECUTE_TOOL != "ratel.invoke"
61
+
62
+
63
+ def test_ratel_attribute_keys_match_the_pin() -> None:
64
+ assert RATEL_ORIGIN == "ratel.origin"
65
+ assert RATEL_SEARCH_TARGET == "ratel.search.target"
66
+ assert RATEL_SEARCH_TOP_K == "ratel.search.top_k"
67
+ assert RATEL_SEARCH_HIT_COUNT == "ratel.search.hit_count"
68
+ assert RATEL_SEARCH_QUERY == "ratel.search.query"
69
+ assert RATEL_TOOL_ARGS_SIZE_BYTES == "ratel.tool.args_size_bytes"
70
+ assert RATEL_UPSTREAM_SERVER == "ratel.upstream.server"
71
+ assert RATEL_UPSTREAM_TRANSPORT == "ratel.upstream.transport"
72
+ assert RATEL_UPSTREAM_TOOL_COUNT == "ratel.upstream.tool_count"
73
+ assert RATEL_SKILL_ID == "ratel.skill.id"
74
+ assert RATEL_AUTH_OUTCOME == "ratel.auth.outcome"
75
+
76
+
77
+ def test_gen_ai_interop_keys_stay_under_gen_ai_never_renamed_into_ratel() -> None:
78
+ assert GEN_AI_OPERATION_NAME == "gen_ai.operation.name"
79
+ assert GEN_AI_TOOL_NAME == "gen_ai.tool.name"
80
+ assert GEN_AI_TOOL_CALL_ID == "gen_ai.tool.call.id"
81
+ assert GEN_AI_TOOL_CALL_ARGUMENTS == "gen_ai.tool.call.arguments"
82
+ assert GEN_AI_TOOL_CALL_RESULT == "gen_ai.tool.call.result"
83
+ for key in (
84
+ GEN_AI_OPERATION_NAME,
85
+ GEN_AI_TOOL_NAME,
86
+ GEN_AI_TOOL_CALL_ID,
87
+ GEN_AI_TOOL_CALL_ARGUMENTS,
88
+ GEN_AI_TOOL_CALL_RESULT,
89
+ ):
90
+ assert key.startswith("gen_ai.")
91
+ assert not key.startswith("ratel.")
92
+
93
+
94
+ def test_every_ratel_attribute_key_is_namespaced() -> None:
95
+ for key in (
96
+ RATEL_ORIGIN,
97
+ RATEL_SEARCH_TARGET,
98
+ RATEL_SEARCH_TOP_K,
99
+ RATEL_SEARCH_HIT_COUNT,
100
+ RATEL_SEARCH_QUERY,
101
+ RATEL_TOOL_ARGS_SIZE_BYTES,
102
+ RATEL_UPSTREAM_SERVER,
103
+ RATEL_UPSTREAM_TRANSPORT,
104
+ RATEL_UPSTREAM_TOOL_COUNT,
105
+ RATEL_SKILL_ID,
106
+ RATEL_AUTH_OUTCOME,
107
+ ):
108
+ assert key.startswith("ratel.")
109
+
110
+
111
+ def test_attribute_keys_are_unique() -> None:
112
+ keys = [
113
+ RATEL_ORIGIN,
114
+ RATEL_SEARCH_TARGET,
115
+ RATEL_SEARCH_TOP_K,
116
+ RATEL_SEARCH_HIT_COUNT,
117
+ RATEL_SEARCH_QUERY,
118
+ RATEL_TOOL_ARGS_SIZE_BYTES,
119
+ RATEL_UPSTREAM_SERVER,
120
+ RATEL_UPSTREAM_TRANSPORT,
121
+ RATEL_UPSTREAM_TOOL_COUNT,
122
+ RATEL_SKILL_ID,
123
+ RATEL_AUTH_OUTCOME,
124
+ GEN_AI_OPERATION_NAME,
125
+ GEN_AI_TOOL_NAME,
126
+ GEN_AI_TOOL_CALL_ID,
127
+ GEN_AI_TOOL_CALL_ARGUMENTS,
128
+ GEN_AI_TOOL_CALL_RESULT,
129
+ ]
130
+ assert len(set(keys)) == len(keys)
131
+
132
+
133
+ def test_origin_maps_to_wire_strings() -> None:
134
+ assert Origin.DIRECT == "direct"
135
+ assert Origin.AGENT == "agent"
136
+
137
+
138
+ def test_search_target_maps_to_wire_strings() -> None:
139
+ assert SearchTarget.TOOL == "tool"
140
+ assert SearchTarget.SKILL == "skill"
141
+
142
+
143
+ def test_auth_outcome_maps_to_wire_strings() -> None:
144
+ assert AuthOutcome.OK == "ok"
145
+ assert AuthOutcome.REFRESHED == "refreshed"
146
+ assert AuthOutcome.NEEDS_AUTH == "needs_auth"
147
+ assert AuthOutcome.FAILED == "failed"
@@ -0,0 +1,24 @@
1
+ """Regression guard for the tree-shakeable split (ADR-0007).
2
+
3
+ Importing the constants must not pull the OpenTelemetry SDK, so the SDK (emit
4
+ side), the server (read side), and edge/serverless emitters take the `ratel.*`
5
+ vocabulary weight-free. `init()` lives in the opt-in `ratel_ai_telemetry.otlp`
6
+ submodule behind the `[otlp]` extra; plain `import ratel_ai_telemetry` stays clean.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ import sys
13
+
14
+
15
+ def test_importing_the_package_pulls_no_opentelemetry() -> None:
16
+ # A fresh interpreter, so OTel imported by other test modules can't leak in.
17
+ code = (
18
+ "import sys, ratel_ai_telemetry\n"
19
+ "otel = sorted(m for m in sys.modules "
20
+ "if m == 'opentelemetry' or m.startswith('opentelemetry.'))\n"
21
+ "assert not otel, f'constants import pulled OTel: {otel}'\n"
22
+ )
23
+ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
24
+ assert result.returncode == 0, result.stderr
@@ -0,0 +1,104 @@
1
+ """Tests for the init() OTLP builder and its content-capture gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from ratel_ai_telemetry.otlp import (
8
+ DEFAULT_SERVICE_NAME,
9
+ ENDPOINT_ENV,
10
+ ContentCapture,
11
+ content_capture_mode,
12
+ init,
13
+ resolve_otlp_config,
14
+ )
15
+
16
+ CONTENT_ENV = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
17
+
18
+
19
+ class TestResolveOtlpConfig:
20
+ def test_api_key_form_uses_ratel_url_and_bearer_and_default_service(self) -> None:
21
+ cfg = resolve_otlp_config(
22
+ api_key="secret",
23
+ env={ENDPOINT_ENV: "https://collector.ratel.sh/v1/traces"},
24
+ )
25
+ assert cfg.url == "https://collector.ratel.sh/v1/traces"
26
+ assert cfg.headers["Authorization"] == "Bearer secret"
27
+ assert cfg.service_name == DEFAULT_SERVICE_NAME
28
+
29
+ def test_endpoint_headers_form_is_verbatim_without_authorization(self) -> None:
30
+ cfg = resolve_otlp_config(
31
+ endpoint="http://localhost:4318/v1/traces",
32
+ headers={"x-custom": "1"},
33
+ env={},
34
+ )
35
+ assert cfg.url == "http://localhost:4318/v1/traces"
36
+ assert cfg.headers == {"x-custom": "1"}
37
+ assert "Authorization" not in cfg.headers
38
+
39
+ def test_explicit_endpoint_wins_over_ratel_url(self) -> None:
40
+ cfg = resolve_otlp_config(
41
+ endpoint="https://explicit/v1/traces",
42
+ api_key="k",
43
+ env={ENDPOINT_ENV: "https://env/v1/traces"},
44
+ )
45
+ assert cfg.url == "https://explicit/v1/traces"
46
+ assert cfg.headers["Authorization"] == "Bearer k"
47
+
48
+ def test_custom_service_name_is_respected(self) -> None:
49
+ cfg = resolve_otlp_config(
50
+ endpoint="https://x/v1/traces", service_name="my-agent", env={}
51
+ )
52
+ assert cfg.service_name == "my-agent"
53
+
54
+ def test_raises_when_no_endpoint_and_no_ratel_url(self) -> None:
55
+ with pytest.raises(ValueError, match=ENDPOINT_ENV):
56
+ resolve_otlp_config(api_key="k", env={})
57
+
58
+
59
+ class TestContentCaptureMode:
60
+ def test_defaults_to_no_content_when_unset_or_empty(self) -> None:
61
+ assert content_capture_mode(env={}) == ContentCapture.NO_CONTENT
62
+ assert content_capture_mode(env={CONTENT_ENV: ""}) == ContentCapture.NO_CONTENT
63
+
64
+ def test_parses_each_enum_value_case_insensitively(self) -> None:
65
+ assert content_capture_mode(env={CONTENT_ENV: "NO_CONTENT"}) == ContentCapture.NO_CONTENT
66
+ assert content_capture_mode(env={CONTENT_ENV: "span_only"}) == ContentCapture.SPAN_ONLY
67
+ assert content_capture_mode(env={CONTENT_ENV: "Event_Only"}) == ContentCapture.EVENT_ONLY
68
+ assert (
69
+ content_capture_mode(env={CONTENT_ENV: "SPAN_AND_EVENT"})
70
+ == ContentCapture.SPAN_AND_EVENT
71
+ )
72
+
73
+ def test_maps_the_legacy_boolean_form(self) -> None:
74
+ assert content_capture_mode(env={CONTENT_ENV: "true"}) == ContentCapture.SPAN_AND_EVENT
75
+ assert content_capture_mode(env={CONTENT_ENV: "false"}) == ContentCapture.NO_CONTENT
76
+
77
+
78
+ class TestInit:
79
+ def test_returns_a_provider_with_shutdown(self) -> None:
80
+ provider = init(
81
+ api_key="k",
82
+ endpoint="http://localhost:4318/v1/traces",
83
+ service_name="test",
84
+ )
85
+ try:
86
+ assert callable(provider.shutdown)
87
+ finally:
88
+ provider.shutdown()
89
+
90
+ def test_raises_on_misconfiguration(self, monkeypatch: pytest.MonkeyPatch) -> None:
91
+ monkeypatch.delenv(ENDPOINT_ENV, raising=False)
92
+ with pytest.raises(ValueError, match=ENDPOINT_ENV):
93
+ init(api_key="k")
94
+
95
+
96
+ def test_top_level_lazy_accessor_resolves_the_otlp_surface() -> None:
97
+ """`from ratel_ai_telemetry import init` still works via the module __getattr__,
98
+ resolving to the same object as the .otlp submodule (ADR-0007 back-compat)."""
99
+ import ratel_ai_telemetry
100
+ from ratel_ai_telemetry.otlp import init as otlp_init
101
+ from ratel_ai_telemetry.otlp import resolve_otlp_config as otlp_resolve
102
+
103
+ assert ratel_ai_telemetry.init is otlp_init
104
+ assert ratel_ai_telemetry.resolve_otlp_config is otlp_resolve
@@ -0,0 +1,94 @@
1
+ """Tests for the composable OTLP span-processor (coexistence with another provider)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+ from typing import Any
7
+
8
+ import pytest
9
+
10
+ from ratel_ai_telemetry.otlp import (
11
+ ENDPOINT_ENV,
12
+ init,
13
+ ratel_signal_filter,
14
+ ratel_span_exporter,
15
+ ratel_span_processor,
16
+ )
17
+
18
+ ENDPOINT = "http://localhost:4318/v1/traces"
19
+
20
+
21
+ def span(name: str, attributes: dict[str, Any] | None = None) -> Any:
22
+ # The filter reads only a span's name + attribute keys, so a minimal shape suffices.
23
+ return SimpleNamespace(name=name, attributes=attributes or {})
24
+
25
+
26
+ class TestRatelSignalFilter:
27
+ def test_forwards_ratel_named_spans(self) -> None:
28
+ assert ratel_signal_filter(span("ratel.search"))
29
+ assert ratel_signal_filter(span("ratel.skill.load"))
30
+
31
+ def test_forwards_spans_with_gen_ai_or_ratel_attributes(self) -> None:
32
+ assert ratel_signal_filter(span("execute_tool", {"gen_ai.operation.name": "execute_tool"}))
33
+ assert ratel_signal_filter(span("execute_tool", {"ratel.origin": "agent"}))
34
+ assert ratel_signal_filter(span("chat gpt-4o", {"gen_ai.request.model": "gpt-4o"}))
35
+
36
+ def test_drops_spans_without_the_signal(self) -> None:
37
+ assert not ratel_signal_filter(span("ai.generate_text", {"ai.model.id": "gpt-4o"}))
38
+ assert not ratel_signal_filter(span("GET /health"))
39
+
40
+
41
+ class TestRatelSpanExporter:
42
+ def test_builds_an_otlp_exporter(self) -> None:
43
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
44
+
45
+ assert isinstance(ratel_span_exporter(endpoint=ENDPOINT), OTLPSpanExporter)
46
+
47
+ def test_raises_without_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> None:
48
+ monkeypatch.delenv(ENDPOINT_ENV, raising=False)
49
+ with pytest.raises(ValueError, match=ENDPOINT_ENV):
50
+ ratel_span_exporter(api_key="k")
51
+
52
+
53
+ class TestRatelSpanProcessor:
54
+ def test_forwards_only_signal_bearing_spans_by_default(
55
+ self, monkeypatch: pytest.MonkeyPatch
56
+ ) -> None:
57
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
58
+
59
+ forwarded: list[str] = []
60
+ monkeypatch.setattr(BatchSpanProcessor, "on_end", lambda _self, s: forwarded.append(s.name))
61
+ proc = ratel_span_processor(endpoint=ENDPOINT)
62
+ proc.on_end(span("ratel.search"))
63
+ proc.on_end(span("execute_tool", {"gen_ai.operation.name": "execute_tool"}))
64
+ proc.on_end(span("ai.generate_text", {"ai.model.id": "gpt-4o"}))
65
+ proc.shutdown()
66
+ assert forwarded == ["ratel.search", "execute_tool"]
67
+
68
+ def test_respects_a_custom_filter(self, monkeypatch: pytest.MonkeyPatch) -> None:
69
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
70
+
71
+ forwarded: list[str] = []
72
+ monkeypatch.setattr(BatchSpanProcessor, "on_end", lambda _self, s: forwarded.append(s.name))
73
+ proc = ratel_span_processor(endpoint=ENDPOINT, span_filter=lambda _s: True)
74
+ proc.on_end(span("ai.generate_text", {"ai.model.id": "gpt-4o"}))
75
+ proc.shutdown()
76
+ assert forwarded == ["ai.generate_text"]
77
+
78
+
79
+ class TestInitGuard:
80
+ def test_raises_pointing_at_the_processor_when_a_provider_is_registered(self) -> None:
81
+ from opentelemetry import trace
82
+ from opentelemetry.sdk.trace import TracerProvider
83
+
84
+ trace.set_tracer_provider(TracerProvider())
85
+ with pytest.raises(RuntimeError, match="ratel_span_processor"):
86
+ init(endpoint=ENDPOINT)
87
+
88
+
89
+ def test_new_processor_surface_is_importable_top_level() -> None:
90
+ """The coexistence surface resolves through the lazy top-level accessor (ADR-0007)."""
91
+ import ratel_ai_telemetry
92
+ from ratel_ai_telemetry.otlp import ratel_span_processor as otlp_processor
93
+
94
+ assert ratel_ai_telemetry.ratel_span_processor is otlp_processor