diff --git a/docs/architecture/opentelemetry.md b/docs/architecture/opentelemetry.md new file mode 100644 index 000000000..e22ad63a9 --- /dev/null +++ b/docs/architecture/opentelemetry.md @@ -0,0 +1,32 @@ +# OpenTelemetry Routing Semantics + +Praxis AI can add AI routing decisions to the request trace created and +exported by Praxis core. Build the proxy with: + +```sh +cargo build --release -p praxis-ai-proxy --features opentelemetry +``` + +The feature is disabled by default. It does not install an exporter or parse +OpenTelemetry environment variables. Configure exporting, propagation, +sampling, and request lifecycle tracing through Praxis core. + +After `intelligent_route` successfully selects a provider, the feature emits a +short `routing.select` child span. The span contains bounded routing identity, +admission, locality, rank, tier, and overlay revision attributes. It never +records request or response bodies, prompts, credentials, authorization +headers, cookies, or session keys. + +The division of responsibility is intentional: + +```text +Praxis core request span + | + `-- routing.select (Praxis AI) + | + `-- upstream hop (Praxis core) +``` + +Praxis core owns the complete HTTP span lifetime and transport boundaries. +Praxis AI records only the semantic decision it makes. This prevents duplicate +request roots, conflicting trace propagation, and multiple exporter runtimes. diff --git a/filters/Cargo.toml b/filters/Cargo.toml index 185a344e5..1eeb981bb 100644 --- a/filters/Cargo.toml +++ b/filters/Cargo.toml @@ -15,6 +15,7 @@ name = "praxis_ai_filters" [features] default = ["apis"] apis = ["praxis-ai-apis/default"] +opentelemetry = [] praxis-main = [] [lints] diff --git a/filters/src/lib.rs b/filters/src/lib.rs index 16ced9341..e8aaca3ba 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -11,6 +11,8 @@ pub mod agentic; pub mod guardrails; pub mod inference; +#[cfg(feature = "opentelemetry")] +mod opentelemetry; pub mod prompt_enrich; mod register; pub mod routing; diff --git a/filters/src/opentelemetry.rs b/filters/src/opentelemetry.rs new file mode 100644 index 000000000..ebdb12623 --- /dev/null +++ b/filters/src/opentelemetry.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Feature-gated routing semantics for Praxis core request traces. +//! +//! Praxis core owns subscriber installation, OTLP export, propagation, +//! sampling, request lifecycle spans, and provider-hop client spans. AI owns +//! only the semantic routing decision made by `intelligent_route`. + +use std::sync::Arc; + +use tracing::field::Empty; + +use crate::routing::descriptor::RouteCandidate; + +/// Borrowed, validated attributes for a routing decision span. +struct RoutingSelection<'a> { + /// Producer-assigned admission state label. + admission_state: &'static str, + /// Selected upstream cluster name. + cluster: &'a str, + /// Routed capability kind. + kind: &'static str, + /// Site handling the inbound request. + local_site: &'a str, + /// Selected provider capability name. + provider: &'a str, + /// Producer-assigned candidate rank, when available. + rank: Option, + /// Serving overlay semantic revision, when available. + revision: Option<&'a str>, + /// Site that owns the selected provider. + site: &'a str, + /// Stable identifier assigned to the selected provider. + stable_id: &'a str, + /// Producer-assigned selection tier, when available. + tier: Option<&'a str>, +} + +impl<'a> RoutingSelection<'a> { + /// Project only bounded routing state; request and credential state is not + /// accepted by this constructor. + fn from_candidate( + candidate: &'a RouteCandidate, + local_site: &'a Arc, + semantic_revision: Option<&'a Arc>, + ) -> Self { + Self { + admission_state: candidate.admission_state.as_str(), + cluster: candidate.cluster.as_ref(), + kind: candidate.kind.as_str(), + local_site: local_site.as_ref(), + provider: candidate.name.as_ref(), + rank: candidate.rank, + revision: semantic_revision.map(AsRef::as_ref), + site: candidate.site.as_ref(), + stable_id: candidate.stable_id.as_ref(), + tier: candidate.selection_tier.as_deref(), + } + } +} + +/// Emit a bounded child span for a completed routing decision. +/// +/// No prompt, body, credential, authorization header, cookie, session key, or +/// raw request identifier is recorded. When the feature is disabled this call +/// site is compiled out entirely. +pub(crate) fn record_routing_selection( + candidate: &RouteCandidate, + local_site: &Arc, + semantic_revision: Option<&Arc>, +) { + let selection = RoutingSelection::from_candidate(candidate, local_site, semantic_revision); + let span = tracing::info_span!( + "routing.select", + "selected.provider" = selection.provider, + "selected.cluster" = selection.cluster, + "selected.site" = selection.site, + "selected.stable_id" = selection.stable_id, + "routing.admission_state" = selection.admission_state, + "routing.kind" = selection.kind, + "routing.local_site" = selection.local_site, + "routing.rank" = Empty, + "routing.selection_tier" = Empty, + "overlay.revision" = Empty, + ); + if let Some(rank) = selection.rank { + span.record("routing.rank", rank); + } + if let Some(tier) = selection.tier { + span.record("routing.selection_tier", tier); + } + if let Some(revision) = selection.revision { + span.record("overlay.revision", revision); + } + let _entered = span.enter(); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::RoutingSelection; + use crate::routing::descriptor::{AdmissionState, CapabilityKind, RouteCandidate}; + + #[test] + fn projects_only_validated_routing_attributes() { + let candidate = candidate(); + let local_site = Arc::from("site-local"); + let revision = Arc::from("revision-a"); + let fields = RoutingSelection::from_candidate(&candidate, &local_site, Some(&revision)); + + assert_eq!(fields.provider, "model-a", "provider must match the candidate name"); + assert_eq!(fields.cluster, "provider-a", "cluster must match the selected upstream"); + assert_eq!(fields.site, "site-a", "site must match the provider owner"); + assert_eq!(fields.stable_id, "stable-a", "stable ID must match the candidate"); + assert_eq!( + fields.local_site, "site-local", + "local site must match the routing context" + ); + assert_eq!(fields.rank, Some(2), "rank must preserve producer metadata"); + assert_eq!( + fields.tier, + Some("same_region"), + "selection tier must preserve producer metadata" + ); + assert_eq!( + fields.revision, + Some("revision-a"), + "revision must identify the serving overlay" + ); + } + + #[test] + fn optional_attributes_remain_absent() { + let mut candidate = candidate(); + candidate.rank = None; + candidate.selection_tier = None; + let local_site = Arc::from("site-local"); + let fields = RoutingSelection::from_candidate(&candidate, &local_site, None); + + assert_eq!(fields.rank, None, "missing rank must remain absent"); + assert_eq!(fields.tier, None, "missing selection tier must remain absent"); + assert_eq!(fields.revision, None, "missing overlay revision must remain absent"); + } + + // ------------------------------------------------------------------------- + // Test Utilities + // ------------------------------------------------------------------------- + + /// Build a fully populated candidate for attribute projection tests. + fn candidate() -> RouteCandidate { + RouteCandidate { + admission_state: AdmissionState::NewAndExisting, + cluster: Arc::from("provider-a"), + fresh: true, + kind: CapabilityKind::InferenceModel, + name: Arc::from("model-a"), + rank: Some(2), + selection_tier: Some(Arc::from("same_region")), + site: Arc::from("site-a"), + stable_id: Arc::from("stable-a"), + } + } +} diff --git a/filters/src/routing/intelligent_route.rs b/filters/src/routing/intelligent_route.rs index 7fa4706d4..5b1c2962b 100644 --- a/filters/src/routing/intelligent_route.rs +++ b/filters/src/routing/intelligent_route.rs @@ -661,6 +661,8 @@ fn apply_reused( ctx.cluster = Some(Arc::clone(&candidate.cluster)); record_route_decision(ctx, local_site, candidate); write_provider_context(ctx, candidate, provider_hop_clusters, semantic_revision)?; + #[cfg(feature = "opentelemetry")] + crate::opentelemetry::record_routing_selection(candidate, local_site, semantic_revision); ctx.set_metadata("intelligent_route.session.bound", "true"); ctx.set_metadata("intelligent_route.session.reused", "true"); ctx.set_metadata("intelligent_route.session.failover", "false"); @@ -677,7 +679,10 @@ fn apply_route( ) -> Result<(), FilterError> { ctx.cluster = Some(Arc::clone(&candidate.cluster)); record_route_decision(ctx, local_site, candidate); - write_provider_context(ctx, candidate, provider_hop_clusters, semantic_revision) + write_provider_context(ctx, candidate, provider_hop_clusters, semantic_revision)?; + #[cfg(feature = "opentelemetry")] + crate::opentelemetry::record_routing_selection(candidate, local_site, semantic_revision); + Ok(()) } /// Record session-affinity metadata and store a binding. diff --git a/server/Cargo.toml b/server/Cargo.toml index 0e4018d3f..11ba812c4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,6 +22,7 @@ name = "praxis-ai" path = "src/main.rs" [features] +opentelemetry = ["praxis-ai-filters/opentelemetry"] praxis-main = ["praxis-ai-filters/praxis-main", "praxis-ai-apis/praxis-main"] [lints]