use serde::{Deserialize, Serialize};
use serde_json::Value;
use agent_registry_contract::{AgentId, ResourceRelations};
pub const SERVICE_NAME: &str = "agent-trace-infra";
pub const DEFAULT_PORT: u16 = 5140;
pub const AUDIENCE: &str = "agent-trace-infra";
pub const SERVICE_TOKEN_ENV: &str = "AGENT_TRACE_INFRA_SERVICE_TOKEN";
pub const HEALTH_PATH: &str = "/health";
pub const EVENTS_APPEND_PATH: &str = "/internal/v1/trace-infra/events/batch";
pub const EVENTS_SEARCH_PATH: &str = "/internal/v1/trace-infra/events/search";
pub const LANGFUSE_PROJECTS_PATH: &str = "/internal/v1/trace-infra/langfuse-projects";
pub const LANGFUSE_PROJECT_PATH: &str = "/internal/v1/trace-infra/langfuse-projects/{projectId}";
pub const ADMIN_LANGFUSE_PROJECTS_PATH: &str = "/admin/api/langfuse-projects";
pub const MAX_LANGFUSE_PROJECT_PAGE_SIZE: u32 = 200;
pub const RUNTIME_BINDING_PATH: &str = "/internal/v1/trace-infra/runtime-bindings/{runtimeId}";
pub const RUNTIME_BINDING_INITIALIZE_PATH: &str =
"/internal/v1/trace-infra/runtime-bindings/{runtimeId}/initialize";
pub const RUNTIME_BINDINGS_RESOLVE_PATH: &str = "/internal/v1/trace-infra/runtime-bindings:resolve";
pub const MAX_RUNTIME_BINDING_RESOLVE_SIZE: usize = 200;
pub const ADMIN_LANGFUSE_CONFIG_PATH: &str = "/internal/v1/trace-infra/langfuse-config";
pub const ADMIN_CLICKHOUSE_CONFIG_PATH: &str = "/internal/v1/trace-infra/clickhouse-config";
pub const ADMIN_GATEWAY_LANGFUSE_CONFIG_PATH: &str = "/admin/api/langfuse-config";
pub const ADMIN_GATEWAY_CLICKHOUSE_CONFIG_PATH: &str = "/admin/api/clickhouse-config";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CreateLangfuseProjectRequest {
pub name: String,
pub owner_email: String,
#[serde(default)]
pub relations: ResourceRelations,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ListLangfuseProjectsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub related_agent_id: Option<AgentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub related_runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub related_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LangfuseProject {
pub id: String,
pub name: String,
pub owner_email: String,
pub public_key: String,
pub base_url: String,
#[serde(default)]
pub relations: ResourceRelations,
pub created_at_ms: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InitializeRuntimeBindingRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<AgentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_email: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeLangfuseBinding {
pub runtime_id: String,
pub project_name: String,
pub langfuse_project_id: String,
pub public_key: String,
pub status: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResolveRuntimeBindingsRequest {
pub runtime_ids: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveRuntimeBindingsResponse {
pub bindings: Vec<RuntimeLangfuseBinding>,
pub unbound_runtime_ids: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LangfuseAdminConfig {
#[serde(default)]
pub base_url: String,
pub public_key_configured: bool,
pub secret_key_configured: bool,
pub managed: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClickHouseAdminConfig {
#[serde(default)]
pub host: String,
#[serde(default)]
pub port: String,
#[serde(default)]
pub database: String,
#[serde(default)]
pub user: String,
pub password_configured: bool,
pub managed: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateLangfuseAdminConfigRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_key: Option<String>,
#[serde(default)]
pub clear_public_key: bool,
#[serde(default)]
pub clear_secret_key: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClickHouseAdminConfigRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default)]
pub clear_password: bool,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProvisionedLangfuseProject {
pub project: LangfuseProject,
pub secret_key: String,
}
impl std::fmt::Debug for ProvisionedLangfuseProject {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProvisionedLangfuseProject")
.field("project", &self.project)
.field("secret_key", &"[REDACTED]")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LangfuseProjectPage {
pub items: Vec<LangfuseProject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
pub const METRICS_TOKEN_USAGE_PATH: &str = "/internal/v1/trace-infra/metrics/token-usage";
pub const METRICS_MODEL_BREAKDOWN_PATH: &str = "/internal/v1/trace-infra/metrics/model-breakdown";
pub const METRICS_PROJECT_SUMMARY_PATH: &str = "/internal/v1/trace-infra/metrics/project-summary";
pub const METRICS_LATENCY_PATH: &str = "/internal/v1/trace-infra/metrics/latency";
pub const METRICS_ERROR_RATE_PATH: &str = "/internal/v1/trace-infra/metrics/error-rate";
pub const METRICS_CACHE_HIT_RATE_PATH: &str = "/internal/v1/trace-infra/metrics/cache-hit-rate";
pub const METRICS_COST_BREAKDOWN_PATH: &str = "/internal/v1/trace-infra/metrics/cost-breakdown";
pub const METRICS_MODEL_TREND_PATH: &str = "/internal/v1/trace-infra/metrics/model-trend";
pub const METRICS_TOKEN_DISTRIBUTION_PATH: &str =
"/internal/v1/trace-infra/metrics/token-distribution";
pub const METRICS_ACTIVITY_PATH: &str = "/internal/v1/trace-infra/metrics/activity";
pub const METRICS_PRICING_TIER_PATH: &str = "/internal/v1/trace-infra/metrics/pricing-tier";
pub const MAX_METRICS_LOOKBACK_DAYS: u32 = 3_650;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TraceScope {
pub conversation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_space_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_type: Option<String>,
}
impl TraceScope {
pub fn run(conversation_id: impl Into<String>, run_id: impl Into<String>) -> Self {
Self {
conversation_id: conversation_id.into(),
run_id: Some(run_id.into()),
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEventRecord {
pub id: String,
pub scope: TraceScope,
pub event_type: String,
#[serde(default)]
pub payload: Value,
#[serde(default)]
pub sequence: i64,
#[serde(default)]
pub created_at_ms: i64,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub metadata: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendEventsRequest {
pub events: Vec<RuntimeEventRecord>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendResponse {
pub appended: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchEventsRequest {
pub scope: TraceScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventsResponse {
pub items: Vec<RuntimeEventRecord>,
}
pub const MAX_PAGE_SIZE: usize = 10_000;
pub const DEFAULT_PAGE_SIZE: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiError {
pub status: u16,
pub message: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MetricsDataSource {
#[serde(rename = "v3")]
V3,
#[serde(rename = "v4")]
V4,
#[serde(rename = "auto")]
#[default]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MetricsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_ids: Option<Vec<String>>,
#[serde(default = "default_days")]
pub days: u32,
#[serde(default)]
pub data_source: MetricsDataSource,
#[serde(default = "default_v4_cutover")]
pub v4_cutover_date: String,
}
impl Default for MetricsQuery {
fn default() -> Self {
Self {
project_ids: None,
days: default_days(),
data_source: MetricsDataSource::default(),
v4_cutover_date: default_v4_cutover(),
}
}
}
fn default_v4_cutover() -> String {
"2026-09-01 00:00:00".to_string()
}
const fn default_days() -> u32 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsagePoint {
pub day: String,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_tokens: u64,
pub observation_count: u64,
pub generation_count: u64,
pub total_cost: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsageResponse {
pub project_id: String,
pub points: Vec<TokenUsagePoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelBreakdownEntry {
pub project_id: String,
pub model_name: String,
pub observation_count: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_tokens: u64,
pub total_cost: Option<f64>,
pub pricing_tier: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectSummary {
pub project_id: String,
pub total_tokens: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub observation_count: u64,
pub generation_count: u64,
pub total_cost: Option<f64>,
pub unique_models: u64,
pub unique_traces: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LatencyPoint {
pub day: String,
pub project_id: String,
pub model_name: Option<String>,
pub observation_count: u64,
pub avg_duration_seconds: Option<f64>,
pub p50_duration_seconds: Option<f64>,
pub p95_duration_seconds: Option<f64>,
pub p99_duration_seconds: Option<f64>,
pub avg_ttft_seconds: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LatencyResponse {
pub project_id: String,
pub points: Vec<LatencyPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorRatePoint {
pub day: String,
pub project_id: String,
pub total_observations: u64,
pub error_count: u64,
pub error_rate: f64,
pub model_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorRateResponse {
pub project_id: String,
pub points: Vec<ErrorRatePoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheHitRatePoint {
pub project_id: String,
pub model_name: Option<String>,
pub total_input_tokens: u64,
pub cached_tokens: u64,
pub cache_hit_pct: f64,
#[serde(default)]
pub observation_count: u64,
#[serde(default)]
pub cache_measured_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheHitRateResponse {
pub items: Vec<CacheHitRatePoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CostBreakdownPoint {
pub project_id: String,
pub model_name: Option<String>,
pub total_input_cost: Option<f64>,
pub total_output_cost: Option<f64>,
pub total_cost: Option<f64>,
pub observation_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CostBreakdownResponse {
pub items: Vec<CostBreakdownPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTrendPoint {
pub day: String,
pub project_id: String,
pub model_name: Option<String>,
pub call_count: u64,
pub total_tokens: u64,
pub total_cost: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTrendResponse {
pub project_id: String,
pub points: Vec<ModelTrendPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenDistributionPoint {
pub project_id: String,
pub model_name: Option<String>,
pub observation_count: u64,
pub avg_tokens: Option<f64>,
pub p50_tokens: Option<f64>,
pub p95_tokens: Option<f64>,
pub p99_tokens: Option<f64>,
pub max_tokens: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenDistributionResponse {
pub items: Vec<TokenDistributionPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivityPoint {
pub day: String,
pub project_id: String,
pub active_traces: u64,
pub observation_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivityResponse {
pub project_id: String,
pub points: Vec<ActivityPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PricingTierPoint {
pub project_id: String,
pub pricing_tier: Option<String>,
pub model_name: Option<String>,
pub observation_count: u64,
pub total_tokens: u64,
pub total_cost: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PricingTierResponse {
pub items: Vec<PricingTierPoint>,
}
pub const REDACTED: &str = "<redacted>";
pub fn redact_payload(value: &mut Value) {
match value {
Value::Object(fields) => {
for (key, field) in fields.iter_mut() {
if is_sensitive_key(key) {
*field = Value::String(REDACTED.to_string());
} else {
redact_payload(field);
}
}
}
Value::Array(items) => items.iter_mut().for_each(redact_payload),
_ => {}
}
}
fn is_sensitive_key(key: &str) -> bool {
let normalised = key
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(|character| character.to_lowercase())
.collect::<String>();
const EXACT: &[&str] = &[
"apikey",
"authorization",
"gatewaytoken",
"password",
"secret",
"token",
];
if EXACT.contains(&normalised.as_str()) {
return true;
}
["apikey", "secret", "token", "password"]
.iter()
.any(|needle| normalised.ends_with(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scope_serialises_camel_case_and_omits_absent_provenance() {
let value = serde_json::to_value(TraceScope::run("conv-1", "run-1")).expect("serialize");
assert_eq!(value, json!({"conversationId": "conv-1", "runId": "run-1"}));
}
#[test]
fn redaction_masks_credential_fields_at_any_depth() {
let mut payload = json!({
"model": "test-model",
"api_key": "sensitive-api-value",
"headers": {"Authorization": "sensitive-authorization-value"},
"calls": [
{"accessToken": "sensitive-access-value"},
{"clientSecret": "sensitive-client-value"}
],
"nested": {"deep": {"apiKey": "sensitive-nested-value"}}
});
redact_payload(&mut payload);
assert_eq!(payload["model"], "test-model", "non-secrets must survive");
assert_eq!(payload["api_key"], REDACTED);
assert_eq!(payload["headers"]["Authorization"], REDACTED);
assert_eq!(payload["calls"][0]["accessToken"], REDACTED);
assert_eq!(payload["calls"][1]["clientSecret"], REDACTED);
assert_eq!(payload["nested"]["deep"]["apiKey"], REDACTED);
}
#[test]
fn redaction_does_not_mask_lookalike_field_names() {
let mut payload = json!({
"tokenCount": 42,
"secretsManagerArn": "arn:aws:secretsmanager:...",
"passwordPolicy": {"minLength": 12}
});
redact_payload(&mut payload);
assert_eq!(payload["tokenCount"], 42);
assert_eq!(
payload["secretsManagerArn"], "arn:aws:secretsmanager:...",
"a name that merely contains 'secret' is not a secret"
);
assert_eq!(payload["passwordPolicy"]["minLength"], 12);
}
#[test]
fn an_event_round_trips_through_json() {
let record = RuntimeEventRecord {
id: "e-1".into(),
scope: TraceScope::run("conv-1", "run-1"),
event_type: "tool_call".into(),
payload: json!({"name": "execute_cmd"}),
sequence: 3,
created_at_ms: 1_700_000_000_000,
metadata: Value::Null,
};
let encoded = serde_json::to_value(&record).expect("serialize");
assert_eq!(encoded["eventType"], "tool_call");
assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
let decoded: RuntimeEventRecord = serde_json::from_value(encoded).expect("deserialize");
assert_eq!(decoded, record);
}
}