Skip to main content

agent_trace_contract/
lib.rs

1//! HTTP API contract for the Trace Infra service.
2//!
3//! # Why trace is its own service and not part of History Infra
4//!
5//! Conversation messages and trace events look similar on the wire and behave
6//! nothing alike:
7//!
8//! | | Conversation | Trace |
9//! | --- | --- | --- |
10//! | Replayed into a model prompt | yes | **never** |
11//! | Losing one record | loses user work | acceptable |
12//! | Volume | one per turn | many per turn |
13//! | Retention | durable | short |
14//!
15//! Sharing one store would force the stricter guarantee onto high-volume
16//! diagnostic data, and — worse — put events one filtering mistake away from
17//! being replayed as conversation. Separate services keep that impossible by
18//! construction.
19//!
20//! # Layering
21//!
22//! ```text
23//! agent-trace-contract   paths + DTOs + redaction   (this crate)
24//! agent-trace-runtime    store implementation
25//! agent-trace-server     router + auth + bin
26//! ```
27//!
28//! See [`api.md`](../api.md) for the full HTTP reference.
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use agent_registry_contract::{AgentId, ResourceRelations};
34
35pub const SERVICE_NAME: &str = "agent-trace-infra";
36pub const DEFAULT_PORT: u16 = 5140;
37pub const AUDIENCE: &str = "agent-trace-infra";
38
39/// Optional static service token. When unset, the server runs in open mode.
40pub const SERVICE_TOKEN_ENV: &str = "AGENT_TRACE_INFRA_SERVICE_TOKEN";
41
42pub const HEALTH_PATH: &str = "/health";
43pub const EVENTS_APPEND_PATH: &str = "/internal/v1/trace-infra/events/batch";
44pub const EVENTS_SEARCH_PATH: &str = "/internal/v1/trace-infra/events/search";
45
46// ── Langfuse project administration ───────────────────────────────────
47pub const LANGFUSE_PROJECTS_PATH: &str = "/internal/v1/trace-infra/langfuse-projects";
48pub const LANGFUSE_PROJECT_PATH: &str = "/internal/v1/trace-infra/langfuse-projects/{projectId}";
49pub const ADMIN_LANGFUSE_PROJECTS_PATH: &str = "/admin/api/langfuse-projects";
50pub const MAX_LANGFUSE_PROJECT_PAGE_SIZE: u32 = 200;
51
52/// Allocate a Langfuse project and project-level API key to Infra resources.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields, rename_all = "camelCase")]
55pub struct CreateLangfuseProjectRequest {
56    pub name: String,
57    pub owner_email: String,
58    #[serde(default)]
59    pub relations: ResourceRelations,
60}
61
62/// Query the project-to-resource relationships maintained by Trace Infra.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields, rename_all = "camelCase")]
65pub struct ListLangfuseProjectsRequest {
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub related_agent_id: Option<AgentId>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub related_runtime_id: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub related_user_id: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub cursor: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub limit: Option<u32>,
76}
77
78/// Persisted Langfuse project assignment. Project secrets are intentionally
79/// absent from lookup/list responses.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct LangfuseProject {
83    pub id: String,
84    pub name: String,
85    pub owner_email: String,
86    pub public_key: String,
87    pub base_url: String,
88    #[serde(default)]
89    pub relations: ResourceRelations,
90    pub created_at_ms: u64,
91}
92
93/// Creation response. The secret can be recovered only through an idempotent
94/// replay of the same create request; ordinary project queries redact it.
95#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct ProvisionedLangfuseProject {
98    pub project: LangfuseProject,
99    pub secret_key: String,
100}
101
102impl std::fmt::Debug for ProvisionedLangfuseProject {
103    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        formatter
105            .debug_struct("ProvisionedLangfuseProject")
106            .field("project", &self.project)
107            .field("secret_key", &"[REDACTED]")
108            .finish()
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct LangfuseProjectPage {
115    pub items: Vec<LangfuseProject>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub next_cursor: Option<String>,
118}
119
120// ── Langfuse token-usage metrics ───────────────────────────────────────
121pub const METRICS_TOKEN_USAGE_PATH: &str = "/internal/v1/trace-infra/metrics/token-usage";
122pub const METRICS_MODEL_BREAKDOWN_PATH: &str = "/internal/v1/trace-infra/metrics/model-breakdown";
123pub const METRICS_PROJECT_SUMMARY_PATH: &str = "/internal/v1/trace-infra/metrics/project-summary";
124pub const METRICS_LATENCY_PATH: &str = "/internal/v1/trace-infra/metrics/latency";
125pub const METRICS_ERROR_RATE_PATH: &str = "/internal/v1/trace-infra/metrics/error-rate";
126pub const METRICS_CACHE_HIT_RATE_PATH: &str = "/internal/v1/trace-infra/metrics/cache-hit-rate";
127pub const METRICS_COST_BREAKDOWN_PATH: &str = "/internal/v1/trace-infra/metrics/cost-breakdown";
128pub const METRICS_MODEL_TREND_PATH: &str = "/internal/v1/trace-infra/metrics/model-trend";
129pub const METRICS_TOKEN_DISTRIBUTION_PATH: &str =
130    "/internal/v1/trace-infra/metrics/token-distribution";
131pub const METRICS_ACTIVITY_PATH: &str = "/internal/v1/trace-infra/metrics/activity";
132pub const METRICS_PRICING_TIER_PATH: &str = "/internal/v1/trace-infra/metrics/pricing-tier";
133pub const MAX_METRICS_LOOKBACK_DAYS: u32 = 3_650;
134
135/// Identifies what a trace event belongs to.
136///
137/// `run_id` is what makes a trace useful: it groups every event emitted by one
138/// turn, so a single execution can be reconstructed without guessing from
139/// timestamps.
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct TraceScope {
143    pub conversation_id: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub run_id: Option<String>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub user_id: Option<String>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub agent_id: Option<String>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub agent_space_id: Option<String>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub runtime_type: Option<String>,
154}
155
156impl TraceScope {
157    pub fn run(conversation_id: impl Into<String>, run_id: impl Into<String>) -> Self {
158        Self {
159            conversation_id: conversation_id.into(),
160            run_id: Some(run_id.into()),
161            ..Default::default()
162        }
163    }
164}
165
166/// One runtime trace event.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct RuntimeEventRecord {
170    /// Caller supplied id. Enables idempotent append, so a retry after a lost
171    /// response cannot duplicate a turn's trace.
172    pub id: String,
173    pub scope: TraceScope,
174    /// Coarse kind, e.g. `turn_started`, `tool_call`, `guard_rail`, `turn_finished`.
175    pub event_type: String,
176    #[serde(default)]
177    pub payload: Value,
178    /// Monotonic order within a run. Ties break by `created_at_ms`.
179    #[serde(default)]
180    pub sequence: i64,
181    #[serde(default)]
182    pub created_at_ms: i64,
183    #[serde(default, skip_serializing_if = "Value::is_null")]
184    pub metadata: Value,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct AppendEventsRequest {
190    pub events: Vec<RuntimeEventRecord>,
191}
192
193/// Result of an append. `skipped` counts ids that already existed, so a caller
194/// can tell a fresh write from a deduplicated retry.
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct AppendResponse {
198    pub appended: usize,
199    pub skipped: usize,
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct SearchEventsRequest {
205    pub scope: TraceScope,
206    /// When set, only events of this type are returned.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub event_type: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub limit: Option<usize>,
211}
212
213/// Events in chronological order, oldest first.
214///
215/// The order is part of the contract: a client that has to re-sort is a client
216/// that will eventually get it wrong.
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct EventsResponse {
220    pub items: Vec<RuntimeEventRecord>,
221}
222
223/// Upper bound the server enforces on any page size.
224pub const MAX_PAGE_SIZE: usize = 1_000;
225
226/// Default page size when a request omits `limit`.
227pub const DEFAULT_PAGE_SIZE: usize = 100;
228
229/// Error body shared by every non-2xx response.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ApiError {
232    pub status: u16,
233    pub message: String,
234}
235
236// ── Langfuse token-usage metrics DTOs ──────────────────────────────────
237
238/// Which ClickHouse table to query.
239#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub enum MetricsDataSource {
242    /// Legacy `observations` + `traces` table (v3 path).
243    #[serde(rename = "v3")]
244    V3,
245    /// New `events_core` / `events_full` table (v4 path).
246    #[serde(rename = "v4")]
247    V4,
248    /// Auto-detect: use v4 for dates >= cutover, v3 for dates < cutover.
249    #[serde(rename = "auto")]
250    #[default]
251    Auto,
252}
253
254/// Request body for all metrics queries.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct MetricsQuery {
258    /// Project IDs to filter on (defaults to all configured projects).
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub project_ids: Option<Vec<String>>,
261    /// Number of days to look back (defaults to 30).
262    #[serde(default = "default_days")]
263    pub days: u32,
264    /// Which data source to query.
265    #[serde(default)]
266    pub data_source: MetricsDataSource,
267    /// Cutover date for v4 migration (ISO date string, e.g. "2026-09-01").
268    /// Only used when `data_source` is `auto`.
269    #[serde(default = "default_v4_cutover")]
270    pub v4_cutover_date: String,
271}
272
273impl Default for MetricsQuery {
274    fn default() -> Self {
275        Self {
276            project_ids: None,
277            days: default_days(),
278            data_source: MetricsDataSource::default(),
279            v4_cutover_date: default_v4_cutover(),
280        }
281    }
282}
283
284fn default_v4_cutover() -> String {
285    "2026-09-01 00:00:00".to_string()
286}
287
288const fn default_days() -> u32 {
289    30
290}
291
292/// A single data point in a token-usage timeseries.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct TokenUsagePoint {
296    pub day: String,
297    pub total_input_tokens: u64,
298    pub total_output_tokens: u64,
299    pub total_tokens: u64,
300    pub observation_count: u64,
301    pub generation_count: u64,
302    pub total_cost: Option<f64>,
303}
304
305/// Top-level response for the token-usage timeseries endpoint.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct TokenUsageResponse {
309    pub project_id: String,
310    pub points: Vec<TokenUsagePoint>,
311}
312
313/// A single model breakdown row.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct ModelBreakdownEntry {
317    pub project_id: String,
318    pub model_name: String,
319    pub observation_count: u64,
320    pub total_input_tokens: u64,
321    pub total_output_tokens: u64,
322    pub total_tokens: u64,
323    pub total_cost: Option<f64>,
324    pub pricing_tier: Option<String>,
325}
326
327/// Project-level summary stats.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct ProjectSummary {
331    pub project_id: String,
332    pub total_tokens: u64,
333    pub total_input_tokens: u64,
334    pub total_output_tokens: u64,
335    pub observation_count: u64,
336    pub generation_count: u64,
337    pub total_cost: Option<f64>,
338    pub unique_models: u64,
339    pub unique_traces: u64,
340}
341
342/// A single latency data point (daily bucket).
343#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct LatencyPoint {
346    pub day: String,
347    pub project_id: String,
348    pub model_name: Option<String>,
349    pub observation_count: u64,
350    /// Average duration (end_time - start_time) in seconds.
351    pub avg_duration_seconds: Option<f64>,
352    /// P50 duration in seconds.
353    pub p50_duration_seconds: Option<f64>,
354    /// P95 duration in seconds.
355    pub p95_duration_seconds: Option<f64>,
356    /// P99 duration in seconds.
357    pub p99_duration_seconds: Option<f64>,
358    /// Average time-to-first-token (completion_start_time - start_time) in seconds.
359    pub avg_ttft_seconds: Option<f64>,
360}
361
362/// Latency response grouped by project.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct LatencyResponse {
366    pub project_id: String,
367    pub points: Vec<LatencyPoint>,
368}
369
370/// A single error-rate data point (daily bucket).
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct ErrorRatePoint {
374    pub day: String,
375    pub project_id: String,
376    pub total_observations: u64,
377    pub error_count: u64,
378    /// error_count / total_observations.
379    pub error_rate: f64,
380    pub model_name: Option<String>,
381}
382
383/// Error rate response grouped by project.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct ErrorRateResponse {
387    pub project_id: String,
388    pub points: Vec<ErrorRatePoint>,
389}
390
391// ── Cache hit rate (P0) ────────────────────────────────────────────────
392
393/// Cache hit rate data point.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase")]
396pub struct CacheHitRatePoint {
397    pub project_id: String,
398    pub model_name: Option<String>,
399    pub total_input_tokens: u64,
400    pub cached_tokens: u64,
401    /// cached_tokens / total_input_tokens * 100.
402    pub cache_hit_pct: f64,
403}
404
405/// Cache hit rate response (flat list).
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct CacheHitRateResponse {
409    pub items: Vec<CacheHitRatePoint>,
410}
411
412// ── Cost breakdown (P1) ────────────────────────────────────────────────
413
414/// Cost breakdown data point (input/output/total).
415#[derive(Debug, Clone, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct CostBreakdownPoint {
418    pub project_id: String,
419    pub model_name: Option<String>,
420    pub total_input_cost: Option<f64>,
421    pub total_output_cost: Option<f64>,
422    pub total_cost: Option<f64>,
423    pub observation_count: u64,
424}
425
426/// Cost breakdown response (flat list).
427#[derive(Debug, Clone, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CostBreakdownResponse {
430    pub items: Vec<CostBreakdownPoint>,
431}
432
433// ── Model trend (P1) ───────────────────────────────────────────────────
434
435/// Model usage trend point (daily).
436#[derive(Debug, Clone, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct ModelTrendPoint {
439    pub day: String,
440    pub project_id: String,
441    pub model_name: Option<String>,
442    pub call_count: u64,
443    pub total_tokens: u64,
444    pub total_cost: Option<f64>,
445}
446
447/// Model trend response grouped by project.
448#[derive(Debug, Clone, Serialize, Deserialize)]
449#[serde(rename_all = "camelCase")]
450pub struct ModelTrendResponse {
451    pub project_id: String,
452    pub points: Vec<ModelTrendPoint>,
453}
454
455// ── Token distribution (P2) ────────────────────────────────────────────
456
457/// Token distribution statistics (by model).
458#[derive(Debug, Clone, Serialize, Deserialize)]
459#[serde(rename_all = "camelCase")]
460pub struct TokenDistributionPoint {
461    pub project_id: String,
462    pub model_name: Option<String>,
463    pub observation_count: u64,
464    pub avg_tokens: Option<f64>,
465    pub p50_tokens: Option<f64>,
466    pub p95_tokens: Option<f64>,
467    pub p99_tokens: Option<f64>,
468    pub max_tokens: Option<u64>,
469}
470
471/// Token distribution response (flat list).
472#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct TokenDistributionResponse {
475    pub items: Vec<TokenDistributionPoint>,
476}
477
478// ── Activity (P2) ──────────────────────────────────────────────────────
479
480/// Activity data point (daily).
481#[derive(Debug, Clone, Serialize, Deserialize)]
482#[serde(rename_all = "camelCase")]
483pub struct ActivityPoint {
484    pub day: String,
485    pub project_id: String,
486    pub active_traces: u64,
487    pub observation_count: u64,
488}
489
490/// Activity response grouped by project.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct ActivityResponse {
494    pub project_id: String,
495    pub points: Vec<ActivityPoint>,
496}
497
498// ── Pricing tier analysis (P2) ─────────────────────────────────────────
499
500/// Pricing tier analysis data point.
501#[derive(Debug, Clone, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase")]
503pub struct PricingTierPoint {
504    pub project_id: String,
505    pub pricing_tier: Option<String>,
506    pub model_name: Option<String>,
507    pub observation_count: u64,
508    pub total_tokens: u64,
509    pub total_cost: Option<f64>,
510}
511
512/// Pricing tier response (flat list).
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase")]
515pub struct PricingTierResponse {
516    pub items: Vec<PricingTierPoint>,
517}
518
519/// Placeholder written over any value that looks like a credential.
520pub const REDACTED: &str = "<redacted>";
521
522/// Mask credential-shaped fields anywhere in a trace payload.
523///
524/// # Why this lives in the contract
525///
526/// Trace payloads carry tool arguments and model requests, which are exactly
527/// where an API key or bearer token ends up. Traces are also the data most
528/// likely to be shipped to a log aggregator and kept, so an unredacted secret
529/// there outlives the request that leaked it.
530///
531/// Redaction is therefore part of the wire contract rather than a courtesy each
532/// caller might forget: both the client and the server apply it, so a payload
533/// cannot be stored unmasked even if a caller skips the step.
534pub fn redact_payload(value: &mut Value) {
535    match value {
536        Value::Object(fields) => {
537            for (key, field) in fields.iter_mut() {
538                if is_sensitive_key(key) {
539                    *field = Value::String(REDACTED.to_string());
540                } else {
541                    redact_payload(field);
542                }
543            }
544        }
545        Value::Array(items) => items.iter_mut().for_each(redact_payload),
546        _ => {}
547    }
548}
549
550/// True when a field name looks like it holds a credential.
551///
552/// Matching is done on a normalised key (lowercased, separators removed) so
553/// `api_key`, `apiKey`, and `API-KEY` are all caught by one rule.
554fn is_sensitive_key(key: &str) -> bool {
555    let normalised = key
556        .chars()
557        .filter(|character| character.is_ascii_alphanumeric())
558        .flat_map(|character| character.to_lowercase())
559        .collect::<String>();
560
561    const EXACT: &[&str] = &[
562        "apikey",
563        "authorization",
564        "gatewaytoken",
565        "password",
566        "secret",
567        "token",
568    ];
569    if EXACT.contains(&normalised.as_str()) {
570        return true;
571    }
572
573    // Compound names such as `accessToken`, `clientSecret`, `serviceApiKey`.
574    ["apikey", "secret", "token", "password"]
575        .iter()
576        .any(|needle| normalised.ends_with(needle))
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use serde_json::json;
583
584    #[test]
585    fn scope_serialises_camel_case_and_omits_absent_provenance() {
586        let value = serde_json::to_value(TraceScope::run("conv-1", "run-1")).expect("serialize");
587        assert_eq!(value, json!({"conversationId": "conv-1", "runId": "run-1"}));
588    }
589
590    #[test]
591    fn redaction_masks_credential_fields_at_any_depth() {
592        let mut payload = json!({
593            "model": "test-model",
594            "api_key": "sensitive-api-value",
595            "headers": {"Authorization": "sensitive-authorization-value"},
596            "calls": [
597                {"accessToken": "sensitive-access-value"},
598                {"clientSecret": "sensitive-client-value"}
599            ],
600            "nested": {"deep": {"apiKey": "sensitive-nested-value"}}
601        });
602
603        redact_payload(&mut payload);
604
605        assert_eq!(payload["model"], "test-model", "non-secrets must survive");
606        assert_eq!(payload["api_key"], REDACTED);
607        assert_eq!(payload["headers"]["Authorization"], REDACTED);
608        assert_eq!(payload["calls"][0]["accessToken"], REDACTED);
609        assert_eq!(payload["calls"][1]["clientSecret"], REDACTED);
610        assert_eq!(payload["nested"]["deep"]["apiKey"], REDACTED);
611    }
612
613    #[test]
614    fn redaction_does_not_mask_lookalike_field_names() {
615        // These read like secrets but carry no credential; masking them would
616        // quietly destroy useful trace data.
617        let mut payload = json!({
618            "tokenCount": 42,
619            "secretsManagerArn": "arn:aws:secretsmanager:...",
620            "passwordPolicy": {"minLength": 12}
621        });
622
623        redact_payload(&mut payload);
624
625        assert_eq!(payload["tokenCount"], 42);
626        assert_eq!(
627            payload["secretsManagerArn"], "arn:aws:secretsmanager:...",
628            "a name that merely contains 'secret' is not a secret"
629        );
630        assert_eq!(payload["passwordPolicy"]["minLength"], 12);
631    }
632
633    #[test]
634    fn an_event_round_trips_through_json() {
635        let record = RuntimeEventRecord {
636            id: "e-1".into(),
637            scope: TraceScope::run("conv-1", "run-1"),
638            event_type: "tool_call".into(),
639            payload: json!({"name": "execute_cmd"}),
640            sequence: 3,
641            created_at_ms: 1_700_000_000_000,
642            metadata: Value::Null,
643        };
644        let encoded = serde_json::to_value(&record).expect("serialize");
645        assert_eq!(encoded["eventType"], "tool_call");
646        assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
647        let decoded: RuntimeEventRecord = serde_json::from_value(encoded).expect("deserialize");
648        assert_eq!(decoded, record);
649    }
650}