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 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#[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#[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#[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#[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#[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#[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#[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#[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
224pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct TraceScope {
247 pub conversation_id: String,
248 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct RuntimeEventRecord {
278 pub id: String,
281 pub scope: TraceScope,
282 pub event_type: String,
284 #[serde(default)]
285 pub payload: Value,
286 #[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#[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct EventsResponse {
328 pub items: Vec<RuntimeEventRecord>,
329}
330
331pub const MAX_PAGE_SIZE: usize = 10_000;
334
335pub const DEFAULT_PAGE_SIZE: usize = 100;
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct ApiError {
341 pub status: u16,
342 pub message: String,
343}
344
345#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(rename_all = "camelCase")]
350pub enum MetricsDataSource {
351 #[serde(rename = "v3")]
353 V3,
354 #[serde(rename = "v4")]
356 V4,
357 #[serde(rename = "auto")]
359 #[default]
360 Auto,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct MetricsQuery {
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub project_ids: Option<Vec<String>>,
370 #[serde(default = "default_days")]
372 pub days: u32,
373 #[serde(default)]
375 pub data_source: MetricsDataSource,
376 #[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#[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#[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#[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#[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#[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 pub avg_duration_seconds: Option<f64>,
461 pub p50_duration_seconds: Option<f64>,
463 pub p95_duration_seconds: Option<f64>,
465 pub p99_duration_seconds: Option<f64>,
467 pub avg_ttft_seconds: Option<f64>,
469}
470
471#[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#[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 pub error_rate: f64,
489 pub model_name: Option<String>,
490}
491
492#[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#[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 pub cache_hit_pct: f64,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516#[serde(rename_all = "camelCase")]
517pub struct CacheHitRateResponse {
518 pub items: Vec<CacheHitRatePoint>,
519}
520
521#[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#[derive(Debug, Clone, Serialize, Deserialize)]
537#[serde(rename_all = "camelCase")]
538pub struct CostBreakdownResponse {
539 pub items: Vec<CostBreakdownPoint>,
540}
541
542#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
582#[serde(rename_all = "camelCase")]
583pub struct TokenDistributionResponse {
584 pub items: Vec<TokenDistributionPoint>,
585}
586
587#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct PricingTierResponse {
625 pub items: Vec<PricingTierPoint>,
626}
627
628pub const REDACTED: &str = "<redacted>";
630
631pub 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
659fn 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 ["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 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}