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;
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#[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#[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#[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#[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#[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#[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
207pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct RuntimeEventRecord {
257 pub id: String,
260 pub scope: TraceScope,
261 pub event_type: String,
263 #[serde(default)]
264 pub payload: Value,
265 #[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#[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct EventsResponse {
307 pub items: Vec<RuntimeEventRecord>,
308}
309
310pub const MAX_PAGE_SIZE: usize = 1_000;
312
313pub const DEFAULT_PAGE_SIZE: usize = 100;
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ApiError {
319 pub status: u16,
320 pub message: String,
321}
322
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub enum MetricsDataSource {
329 #[serde(rename = "v3")]
331 V3,
332 #[serde(rename = "v4")]
334 V4,
335 #[serde(rename = "auto")]
337 #[default]
338 Auto,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct MetricsQuery {
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub project_ids: Option<Vec<String>>,
348 #[serde(default = "default_days")]
350 pub days: u32,
351 #[serde(default)]
353 pub data_source: MetricsDataSource,
354 #[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#[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#[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#[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#[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#[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 pub avg_duration_seconds: Option<f64>,
439 pub p50_duration_seconds: Option<f64>,
441 pub p95_duration_seconds: Option<f64>,
443 pub p99_duration_seconds: Option<f64>,
445 pub avg_ttft_seconds: Option<f64>,
447}
448
449#[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#[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 pub error_rate: f64,
467 pub model_name: Option<String>,
468}
469
470#[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#[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 pub cache_hit_pct: f64,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct CacheHitRateResponse {
496 pub items: Vec<CacheHitRatePoint>,
497}
498
499#[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#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(rename_all = "camelCase")]
516pub struct CostBreakdownResponse {
517 pub items: Vec<CostBreakdownPoint>,
518}
519
520#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct TokenDistributionResponse {
562 pub items: Vec<TokenDistributionPoint>,
563}
564
565#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
601#[serde(rename_all = "camelCase")]
602pub struct PricingTierResponse {
603 pub items: Vec<PricingTierPoint>,
604}
605
606pub const REDACTED: &str = "<redacted>";
608
609pub 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
637fn 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 ["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 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}