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")]
234 pub runtime_id: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub run_id: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub user_id: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub agent_id: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub agent_space_id: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub runtime_type: Option<String>,
245}
246
247impl TraceScope {
248 pub fn run(conversation_id: impl Into<String>, run_id: impl Into<String>) -> Self {
249 Self {
250 conversation_id: conversation_id.into(),
251 run_id: Some(run_id.into()),
252 ..Default::default()
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct RuntimeEventRecord {
261 pub id: String,
264 pub scope: TraceScope,
265 pub event_type: String,
267 #[serde(default)]
268 pub payload: Value,
269 #[serde(default)]
271 pub sequence: i64,
272 #[serde(default)]
273 pub created_at_ms: i64,
274 #[serde(default, skip_serializing_if = "Value::is_null")]
275 pub metadata: Value,
276}
277
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct AppendEventsRequest {
281 pub events: Vec<RuntimeEventRecord>,
282}
283
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct AppendResponse {
289 pub appended: usize,
290 pub skipped: usize,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct SearchEventsRequest {
296 pub scope: TraceScope,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub event_type: Option<String>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub limit: Option<usize>,
302}
303
304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct EventsResponse {
311 pub items: Vec<RuntimeEventRecord>,
312}
313
314pub const MAX_PAGE_SIZE: usize = 1_000;
316
317pub const DEFAULT_PAGE_SIZE: usize = 100;
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct ApiError {
323 pub status: u16,
324 pub message: String,
325}
326
327#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub enum MetricsDataSource {
333 #[serde(rename = "v3")]
335 V3,
336 #[serde(rename = "v4")]
338 V4,
339 #[serde(rename = "auto")]
341 #[default]
342 Auto,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct MetricsQuery {
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub project_ids: Option<Vec<String>>,
352 #[serde(default = "default_days")]
354 pub days: u32,
355 #[serde(default)]
357 pub data_source: MetricsDataSource,
358 #[serde(default = "default_v4_cutover")]
361 pub v4_cutover_date: String,
362}
363
364impl Default for MetricsQuery {
365 fn default() -> Self {
366 Self {
367 project_ids: None,
368 days: default_days(),
369 data_source: MetricsDataSource::default(),
370 v4_cutover_date: default_v4_cutover(),
371 }
372 }
373}
374
375fn default_v4_cutover() -> String {
376 "2026-09-01 00:00:00".to_string()
377}
378
379const fn default_days() -> u32 {
380 30
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct TokenUsagePoint {
387 pub day: String,
388 pub total_input_tokens: u64,
389 pub total_output_tokens: u64,
390 pub total_tokens: u64,
391 pub observation_count: u64,
392 pub generation_count: u64,
393 pub total_cost: Option<f64>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399pub struct TokenUsageResponse {
400 pub project_id: String,
401 pub points: Vec<TokenUsagePoint>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406#[serde(rename_all = "camelCase")]
407pub struct ModelBreakdownEntry {
408 pub project_id: String,
409 pub model_name: String,
410 pub observation_count: u64,
411 pub total_input_tokens: u64,
412 pub total_output_tokens: u64,
413 pub total_tokens: u64,
414 pub total_cost: Option<f64>,
415 pub pricing_tier: Option<String>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct ProjectSummary {
422 pub project_id: String,
423 pub total_tokens: u64,
424 pub total_input_tokens: u64,
425 pub total_output_tokens: u64,
426 pub observation_count: u64,
427 pub generation_count: u64,
428 pub total_cost: Option<f64>,
429 pub unique_models: u64,
430 pub unique_traces: u64,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct LatencyPoint {
437 pub day: String,
438 pub project_id: String,
439 pub model_name: Option<String>,
440 pub observation_count: u64,
441 pub avg_duration_seconds: Option<f64>,
443 pub p50_duration_seconds: Option<f64>,
445 pub p95_duration_seconds: Option<f64>,
447 pub p99_duration_seconds: Option<f64>,
449 pub avg_ttft_seconds: Option<f64>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct LatencyResponse {
457 pub project_id: String,
458 pub points: Vec<LatencyPoint>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct ErrorRatePoint {
465 pub day: String,
466 pub project_id: String,
467 pub total_observations: u64,
468 pub error_count: u64,
469 pub error_rate: f64,
471 pub model_name: Option<String>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct ErrorRateResponse {
478 pub project_id: String,
479 pub points: Vec<ErrorRatePoint>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub struct CacheHitRatePoint {
488 pub project_id: String,
489 pub model_name: Option<String>,
490 pub total_input_tokens: u64,
491 pub cached_tokens: u64,
492 pub cache_hit_pct: f64,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct CacheHitRateResponse {
500 pub items: Vec<CacheHitRatePoint>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct CostBreakdownPoint {
509 pub project_id: String,
510 pub model_name: Option<String>,
511 pub total_input_cost: Option<f64>,
512 pub total_output_cost: Option<f64>,
513 pub total_cost: Option<f64>,
514 pub observation_count: u64,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519#[serde(rename_all = "camelCase")]
520pub struct CostBreakdownResponse {
521 pub items: Vec<CostBreakdownPoint>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct ModelTrendPoint {
530 pub day: String,
531 pub project_id: String,
532 pub model_name: Option<String>,
533 pub call_count: u64,
534 pub total_tokens: u64,
535 pub total_cost: Option<f64>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct ModelTrendResponse {
542 pub project_id: String,
543 pub points: Vec<ModelTrendPoint>,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct TokenDistributionPoint {
552 pub project_id: String,
553 pub model_name: Option<String>,
554 pub observation_count: u64,
555 pub avg_tokens: Option<f64>,
556 pub p50_tokens: Option<f64>,
557 pub p95_tokens: Option<f64>,
558 pub p99_tokens: Option<f64>,
559 pub max_tokens: Option<u64>,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
564#[serde(rename_all = "camelCase")]
565pub struct TokenDistributionResponse {
566 pub items: Vec<TokenDistributionPoint>,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
573#[serde(rename_all = "camelCase")]
574pub struct ActivityPoint {
575 pub day: String,
576 pub project_id: String,
577 pub active_traces: u64,
578 pub observation_count: u64,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(rename_all = "camelCase")]
584pub struct ActivityResponse {
585 pub project_id: String,
586 pub points: Vec<ActivityPoint>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct PricingTierPoint {
595 pub project_id: String,
596 pub pricing_tier: Option<String>,
597 pub model_name: Option<String>,
598 pub observation_count: u64,
599 pub total_tokens: u64,
600 pub total_cost: Option<f64>,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub struct PricingTierResponse {
607 pub items: Vec<PricingTierPoint>,
608}
609
610pub const REDACTED: &str = "<redacted>";
612
613pub fn redact_payload(value: &mut Value) {
626 match value {
627 Value::Object(fields) => {
628 for (key, field) in fields.iter_mut() {
629 if is_sensitive_key(key) {
630 *field = Value::String(REDACTED.to_string());
631 } else {
632 redact_payload(field);
633 }
634 }
635 }
636 Value::Array(items) => items.iter_mut().for_each(redact_payload),
637 _ => {}
638 }
639}
640
641fn is_sensitive_key(key: &str) -> bool {
646 let normalised = key
647 .chars()
648 .filter(|character| character.is_ascii_alphanumeric())
649 .flat_map(|character| character.to_lowercase())
650 .collect::<String>();
651
652 const EXACT: &[&str] = &[
653 "apikey",
654 "authorization",
655 "gatewaytoken",
656 "password",
657 "secret",
658 "token",
659 ];
660 if EXACT.contains(&normalised.as_str()) {
661 return true;
662 }
663
664 ["apikey", "secret", "token", "password"]
666 .iter()
667 .any(|needle| normalised.ends_with(needle))
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use serde_json::json;
674
675 #[test]
676 fn scope_serialises_camel_case_and_omits_absent_provenance() {
677 let value = serde_json::to_value(TraceScope::run("conv-1", "run-1")).expect("serialize");
678 assert_eq!(value, json!({"conversationId": "conv-1", "runId": "run-1"}));
679 }
680
681 #[test]
682 fn redaction_masks_credential_fields_at_any_depth() {
683 let mut payload = json!({
684 "model": "test-model",
685 "api_key": "sensitive-api-value",
686 "headers": {"Authorization": "sensitive-authorization-value"},
687 "calls": [
688 {"accessToken": "sensitive-access-value"},
689 {"clientSecret": "sensitive-client-value"}
690 ],
691 "nested": {"deep": {"apiKey": "sensitive-nested-value"}}
692 });
693
694 redact_payload(&mut payload);
695
696 assert_eq!(payload["model"], "test-model", "non-secrets must survive");
697 assert_eq!(payload["api_key"], REDACTED);
698 assert_eq!(payload["headers"]["Authorization"], REDACTED);
699 assert_eq!(payload["calls"][0]["accessToken"], REDACTED);
700 assert_eq!(payload["calls"][1]["clientSecret"], REDACTED);
701 assert_eq!(payload["nested"]["deep"]["apiKey"], REDACTED);
702 }
703
704 #[test]
705 fn redaction_does_not_mask_lookalike_field_names() {
706 let mut payload = json!({
709 "tokenCount": 42,
710 "secretsManagerArn": "arn:aws:secretsmanager:...",
711 "passwordPolicy": {"minLength": 12}
712 });
713
714 redact_payload(&mut payload);
715
716 assert_eq!(payload["tokenCount"], 42);
717 assert_eq!(
718 payload["secretsManagerArn"], "arn:aws:secretsmanager:...",
719 "a name that merely contains 'secret' is not a secret"
720 );
721 assert_eq!(payload["passwordPolicy"]["minLength"], 12);
722 }
723
724 #[test]
725 fn an_event_round_trips_through_json() {
726 let record = RuntimeEventRecord {
727 id: "e-1".into(),
728 scope: TraceScope::run("conv-1", "run-1"),
729 event_type: "tool_call".into(),
730 payload: json!({"name": "execute_cmd"}),
731 sequence: 3,
732 created_at_ms: 1_700_000_000_000,
733 metadata: Value::Null,
734 };
735 let encoded = serde_json::to_value(&record).expect("serialize");
736 assert_eq!(encoded["eventType"], "tool_call");
737 assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
738 let decoded: RuntimeEventRecord = serde_json::from_value(encoded).expect("deserialize");
739 assert_eq!(decoded, record);
740 }
741}