Skip to main content

ratel_ai_telemetry/
lib.rs

1//! `ratel-ai-telemetry` — the `ratel.*` telemetry vocabulary.
2//!
3//! See `README.md` and the wire contract in `../CONVENTIONS.md` for design.
4
5/// The pinned OpenTelemetry semantic-conventions version this vocabulary tracks
6/// (the `gen_ai` group). The pin is the contract; consumers read against this
7/// exact version, never "latest". Bumping it is a reviewed change with its own
8/// PR and, if the shape changed, a superseding ADR (CONVENTIONS.md § The pin).
9pub const SEMCONV_VERSION: &str = "1.42.0";
10
11/// The ecosystem instrumentation env var gating message/tool content capture.
12/// Default off; the standard OTel gen_ai gate rather than a Ratel-invented flag
13/// (CONVENTIONS.md § Capture gating). This crate is constants-only — the TS/Python
14/// `init()` helpers read it. Values: legacy boolean, or the enum `NO_CONTENT`
15/// (default) / `SPAN_ONLY` / `EVENT_ONLY` / `SPAN_AND_EVENT`.
16pub const CAPTURE_CONTENT_ENV: &str = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT";
17
18// ---------------------------------------------------------------------------
19// Span names (CONVENTIONS.md, Tier 2)
20// ---------------------------------------------------------------------------
21
22/// `ratel.search` — capability search (unifies tool-search and skill-search).
23pub const RATEL_SEARCH: &str = "ratel.search";
24/// `execute_tool` — the `gen_ai.operation.name` value for a tool invocation.
25///
26/// Deliberately the standard OTel `gen_ai` operation, not a bespoke
27/// `ratel.invoke` span, so a generic OTel backend already understands it
28/// (locked 2026-07-05). The invoke is enriched with `ratel.*` attributes.
29pub const EXECUTE_TOOL: &str = "execute_tool";
30/// `ratel.skill.load` — skill content load (`get_skill_content`).
31pub const RATEL_SKILL_LOAD: &str = "ratel.skill.load";
32/// `ratel.upstream.register` — upstream-MCP ingest.
33pub const RATEL_UPSTREAM_REGISTER: &str = "ratel.upstream.register";
34/// `ratel.auth.flow` — MCP auth flow.
35pub const RATEL_AUTH_FLOW: &str = "ratel.auth.flow";
36
37// ---------------------------------------------------------------------------
38// Span event names (CONVENTIONS.md)
39// ---------------------------------------------------------------------------
40
41/// `ratel.search.results` — Opt-In event carrying hit ids + scores + per-stage
42/// BM25 timing; gated like content. The `ratel.search` span itself carries only counts.
43pub const RATEL_SEARCH_RESULTS: &str = "ratel.search.results";
44/// `gen_ai.client.inference.operation.details` — the event that carries message
45/// text and tool-call content (never span attributes). Borrowed from gen_ai (Tier 1).
46pub const GEN_AI_INFERENCE_DETAILS: &str = "gen_ai.client.inference.operation.details";
47
48// ---------------------------------------------------------------------------
49// `ratel.*` attribute keys (CONVENTIONS.md, Tier 2)
50// ---------------------------------------------------------------------------
51
52/// `ratel.origin` — direct library call vs agent-synthesized (shared attribute).
53pub const RATEL_ORIGIN: &str = "ratel.origin";
54/// `ratel.search.target` — `tool` or `skill` (see [`SearchTarget`]).
55pub const RATEL_SEARCH_TARGET: &str = "ratel.search.target";
56/// `ratel.search.top_k` — requested result count.
57pub const RATEL_SEARCH_TOP_K: &str = "ratel.search.top_k";
58/// `ratel.search.hit_count` — results returned.
59pub const RATEL_SEARCH_HIT_COUNT: &str = "ratel.search.hit_count";
60/// `ratel.search.query` — the search text (content, gated like message content).
61pub const RATEL_SEARCH_QUERY: &str = "ratel.search.query";
62/// `ratel.tool.args_size_bytes` — argument payload size on the `execute_tool` span.
63pub const RATEL_TOOL_ARGS_SIZE_BYTES: &str = "ratel.tool.args_size_bytes";
64/// `ratel.upstream.server` — upstream MCP server backing a tool / auth flow.
65pub const RATEL_UPSTREAM_SERVER: &str = "ratel.upstream.server";
66/// `ratel.upstream.transport` — `stdio` / `http` / `sse` / ...
67pub const RATEL_UPSTREAM_TRANSPORT: &str = "ratel.upstream.transport";
68/// `ratel.upstream.tool_count` — tools ingested on register.
69pub const RATEL_UPSTREAM_TOOL_COUNT: &str = "ratel.upstream.tool_count";
70/// `ratel.skill.id` — skill loaded on the `ratel.skill.load` span.
71pub const RATEL_SKILL_ID: &str = "ratel.skill.id";
72/// `ratel.auth.outcome` — `ok` / `refreshed` / `needs_auth` / `failed` (see [`AuthOutcome`]).
73pub const RATEL_AUTH_OUTCOME: &str = "ratel.auth.outcome";
74
75// ---------------------------------------------------------------------------
76// `gen_ai.*` interop keys (CONVENTIONS.md, Tier 1 — borrowed verbatim)
77//
78// Only the subset the `ratel.*` overlay directly emits on the `execute_tool`
79// span. These are OpenTelemetry's, not ours: exposed so callers avoid
80// stringly-typing them, never renamed into `ratel.*`.
81// ---------------------------------------------------------------------------
82
83/// `gen_ai.operation.name` — set to [`EXECUTE_TOOL`] for a tool invocation.
84pub const GEN_AI_OPERATION_NAME: &str = "gen_ai.operation.name";
85/// `gen_ai.tool.name` — the capability tool id.
86pub const GEN_AI_TOOL_NAME: &str = "gen_ai.tool.name";
87/// `gen_ai.tool.call.id` — tool call id, when available.
88pub const GEN_AI_TOOL_CALL_ID: &str = "gen_ai.tool.call.id";
89/// `gen_ai.tool.call.arguments` — tool arguments (Opt-In content, gated).
90pub const GEN_AI_TOOL_CALL_ARGUMENTS: &str = "gen_ai.tool.call.arguments";
91/// `gen_ai.tool.call.result` — tool result (Opt-In content, gated).
92pub const GEN_AI_TOOL_CALL_RESULT: &str = "gen_ai.tool.call.result";
93
94/// Whether a `ratel.*` span was a direct library call or synthesized by the
95/// agent inside its loop. Emitted as the `ratel.origin` attribute; mirrors the
96/// local trace `Origin` (ADR-0007).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum Origin {
99    Direct,
100    Agent,
101}
102
103impl Origin {
104    /// The wire value carried by `ratel.origin`.
105    pub fn as_str(self) -> &'static str {
106        match self {
107            Origin::Direct => "direct",
108            Origin::Agent => "agent",
109        }
110    }
111}
112
113/// What a `ratel.search` span was searching. Emitted as `ratel.search.target`;
114/// folds capability-tool search and skill search into one span shape.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum SearchTarget {
117    Tool,
118    Skill,
119}
120
121impl SearchTarget {
122    /// The wire value carried by `ratel.search.target`.
123    pub fn as_str(self) -> &'static str {
124        match self {
125            SearchTarget::Tool => "tool",
126            SearchTarget::Skill => "skill",
127        }
128    }
129}
130
131/// Outcome of an MCP auth flow. Emitted as `ratel.auth.outcome`; `needs_auth`
132/// is the 401-driven `AuthNeeds` case (ADR-0007 `auth_needs`).
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum AuthOutcome {
135    Ok,
136    Refreshed,
137    NeedsAuth,
138    Failed,
139}
140
141impl AuthOutcome {
142    /// The wire value carried by `ratel.auth.outcome`.
143    pub fn as_str(self) -> &'static str {
144        match self {
145            AuthOutcome::Ok => "ok",
146            AuthOutcome::Refreshed => "refreshed",
147            AuthOutcome::NeedsAuth => "needs_auth",
148            AuthOutcome::Failed => "failed",
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn origin_maps_to_wire_strings() {
159        assert_eq!(Origin::Direct.as_str(), "direct");
160        assert_eq!(Origin::Agent.as_str(), "agent");
161    }
162
163    #[test]
164    fn search_target_maps_to_wire_strings() {
165        assert_eq!(SearchTarget::Tool.as_str(), "tool");
166        assert_eq!(SearchTarget::Skill.as_str(), "skill");
167    }
168
169    #[test]
170    fn auth_outcome_maps_to_wire_strings() {
171        assert_eq!(AuthOutcome::Ok.as_str(), "ok");
172        assert_eq!(AuthOutcome::Refreshed.as_str(), "refreshed");
173        assert_eq!(AuthOutcome::NeedsAuth.as_str(), "needs_auth");
174        assert_eq!(AuthOutcome::Failed.as_str(), "failed");
175    }
176
177    #[test]
178    fn ratel_attribute_keys_match_the_pin() {
179        assert_eq!(RATEL_ORIGIN, "ratel.origin");
180        assert_eq!(RATEL_SEARCH_TARGET, "ratel.search.target");
181        assert_eq!(RATEL_SEARCH_TOP_K, "ratel.search.top_k");
182        assert_eq!(RATEL_SEARCH_HIT_COUNT, "ratel.search.hit_count");
183        assert_eq!(RATEL_SEARCH_QUERY, "ratel.search.query");
184        assert_eq!(RATEL_TOOL_ARGS_SIZE_BYTES, "ratel.tool.args_size_bytes");
185        assert_eq!(RATEL_UPSTREAM_SERVER, "ratel.upstream.server");
186        assert_eq!(RATEL_UPSTREAM_TRANSPORT, "ratel.upstream.transport");
187        assert_eq!(RATEL_UPSTREAM_TOOL_COUNT, "ratel.upstream.tool_count");
188        assert_eq!(RATEL_SKILL_ID, "ratel.skill.id");
189        assert_eq!(RATEL_AUTH_OUTCOME, "ratel.auth.outcome");
190    }
191
192    #[test]
193    fn semconv_pin_is_explicit() {
194        // The pin IS the contract; consumers read against this exact version,
195        // not "latest". Bumping it is a reviewed change (CONVENTIONS.md § The pin).
196        assert_eq!(SEMCONV_VERSION, "1.42.0");
197    }
198
199    #[test]
200    fn content_capture_gate_is_the_ecosystem_env_var() {
201        assert_eq!(
202            CAPTURE_CONTENT_ENV,
203            "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
204        );
205    }
206
207    #[test]
208    fn span_event_names_match_the_pin() {
209        assert_eq!(RATEL_SEARCH_RESULTS, "ratel.search.results");
210        assert_eq!(
211            GEN_AI_INFERENCE_DETAILS,
212            "gen_ai.client.inference.operation.details"
213        );
214    }
215
216    #[test]
217    fn span_names_match_the_pin() {
218        assert_eq!(RATEL_SEARCH, "ratel.search");
219        assert_eq!(RATEL_SKILL_LOAD, "ratel.skill.load");
220        assert_eq!(RATEL_UPSTREAM_REGISTER, "ratel.upstream.register");
221        assert_eq!(RATEL_AUTH_FLOW, "ratel.auth.flow");
222    }
223
224    #[test]
225    fn tool_invocation_is_the_gen_ai_execute_tool_operation_not_ratel_invoke() {
226        // Locked 2026-07-05: invoke rides an `execute_tool` gen_ai span for
227        // OTel-backend interop, not a bespoke `ratel.invoke` span.
228        assert_eq!(EXECUTE_TOOL, "execute_tool");
229        assert_ne!(EXECUTE_TOOL, "ratel.invoke");
230    }
231
232    #[test]
233    fn gen_ai_interop_keys_match_the_pin() {
234        // Tier 1: borrowed verbatim from OTel gen_ai; the execute_tool overlay
235        // rides these, so they must stay under gen_ai.* (interop), never ratel.*.
236        assert_eq!(GEN_AI_OPERATION_NAME, "gen_ai.operation.name");
237        assert_eq!(GEN_AI_TOOL_NAME, "gen_ai.tool.name");
238        assert_eq!(GEN_AI_TOOL_CALL_ID, "gen_ai.tool.call.id");
239        assert_eq!(GEN_AI_TOOL_CALL_ARGUMENTS, "gen_ai.tool.call.arguments");
240        assert_eq!(GEN_AI_TOOL_CALL_RESULT, "gen_ai.tool.call.result");
241        for key in [
242            GEN_AI_OPERATION_NAME,
243            GEN_AI_TOOL_NAME,
244            GEN_AI_TOOL_CALL_ID,
245            GEN_AI_TOOL_CALL_ARGUMENTS,
246            GEN_AI_TOOL_CALL_RESULT,
247        ] {
248            assert!(key.starts_with("gen_ai."), "{key} is not under gen_ai.*");
249            assert!(
250                !key.starts_with("ratel."),
251                "{key} must not be renamed into ratel.*"
252            );
253        }
254    }
255
256    #[test]
257    fn every_ratel_attribute_key_is_namespaced() {
258        for key in [
259            RATEL_ORIGIN,
260            RATEL_SEARCH_TARGET,
261            RATEL_SEARCH_TOP_K,
262            RATEL_SEARCH_HIT_COUNT,
263            RATEL_SEARCH_QUERY,
264            RATEL_TOOL_ARGS_SIZE_BYTES,
265            RATEL_UPSTREAM_SERVER,
266            RATEL_UPSTREAM_TRANSPORT,
267            RATEL_UPSTREAM_TOOL_COUNT,
268            RATEL_SKILL_ID,
269            RATEL_AUTH_OUTCOME,
270        ] {
271            assert!(key.starts_with("ratel."), "{key} is not under ratel.*");
272        }
273    }
274
275    #[test]
276    fn attribute_keys_are_unique() {
277        // A copy-paste dup (two concepts sharing a wire key) passes every
278        // per-constant assert above; only a uniqueness check catches it.
279        let keys = [
280            RATEL_ORIGIN,
281            RATEL_SEARCH_TARGET,
282            RATEL_SEARCH_TOP_K,
283            RATEL_SEARCH_HIT_COUNT,
284            RATEL_SEARCH_QUERY,
285            RATEL_TOOL_ARGS_SIZE_BYTES,
286            RATEL_UPSTREAM_SERVER,
287            RATEL_UPSTREAM_TRANSPORT,
288            RATEL_UPSTREAM_TOOL_COUNT,
289            RATEL_SKILL_ID,
290            RATEL_AUTH_OUTCOME,
291            GEN_AI_OPERATION_NAME,
292            GEN_AI_TOOL_NAME,
293            GEN_AI_TOOL_CALL_ID,
294            GEN_AI_TOOL_CALL_ARGUMENTS,
295            GEN_AI_TOOL_CALL_RESULT,
296        ];
297        let unique: std::collections::HashSet<&str> = keys.iter().copied().collect();
298        assert_eq!(unique.len(), keys.len(), "duplicate attribute key");
299    }
300
301    #[test]
302    fn span_names_are_unique() {
303        // Same copy-paste risk as attribute_keys_are_unique, for the span names.
304        let names = [
305            RATEL_SEARCH,
306            EXECUTE_TOOL,
307            RATEL_SKILL_LOAD,
308            RATEL_UPSTREAM_REGISTER,
309            RATEL_AUTH_FLOW,
310        ];
311        let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
312        assert_eq!(unique.len(), names.len(), "duplicate span name");
313    }
314
315    #[test]
316    fn event_names_are_unique() {
317        // Same copy-paste risk for the two span-event names.
318        let names = [RATEL_SEARCH_RESULTS, GEN_AI_INFERENCE_DETAILS];
319        let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
320        assert_eq!(unique.len(), names.len(), "duplicate event name");
321    }
322}