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.
- ratel_ai_telemetry-0.1.0/.gitignore +8 -0
- ratel_ai_telemetry-0.1.0/CHANGELOG.md +24 -0
- ratel_ai_telemetry-0.1.0/LICENSE.md +21 -0
- ratel_ai_telemetry-0.1.0/PKG-INFO +143 -0
- ratel_ai_telemetry-0.1.0/README.md +94 -0
- ratel_ai_telemetry-0.1.0/pyproject.toml +68 -0
- ratel_ai_telemetry-0.1.0/ratel_ai_telemetry/__init__.py +229 -0
- ratel_ai_telemetry-0.1.0/ratel_ai_telemetry/otlp.py +235 -0
- ratel_ai_telemetry-0.1.0/ratel_ai_telemetry/py.typed +0 -0
- ratel_ai_telemetry-0.1.0/tests/conftest.py +22 -0
- ratel_ai_telemetry-0.1.0/tests/test_conformance.py +108 -0
- ratel_ai_telemetry-0.1.0/tests/test_constants.py +147 -0
- ratel_ai_telemetry-0.1.0/tests/test_import_purity.py +24 -0
- ratel_ai_telemetry-0.1.0/tests/test_init.py +104 -0
- ratel_ai_telemetry-0.1.0/tests/test_processor.py +94 -0
|
@@ -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
|