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