1use 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
39pub 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
46pub 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#[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#[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#[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#[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
120pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct RuntimeEventRecord {
170 pub id: String,
173 pub scope: TraceScope,
174 pub event_type: String,
176 #[serde(default)]
177 pub payload: Value,
178 #[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#[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct EventsResponse {
220 pub items: Vec<RuntimeEventRecord>,
221}
222
223pub const MAX_PAGE_SIZE: usize = 1_000;
225
226pub const DEFAULT_PAGE_SIZE: usize = 100;
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ApiError {
232 pub status: u16,
233 pub message: String,
234}
235
236#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub enum MetricsDataSource {
242 #[serde(rename = "v3")]
244 V3,
245 #[serde(rename = "v4")]
247 V4,
248 #[serde(rename = "auto")]
250 #[default]
251 Auto,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct MetricsQuery {
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub project_ids: Option<Vec<String>>,
261 #[serde(default = "default_days")]
263 pub days: u32,
264 #[serde(default)]
266 pub data_source: MetricsDataSource,
267 #[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#[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#[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#[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#[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#[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 pub avg_duration_seconds: Option<f64>,
352 pub p50_duration_seconds: Option<f64>,
354 pub p95_duration_seconds: Option<f64>,
356 pub p99_duration_seconds: Option<f64>,
358 pub avg_ttft_seconds: Option<f64>,
360}
361
362#[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#[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 pub error_rate: f64,
380 pub model_name: Option<String>,
381}
382
383#[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#[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 pub cache_hit_pct: f64,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct CacheHitRateResponse {
409 pub items: Vec<CacheHitRatePoint>,
410}
411
412#[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#[derive(Debug, Clone, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CostBreakdownResponse {
430 pub items: Vec<CostBreakdownPoint>,
431}
432
433#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct TokenDistributionResponse {
475 pub items: Vec<TokenDistributionPoint>,
476}
477
478#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase")]
515pub struct PricingTierResponse {
516 pub items: Vec<PricingTierPoint>,
517}
518
519pub const REDACTED: &str = "<redacted>";
521
522pub 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
550fn 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 ["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 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}