ratel_ai_core/trace/event.rs
1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3
4use crate::{Fact, Skill, Tool};
5
6const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
7
8/// Catalog entry type carried by [`TraceEvent::CatalogDefinition`].
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CatalogKind {
12 /// An executable tool definition.
13 Tool,
14 /// An on-demand skill definition.
15 Skill,
16 /// A grounding fact definition.
17 Fact,
18}
19
20#[derive(Serialize)]
21struct CatalogDefinitionContent<'a> {
22 kind: CatalogKind,
23 id: &'a str,
24 name: &'a str,
25 description: &'a str,
26 tags: &'a [String],
27 input_schema: Option<&'a serde_json::Value>,
28 output_schema: Option<&'a serde_json::Value>,
29 searchable_description: &'a str,
30 searchable_description_overridden: bool,
31}
32
33/// Where a search came from. Trace consumers separate the paths: rerankers
34/// train on agent calls, the inspector shows all of them, and offline graph
35/// construction reads only the baseline ones.
36///
37/// **Non-exhaustive**, for the same reason [`TraceEvent`] is: the set of
38/// origins grows as Ratel learns to sit in more places, and a downstream
39/// `match` acquiring a `_ =>` arm once is cheaper than a breaking release per
40/// variant. Constructing existing variants is unaffected; only exhaustive
41/// matches need the arm.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum Origin {
46 /// A direct API call — SDK helpers, library callers, benchmarks. Wire
47 /// value `direct`.
48 Direct,
49 /// A call the agent synthesized inside its loop, via the capability
50 /// tools. Wire value `agent`.
51 Agent,
52 /// A query recorded while Ratel was **observing but not serving**: the host
53 /// captured the turn's text so the invocations that follow can be
54 /// attributed to it, while the agent chose from its own full tool list.
55 /// Wire value `baseline`.
56 ///
57 /// Ratel's own search path never produces this — it is written by a host
58 /// running a baseline capture, and it is what marks an observation as
59 /// unbiased evidence rather than something the ranker influenced.
60 Baseline,
61}
62
63/// How a registry corpus changed — carried by [`TraceEvent::IndexChurn`]
64/// (tools), [`TraceEvent::SkillChurn`] (skills), and [`TraceEvent::FactChurn`]
65/// (facts).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum ChurnKind {
69 /// An item was registered — including a replace-in-place re-register of
70 /// an existing id. Wire value `add`.
71 Add,
72 /// An item was removed from the corpus. Wire value `remove`.
73 Remove,
74}
75
76/// Outcome of the one-time embedding-model load. `Slow` flags a machine that may
77/// be underpowered for the model; `Failed` a load that errored (network, cache,
78/// corrupt weights).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum EmbedderLoadStatus {
82 /// The model loaded within the expected budget. Wire value `ok`.
83 Ok,
84 /// The model loaded, but slowly — the machine may be underpowered for it.
85 /// Wire value `slow`.
86 Slow,
87 /// The load errored (network, cache, corrupt weights); the accompanying
88 /// `reason` carries the error. Wire value `failed`.
89 Failed,
90}
91
92/// One ranked tool hit inside a [`TraceEvent::Search`] event.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct SearchHitTrace {
95 /// Id of the matching tool.
96 pub tool_id: String,
97 /// The engine score, widened to `f64` — same per-method semantics as
98 /// [`crate::SearchHit::score`].
99 pub score: f64,
100}
101
102/// Timing and top score of one engine stage of a search. BM25 searches emit
103/// one `bm25` stage, semantic searches one `dense` stage; hybrid emits
104/// `bm25`, `dense`, and `rrf`, in that order. Semantic and hybrid searches
105/// that short-circuit on an empty corpus or `top_k == 0` emit no stages.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct SearchStage {
108 /// Stage name: `"bm25"`, `"dense"`, or `"rrf"`.
109 pub name: String,
110 /// Stage wall time, in milliseconds.
111 pub took_ms: u64,
112 /// Best score the stage produced (that stage's scale); `None` when it
113 /// returned no hits.
114 pub top_score: Option<f64>,
115}
116
117/// One ranked skill hit inside a [`TraceEvent::SkillSearch`] event — the
118/// skill-side twin of [`SearchHitTrace`].
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct SkillHitTrace {
121 /// Id of the matching skill.
122 pub skill_id: String,
123 /// The engine score, widened to `f64` — same per-method semantics as
124 /// [`crate::SkillHit::score`].
125 pub score: f64,
126}
127
128/// One ranked fact hit inside a [`TraceEvent::FactSearch`] event — the
129/// fact-side twin of [`SkillHitTrace`].
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct FactHitTrace {
132 /// Id of the matching fact.
133 pub fact_id: String,
134 /// The engine score, widened to `f64` — same per-method semantics as
135 /// [`crate::FactHit::score`].
136 pub score: f64,
137}
138
139/// Why a fact's body was (re-)injected into the context, carried by
140/// [`TraceEvent::FactInject`]. The grounding layer decides this by scanning
141/// the transcript for the fact's own body text (content presence); it is the
142/// observable half of the re-injection freshness gate.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum FactInjectReason {
146 /// Not present in the transcript and never injected this session — a
147 /// first injection. Wire value `never`.
148 Never,
149 /// Injected earlier but its body is gone from the window now (trimmed /
150 /// compacted out), so it is re-injected. Wire value `evicted`.
151 Evicted,
152 /// The registered body changed since it was injected (the current body is
153 /// absent and differs from the one last injected), so the new version is
154 /// injected. Wire value `mutated`.
155 Mutated,
156}
157
158/// Every event produced by any layer of Ratel. New variants are additive;
159/// renames or removals are breaking — see ADR-0007.
160///
161/// On the wire each event is a JSON object whose `type` tag is the variant
162/// name in snake_case (`IndexChurn` → `index_churn`), with the variant's
163/// fields flattened beside it; sinks wrap it in a [`TraceEnvelope`]. All
164/// `took_ms` fields are wall time in milliseconds.
165///
166/// `#[non_exhaustive]` is what makes "new variants are additive" *true* rather
167/// than aspirational: it requires downstream `match`es to carry a `_ =>` arm, so
168/// a future event variant lands there instead of breaking their compile. Two
169/// axes, only the first mechanical:
170///
171/// - **New variant** → non-breaking, enforced here.
172/// - **New field on an existing variant** → non-breaking only if consumers
173/// destructure with a trailing `..` (as this crate always does); variant-level
174/// non-exhaustiveness is intentionally *not* used, since it would also block
175/// downstream from constructing events by literal.
176///
177/// Renames and removals are breaking on both axes.
178///
179/// ```
180/// use ratel_ai_core::TraceEvent;
181/// // A downstream matcher must include `_ =>`, and is then future-proof:
182/// fn kind(e: &TraceEvent) -> &str {
183/// match e {
184/// TraceEvent::Search { .. } => "search",
185/// _ => "other",
186/// }
187/// }
188/// ```
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191#[non_exhaustive]
192pub enum TraceEvent {
193 /// A catalog definition was registered or materially changed. Content is
194 /// present on the local stream; SDKs project it to the opt-in
195 /// `ratel.catalog.definition` Logs EventRecord.
196 CatalogDefinition {
197 /// Catalog entry type.
198 kind: CatalogKind,
199 /// Stable catalog entry id.
200 id: String,
201 /// Model-facing display/callable name.
202 name: String,
203 /// Model-facing description.
204 description: String,
205 /// Search tags; empty for tools.
206 tags: Vec<String>,
207 /// Tool input JSON Schema; absent for skills and facts.
208 input_schema: Option<Box<serde_json::Value>>,
209 /// Tool output JSON Schema; absent for skills and facts.
210 output_schema: Option<Box<serde_json::Value>>,
211 /// Effective searchable description after applying the optional override.
212 searchable_description: String,
213 /// Whether the effective searchable description came from an override.
214 searchable_description_overridden: bool,
215 /// Lowercase SHA-256 of the definition fields above.
216 content_hash: String,
217 },
218 /// A [`crate::ToolRegistry`] search completed (any [`crate::SearchMethod`]).
219 /// Carries the query, the requested `top_k`, the ranked `hits` with
220 /// scores, the per-engine `stages` timings, and the total wall time.
221 Search {
222 /// The search text.
223 query: String,
224 /// Direct library call vs agent-synthesized.
225 origin: Origin,
226 /// Requested result count.
227 top_k: u32,
228 /// The ranked results, best-first.
229 hits: Vec<SearchHitTrace>,
230 /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
231 stages: Vec<SearchStage>,
232 /// Total search wall time, in milliseconds.
233 took_ms: u64,
234 },
235 /// The tool corpus changed: [`crate::ToolRegistry::register`] emits this
236 /// with [`ChurnKind::Add`] for both a fresh registration and a
237 /// replace-in-place re-register.
238 IndexChurn {
239 /// Whether the id was added or removed.
240 kind: ChurnKind,
241 /// Id of the affected tool.
242 tool_id: String,
243 },
244 /// A [`crate::SkillRegistry`] search completed — the skill-side twin of
245 /// [`TraceEvent::Search`], with the same shape.
246 SkillSearch {
247 /// The search text.
248 query: String,
249 /// Direct library call vs agent-synthesized.
250 origin: Origin,
251 /// Requested result count.
252 top_k: u32,
253 /// The ranked results, best-first.
254 hits: Vec<SkillHitTrace>,
255 /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
256 stages: Vec<SearchStage>,
257 /// Total search wall time, in milliseconds.
258 took_ms: u64,
259 },
260 /// The skill corpus changed — the skill-side twin of
261 /// [`TraceEvent::IndexChurn`]. [`crate::SkillRegistry::register`] emits
262 /// [`ChurnKind::Add`] only; [`crate::SkillRegistry::replace_all`] emits
263 /// either kind, and is the only source of [`ChurnKind::Remove`] for skills.
264 SkillChurn {
265 /// Whether the id was added or removed.
266 kind: ChurnKind,
267 /// Id of the affected skill.
268 skill_id: String,
269 },
270 /// A skill's body was loaded for dispatch (the `get_skill_content` path).
271 /// Emitted by the SDK skill catalogs via
272 /// [`crate::SkillRegistry::record_event`].
273 SkillInvoke {
274 /// Id of the loaded skill.
275 skill_id: String,
276 /// Load wall time, in milliseconds.
277 took_ms: u64,
278 },
279 /// A [`crate::FactRegistry`] search completed — the fact-side twin of
280 /// [`TraceEvent::SkillSearch`], with the same shape.
281 FactSearch {
282 /// The search text.
283 query: String,
284 /// Direct library call vs agent-synthesized.
285 origin: Origin,
286 /// Requested result count.
287 top_k: u32,
288 /// The ranked results, best-first.
289 hits: Vec<FactHitTrace>,
290 /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
291 stages: Vec<SearchStage>,
292 /// Total search wall time, in milliseconds.
293 took_ms: u64,
294 },
295 /// The fact corpus changed — the fact-side twin of
296 /// [`TraceEvent::SkillChurn`], emitted by [`crate::FactRegistry::register`].
297 FactChurn {
298 /// Whether the id was added or removed.
299 kind: ChurnKind,
300 /// Id of the affected fact.
301 fact_id: String,
302 },
303 /// A fact's body was injected into the context by the grounding layer.
304 /// Emitted by the SDK via [`crate::FactRegistry::record_event`]; `reason`
305 /// records why the re-injection freshness gate let it through.
306 FactInject {
307 /// Id of the injected fact.
308 fact_id: String,
309 /// Why it was (re-)injected this turn.
310 reason: FactInjectReason,
311 },
312 /// A fact was *not* re-injected because it is still fresh in the context —
313 /// the token-saving half of the freshness gate, surfaced so the saving is
314 /// observable. Emitted by the SDK via
315 /// [`crate::FactRegistry::record_event`].
316 FactInjectSkip {
317 /// Id of the fact that was already present and left alone.
318 fact_id: String,
319 },
320 /// A fact rode along in a per-call grounding snapshot — the stateless
321 /// `groundSnapshot` path: recomputed each call, nothing persisted, no
322 /// freshness gate. The per-call twin of [`TraceEvent::FactInject`], emitted
323 /// by the SDK via [`crate::FactRegistry::record_event`] once per fact per
324 /// snapshot.
325 FactSnapshot {
326 /// Id of the fact included in the snapshot.
327 fact_id: String,
328 },
329 /// A tool invocation began. Emitted by the SDK catalogs just before the
330 /// tool's executor runs; paired with [`TraceEvent::InvokeEnd`] or
331 /// [`TraceEvent::InvokeError`].
332 InvokeStart {
333 /// Id of the invoked tool.
334 tool_id: String,
335 /// Size of the serialized argument payload, in bytes.
336 args_size_bytes: u64,
337 },
338 /// A tool invocation completed successfully.
339 InvokeEnd {
340 /// Id of the invoked tool.
341 tool_id: String,
342 /// Invocation wall time, in milliseconds.
343 took_ms: u64,
344 },
345 /// A tool invocation failed; `error` carries the executor's message.
346 InvokeError {
347 /// Id of the invoked tool.
348 tool_id: String,
349 /// Wall time until the failure, in milliseconds.
350 took_ms: u64,
351 /// The failure message.
352 error: String,
353 },
354 /// The agent searched the catalog through the capability tools
355 /// (`search_capabilities`, or the deprecated `search_tools`). Carries only
356 /// the hit *count*; the ranked list with scores is on the underlying
357 /// [`TraceEvent::Search`] / [`TraceEvent::SkillSearch`] the registries
358 /// emit for the same call. The `gateway_*` wire prefix is frozen
359 /// (ADR-0007: renames are breaking).
360 GatewaySearch {
361 /// The search text.
362 query: String,
363 /// Direct library call vs agent-synthesized.
364 origin: Origin,
365 /// Requested result count.
366 top_k: u32,
367 /// Number of results returned.
368 hits: u32,
369 /// Total search wall time, in milliseconds.
370 took_ms: u64,
371 },
372 /// The agent invoked a tool through the `invoke_tool` capability tool and
373 /// it succeeded.
374 GatewayInvoke {
375 /// Id of the invoked tool.
376 tool_id: String,
377 /// Invocation wall time, in milliseconds.
378 took_ms: u64,
379 },
380 /// A capability-tool call failed: an unknown tool/skill id, an executor
381 /// error, or an upstream that needs auth.
382 GatewayError {
383 /// Id of the tool (or skill) the call named.
384 tool_id: String,
385 /// The failure message (e.g. `needs_auth`).
386 error: String,
387 },
388 /// An upstream MCP server's tools were ingested into the catalog
389 /// (the SDK's `register_mcp_server`).
390 UpstreamRegister {
391 /// Upstream server name.
392 server: String,
393 /// Transport used to reach it (e.g. `stdio` / `http` / `sse`).
394 transport: String,
395 /// Number of tools ingested.
396 tool_count: u32,
397 },
398 /// A proxied call to a tool backed by an upstream MCP server completed.
399 UpstreamInvoke {
400 /// Upstream server name.
401 server: String,
402 /// Id of the invoked tool.
403 tool_id: String,
404 /// Invocation wall time, in milliseconds.
405 took_ms: u64,
406 },
407 /// A proxied upstream call failed; `error` carries the upstream's message.
408 UpstreamError {
409 /// Upstream server name.
410 server: String,
411 /// Id of the invoked tool.
412 tool_id: String,
413 /// The failure message.
414 error: String,
415 },
416 /// A credential refresh for an upstream MCP server was attempted.
417 AuthRefresh {
418 /// Upstream server name.
419 upstream: String,
420 /// Whether the refresh produced valid credentials.
421 ok: bool,
422 },
423 /// An upstream MCP server challenged for auth (e.g. a 401): user
424 /// interaction is required before its tools work.
425 AuthNeeds {
426 /// Upstream server name.
427 upstream: String,
428 },
429 /// An interactive auth flow (e.g. OAuth) started for an upstream MCP
430 /// server; paired with [`TraceEvent::AuthFlowEnd`].
431 AuthFlowStart {
432 /// Upstream server name.
433 upstream: String,
434 },
435 /// The interactive auth flow ended.
436 AuthFlowEnd {
437 /// Upstream server name.
438 upstream: String,
439 /// Whether the flow produced valid credentials.
440 ok: bool,
441 },
442 /// One fan-out subscriber lost events because its bounded queue overflowed.
443 EventsDropped {
444 /// Number of events dropped during this observation window.
445 dropped_count: u64,
446 /// Stable machine-readable loss reason; currently `queue_overflow`.
447 reason: String,
448 /// Timestamp of the first drop in this report, in Unix milliseconds.
449 window_start_ts: u64,
450 /// Timestamp of the last drop in this report, in Unix milliseconds.
451 window_end_ts: u64,
452 },
453 /// Emitted once, on the first (cold) load of the embedding model. `status`
454 /// flags a slow load (possibly underpowered machine) or a failed one;
455 /// `reason` carries the hint / error. See `embedding.rs` and ADR-0011.
456 EmbedderLoad {
457 /// Resolved model display name: repo id, local path, or endpoint model
458 /// and URL.
459 model: String,
460 /// Load outcome: ok, slow, or failed.
461 status: EmbedderLoadStatus,
462 /// Load wall time, in milliseconds (`0` when the load failed before
463 /// timing).
464 took_ms: u64,
465 /// The slow-load hint or the load error; `None` on a normal load.
466 reason: Option<String>,
467 },
468 /// Emitted once when a configured embedding model is actually downloaded to
469 /// the HuggingFace cache (a cold fetch), carrying the real byte size — so a
470 /// multi-second first-run download is never a silent surprise. See ADR-0012.
471 EmbedderDownload {
472 /// The model that was downloaded.
473 model: String,
474 /// Real download size, in bytes.
475 bytes: u64,
476 },
477 /// Emitted when a semantic/hybrid search runs against an embedding set built
478 /// with a *different* model than the one now configured. Retrieval fails
479 /// rather than mixing vector spaces; the caller must rebuild the complete
480 /// embedding cache. See `dense_cache.rs` and ADR-0012.
481 EmbedderModelMismatch {
482 /// The model the existing embeddings were built with.
483 built: String,
484 /// The model now configured.
485 active: String,
486 },
487 /// Emitted once when a semantic/hybrid search finds the attached intent
488 /// graph's centroids were built with a *different* embedding model than the
489 /// active one, so cosine across the two spaces would be meaningless. Unlike
490 /// [`Self::EmbedderModelMismatch`] (corpus, fatal), the usage arm merely
491 /// **pauses** — base ranking is unaffected — until the graph is rebuilt. See
492 /// `usage.rs` and ADR-0014.
493 UsageModelMismatch {
494 /// The graph's model — its fingerprint, or its centroid width when the
495 /// mismatch is dimensional.
496 built: String,
497 /// The active model, in the same units as `built`.
498 active: String,
499 /// `true` when the models differ in output dimension, `false` when only
500 /// the model identity differs at the same width (a same-dim swap a length
501 /// check cannot catch).
502 dim_mismatch: bool,
503 },
504 /// Emitted on every search of a registry that has an intent graph attached,
505 /// recording whether usage history contributed to the ranking (ADR-0014).
506 /// A registry with no graph emits nothing, so this event's presence is
507 /// itself the signal that adaptive ranking is switched on.
508 ///
509 /// `intent: None` is the **miss** case: the query matched no cluster and
510 /// ranked exactly as it would have with no graph at all. A rising share of
511 /// misses means the graph no longer covers what is being asked — the cue to
512 /// re-derive it.
513 ///
514 /// `intent: Some(_)` with `promoted: 0` and `dropped > 0` is a different
515 /// failure wearing similar clothes: the cluster matched, but every
516 /// capability it remembers has left the catalog, so it contributed nothing.
517 /// That is catalog drift, not a coverage gap, and re-deriving the graph
518 /// fixes it. Reading the two apart is what [`Self::UsageBoost::dropped`] is
519 /// for.
520 UsageBoost {
521 /// Id of the matched cluster; `None` when nothing cleared the match
522 /// threshold.
523 intent: Option<String>,
524 /// How well the query matched the cluster — cosine on the dense tier,
525 /// token-overlap share on the lexical one. `0.0` on a miss. Scales
526 /// differ between tiers, so compare within one. Reported so near-misses
527 /// are visible and the threshold can be judged against real traffic.
528 similarity: f64,
529 /// The matched cluster's observation count, which scales the arm's
530 /// weight. `0` on a miss.
531 support: u32,
532 /// How many capability ids the arm contributed to the fusion. `0` on a
533 /// miss.
534 promoted: u32,
535 /// How many ids the matched cluster remembers that the registry no
536 /// longer defines, so they were dropped from the arm rather than
537 /// ranked. `0` on a miss — nothing matched, so nothing was dropped.
538 ///
539 /// Non-zero means the graph and the catalog have drifted apart. It says
540 /// nothing about ranking quality on its own: an id the agent cannot
541 /// invoke must never be returned, so dropping is correct. What it
542 /// buys is that the drop is *visible* instead of being folded into the
543 /// miss rate.
544 ///
545 /// `#[serde(default)]` so a log written before this field existed still
546 /// replays: an older `usage_boost` line reads as `0`, which is what it
547 /// meant.
548 #[serde(default)]
549 dropped: u32,
550 },
551 /// Emitted once when an in-process model's pooling could not be detected
552 /// (no `1_Pooling/config.json`) and no override was given, so a mode was
553 /// assumed. A non-silent guess: set `pooling` to correct it. See ADR-0012.
554 EmbedderPoolingAssumed {
555 /// The model whose pooling could not be detected.
556 model: String,
557 /// The pooling mode that was assumed (`"cls"` or `"mean"`).
558 pooling: String,
559 },
560}
561
562impl TraceEvent {
563 pub(crate) fn catalog_definition_for_tool(tool: &Tool) -> Option<Self> {
564 Self::catalog_definition(
565 CatalogKind::Tool,
566 &tool.id,
567 &tool.name,
568 &tool.description,
569 &[],
570 Some(tool.input_schema.clone()),
571 Some(tool.output_schema.clone()),
572 tool.experimental_searchable_description.as_deref(),
573 )
574 }
575
576 pub(crate) fn catalog_definition_for_skill(skill: &Skill) -> Option<Self> {
577 Self::catalog_definition(
578 CatalogKind::Skill,
579 &skill.id,
580 &skill.name,
581 &skill.description,
582 &skill.tags,
583 None,
584 None,
585 skill.experimental_searchable_description.as_deref(),
586 )
587 }
588
589 pub(crate) fn catalog_definition_for_fact(fact: &Fact) -> Option<Self> {
590 Self::catalog_definition(
591 CatalogKind::Fact,
592 &fact.id,
593 &fact.name,
594 &fact.description,
595 &fact.tags,
596 None,
597 None,
598 fact.experimental_searchable_description.as_deref(),
599 )
600 }
601
602 pub(crate) fn catalog_definition_hash(&self) -> Option<&str> {
603 match self {
604 Self::CatalogDefinition { content_hash, .. } => Some(content_hash),
605 _ => None,
606 }
607 }
608
609 #[allow(clippy::too_many_arguments)]
610 fn catalog_definition(
611 kind: CatalogKind,
612 id: &str,
613 name: &str,
614 description: &str,
615 tags: &[String],
616 input_schema: Option<serde_json::Value>,
617 output_schema: Option<serde_json::Value>,
618 override_description: Option<&str>,
619 ) -> Option<Self> {
620 if input_schema.as_ref().is_some_and(has_unsafe_integer)
621 || output_schema.as_ref().is_some_and(has_unsafe_integer)
622 {
623 return None;
624 }
625 let searchable_description = override_description.unwrap_or(description);
626 let searchable_description_overridden = override_description.is_some();
627 let content = CatalogDefinitionContent {
628 kind,
629 id,
630 name,
631 description,
632 tags,
633 input_schema: input_schema.as_ref(),
634 output_schema: output_schema.as_ref(),
635 searchable_description,
636 searchable_description_overridden,
637 };
638 let content_hash = catalog_definition_hash(&content)?;
639 Some(Self::CatalogDefinition {
640 kind,
641 id: id.into(),
642 name: name.into(),
643 description: description.into(),
644 tags: tags.to_vec(),
645 input_schema: input_schema.map(Box::new),
646 output_schema: output_schema.map(Box::new),
647 searchable_description: searchable_description.into(),
648 searchable_description_overridden,
649 content_hash,
650 })
651 }
652}
653
654fn catalog_definition_hash(content: &CatalogDefinitionContent<'_>) -> Option<String> {
655 let canonical = serde_json_canonicalizer::to_vec(content).ok()?;
656 Some(format!("{:x}", Sha256::digest(canonical)))
657}
658
659fn has_unsafe_integer(value: &serde_json::Value) -> bool {
660 match value {
661 serde_json::Value::Number(number) => number
662 .as_f64()
663 .is_some_and(|number| number.fract() == 0.0 && number.abs() > MAX_SAFE_INTEGER),
664 serde_json::Value::Array(values) => values.iter().any(has_unsafe_integer),
665 serde_json::Value::Object(values) => values.values().any(has_unsafe_integer),
666 _ => false,
667 }
668}
669
670/// Per-event correlation fields supplied by the emitting integration.
671///
672/// Sinks fill stable envelope fields such as `event_id`, `session_id`, and
673/// `source_id`; callers use this context only for facts known at the emission
674/// site. Missing fields are omitted from the flattened JSON envelope.
675#[derive(Debug, Clone, Default, PartialEq, Eq)]
676pub struct TraceEventContext {
677 /// Client-generated id for this event. Sinks mint one when absent.
678 pub event_id: Option<String>,
679 /// Id shared by every event in one invocation lifecycle.
680 pub invocation_id: Option<String>,
681 /// Catalog revision known when the event was emitted.
682 pub catalog_version: Option<String>,
683 /// Deployment environment supplied by the application.
684 pub environment: Option<String>,
685 /// Application-provided subject id.
686 pub end_user_id: Option<String>,
687 /// Active OpenTelemetry trace id, when available.
688 pub trace_id: Option<String>,
689 /// Active OpenTelemetry span id, when available.
690 pub span_id: Option<String>,
691}
692
693impl TraceEventContext {
694 /// Create context for one invocation lifecycle with a fresh opaque id.
695 /// Clone and pass it with the start and terminal event so concurrent calls
696 /// to the same tool remain paired even when they finish out of order.
697 pub fn new_invocation() -> Self {
698 Self {
699 invocation_id: Some(ulid::Ulid::new().to_string()),
700 ..Self::default()
701 }
702 }
703}
704
705/// The versioned wrapper a sink writes around each [`TraceEvent`]: schema
706/// version, stable identity, timestamp, and correlation fields. On the wire the event is flattened
707/// (`#[serde(flatten)]`), so its `type` tag and fields sit beside `v` / `ts` /
708/// `session_id` in one JSON object.
709#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710pub struct TraceEnvelope {
711 /// Envelope schema version; currently `2`.
712 pub v: u32,
713 /// Client-generated ULID identifying exactly this event.
714 #[serde(default)]
715 pub event_id: String,
716 /// Event time, in milliseconds since the Unix epoch.
717 pub ts: u64,
718 /// The session the event belongs to, as given to the sink — correlates
719 /// all events from one agent session.
720 pub session_id: String,
721 /// Stable source identity shared by events and catalog snapshots.
722 #[serde(default)]
723 pub source_id: String,
724 /// Id shared by every event in one invocation lifecycle.
725 #[serde(skip_serializing_if = "Option::is_none")]
726 pub invocation_id: Option<String>,
727 /// Catalog revision known when the event was emitted.
728 #[serde(skip_serializing_if = "Option::is_none")]
729 pub catalog_version: Option<String>,
730 /// Deployment environment supplied by the application.
731 #[serde(skip_serializing_if = "Option::is_none")]
732 pub environment: Option<String>,
733 /// Application-provided subject id.
734 #[serde(skip_serializing_if = "Option::is_none")]
735 pub end_user_id: Option<String>,
736 /// Active OpenTelemetry trace id, when available.
737 #[serde(skip_serializing_if = "Option::is_none")]
738 pub trace_id: Option<String>,
739 /// Active OpenTelemetry span id, when available.
740 #[serde(skip_serializing_if = "Option::is_none")]
741 pub span_id: Option<String>,
742 /// The event itself, flattened into the envelope on the wire.
743 #[serde(flatten)]
744 pub event: TraceEvent,
745}