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