diff --git a/crates/aether-cli/src/telemetry.rs b/crates/aether-cli/src/telemetry.rs index a5c8d29c9..3a5c54b42 100644 --- a/crates/aether-cli/src/telemetry.rs +++ b/crates/aether-cli/src/telemetry.rs @@ -15,6 +15,8 @@ pub(crate) fn build_telemetry_runtime( TelemetryRuntime::new(&TelemetryConfig { endpoint: settings.otlp.endpoint.clone(), + traces_endpoint: settings.otlp.traces_endpoint.clone(), + metrics_endpoint: settings.otlp.metrics_endpoint.clone(), headers: settings.otlp.headers.clone().into_iter().collect(), service_name: settings.service_name().to_string(), service_version: env!("CARGO_PKG_VERSION").to_string(), diff --git a/crates/aether-project/src/aether_settings.rs b/crates/aether-project/src/aether_settings.rs index 4d247f26a..4a11810ea 100644 --- a/crates/aether-project/src/aether_settings.rs +++ b/crates/aether-project/src/aether_settings.rs @@ -140,8 +140,18 @@ pub struct TelemetrySignalSettings { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OtlpTelemetrySettings { + /// Base URL for an OTLP/HTTP collector. Aether appends `/v1/traces` or + /// `/v1/metrics` for the respective signal. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, + /// Exact OTLP/HTTP trace export URL. Overrides the traces URL derived from + /// `endpoint`, for providers that require a signal-specific endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traces_endpoint: Option, + /// Exact OTLP/HTTP metric export URL. Overrides the metrics URL derived from + /// `endpoint`, for providers that require a signal-specific endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics_endpoint: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, } @@ -178,6 +188,8 @@ impl TelemetrySettings { merge_field(&mut self.traces.enabled, next.traces.enabled); merge_field(&mut self.metrics.enabled, next.metrics.enabled); merge_field(&mut self.otlp.endpoint, next.otlp.endpoint); + merge_field(&mut self.otlp.traces_endpoint, next.otlp.traces_endpoint); + merge_field(&mut self.otlp.metrics_endpoint, next.otlp.metrics_endpoint); self.otlp.headers.extend(next.otlp.headers); } } @@ -190,7 +202,10 @@ impl TelemetrySignalSettings { impl OtlpTelemetrySettings { fn is_unset(&self) -> bool { - self.endpoint.is_none() && self.headers.is_empty() + self.endpoint.is_none() + && self.traces_endpoint.is_none() + && self.metrics_endpoint.is_none() + && self.headers.is_empty() } } @@ -508,7 +523,11 @@ mod tests { "captureContent": true, "traces": { "enabled": true }, "metrics": { "enabled": true }, - "otlp": { "endpoint": "http://localhost:4318" } + "otlp": { + "endpoint": "http://localhost:4318", + "tracesEndpoint": "https://traces.example.com/export", + "metricsEndpoint": "https://metrics.example.com/export" + } }, "agents": [] }"# @@ -533,6 +552,8 @@ mod tests { assert!(telemetry.traces_enabled(), "omitted nested fields remain inherited"); assert!(!telemetry.metrics_enabled(), "nested overrides merge independently"); assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318")); + assert_eq!(telemetry.otlp.traces_endpoint.as_deref(), Some("https://traces.example.com/export")); + assert_eq!(telemetry.otlp.metrics_endpoint.as_deref(), Some("https://metrics.example.com/export")); } #[test] diff --git a/crates/aether-project/src/docs/aether_settings.md b/crates/aether-project/src/docs/aether_settings.md index 099e36ddc..74cffd8aa 100644 --- a/crates/aether-project/src/docs/aether_settings.md +++ b/crates/aether-project/src/docs/aether_settings.md @@ -89,3 +89,5 @@ Prompt, response, reasoning, and tool argument content is not exported unless `c ] } ``` + +For an OTLP backend with exact signal URLs, set `otlp.tracesEndpoint` and `otlp.metricsEndpoint`. Aether sends each configured signal to its matching URL unchanged; an unconfigured signal uses the `/v1/traces` or `/v1/metrics` URL derived from `otlp.endpoint`. diff --git a/crates/aether-telemetry/src/telemetry_runtime.rs b/crates/aether-telemetry/src/telemetry_runtime.rs index 74ef16bb9..0067b99c0 100644 --- a/crates/aether-telemetry/src/telemetry_runtime.rs +++ b/crates/aether-telemetry/src/telemetry_runtime.rs @@ -26,6 +26,12 @@ pub struct TelemetryRuntime { pub struct TelemetryConfig { /// Base URL for the OTLP/HTTP collector, required whenever a signal needs an exporter. pub endpoint: Option, + /// Exact OTLP/HTTP trace export URL. When set, this takes precedence over the + /// trace URL derived from `endpoint`. + pub traces_endpoint: Option, + /// Exact OTLP/HTTP metric export URL. When set, this takes precedence over the + /// metric URL derived from `endpoint`. + pub metrics_endpoint: Option, pub headers: HashMap, pub service_name: String, pub service_version: String, @@ -82,6 +88,15 @@ impl TelemetryRuntime { impl TelemetryConfig { fn signal_endpoint(&self, signal: &str) -> Result { + let signal_specific_endpoint = match signal { + "traces" => self.traces_endpoint.as_deref(), + "metrics" => self.metrics_endpoint.as_deref(), + _ => None, + }; + if let Some(endpoint) = signal_specific_endpoint.filter(|endpoint| !endpoint.is_empty()) { + return Ok(endpoint.to_string()); + } + Ok(format!("{}/v1/{signal}", self.required_endpoint()?.trim_end_matches('/'))) } diff --git a/crates/aether-telemetry/tests/otlp_collector_tests.rs b/crates/aether-telemetry/tests/otlp_collector_tests.rs index 3e1c6b935..f1b662e53 100644 --- a/crates/aether-telemetry/tests/otlp_collector_tests.rs +++ b/crates/aether-telemetry/tests/otlp_collector_tests.rs @@ -13,11 +13,75 @@ use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequ use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; use prost::Message; +#[tokio::test(flavor = "multi_thread")] +async fn runtime_exports_metrics_to_a_signal_specific_endpoint() { + let collector = FakeOtlpCollector::start().await; + let runtime = TelemetryRuntime::new(&TelemetryConfig { + endpoint: None, + traces_endpoint: None, + metrics_endpoint: Some(collector.signal_specific_metrics_endpoint()), + headers: HashMap::from([("x-telemetry-test".to_string(), "signal-specific".to_string())]), + service_name: "aether-test".to_string(), + service_version: "test".to_string(), + sample_ratio: 1.0, + capture_content: false, + traces_enabled: false, + metrics_enabled: true, + }) + .expect("runtime initializes against a signal-specific endpoint"); + { + let factory = runtime.observer_factory(); + let mut observer = factory(); + for event in events() { + observer.on_event(&event); + } + }; + + runtime.shutdown().expect("runtime flushes metrics"); + let exports = collector.exports(); + + assert!(exports.traces.is_empty()); + assert_eq!(exports.metrics.len(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn runtime_exports_traces_to_a_signal_specific_endpoint() { + let collector = FakeOtlpCollector::start().await; + let runtime = TelemetryRuntime::new(&TelemetryConfig { + endpoint: None, + traces_endpoint: Some(collector.signal_specific_traces_endpoint()), + metrics_endpoint: None, + headers: HashMap::from([("x-telemetry-test".to_string(), "signal-specific".to_string())]), + service_name: "aether-test".to_string(), + service_version: "test".to_string(), + sample_ratio: 1.0, + capture_content: false, + traces_enabled: true, + metrics_enabled: false, + }) + .expect("runtime initializes against a signal-specific endpoint"); + { + let factory = runtime.observer_factory(); + let mut observer = factory(); + for event in events() { + observer.on_event(&event); + } + }; + + runtime.shutdown().expect("runtime flushes traces"); + let exports = collector.exports(); + + assert_eq!(exports.traces.len(), 1); + assert!(exports.metrics.is_empty()); +} + #[tokio::test(flavor = "multi_thread")] async fn runtime_exports_genai_spans_and_metrics_to_an_otlp_collector() { let collector = FakeOtlpCollector::start().await; let runtime = TelemetryRuntime::new(&TelemetryConfig { endpoint: Some(collector.endpoint()), + traces_endpoint: None, + metrics_endpoint: None, headers: HashMap::from([("x-telemetry-test".to_string(), "collector-test".to_string())]), service_name: "aether-test".to_string(), service_version: "test".to_string(), @@ -103,6 +167,8 @@ impl FakeOtlpCollector { let exports = Arc::new(Mutex::new(Exports::default())); let app = Router::new() .route("/v1/traces", post(export_traces)) + .route("/i/v0/ai/otel", post(export_traces)) + .route("/custom/metrics", post(export_metrics)) .route("/v1/metrics", post(export_metrics)) .with_state(exports.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind fake collector"); @@ -118,6 +184,14 @@ impl FakeOtlpCollector { format!("http://{}", self.address) } + fn signal_specific_traces_endpoint(&self) -> String { + format!("http://{}/i/v0/ai/otel", self.address) + } + + fn signal_specific_metrics_endpoint(&self) -> String { + format!("http://{}/custom/metrics", self.address) + } + fn exports(&self) -> Exports { let mut exports = self.exports.lock().expect("collector state"); std::mem::take(&mut *exports) diff --git a/crates/aether-telemetry/tests/runtime_tests.rs b/crates/aether-telemetry/tests/runtime_tests.rs index f866844b8..73dcc0a96 100644 --- a/crates/aether-telemetry/tests/runtime_tests.rs +++ b/crates/aether-telemetry/tests/runtime_tests.rs @@ -5,6 +5,8 @@ use std::collections::HashMap; fn disabled_signals_do_not_require_an_endpoint_or_exporters() { let runtime = TelemetryRuntime::new(&TelemetryConfig { endpoint: None, + traces_endpoint: None, + metrics_endpoint: None, traces_enabled: false, metrics_enabled: false, ..test_config() @@ -28,6 +30,8 @@ fn enabled_signals_validate_their_endpoint() { for (traces_enabled, metrics_enabled) in [(true, false), (false, true)] { let result = TelemetryRuntime::new(&TelemetryConfig { endpoint: Some("not a valid endpoint".to_string()), + traces_endpoint: None, + metrics_endpoint: None, traces_enabled, metrics_enabled, ..test_config() @@ -69,6 +73,8 @@ fn headers_are_validated_even_when_signals_are_disabled() { fn test_config() -> TelemetryConfig { TelemetryConfig { endpoint: Some("http://localhost:4318".to_string()), + traces_endpoint: None, + metrics_endpoint: None, headers: HashMap::new(), service_name: "aether".to_string(), service_version: "test".to_string(), diff --git a/packages/aether-sdk/package.json b/packages/aether-sdk/package.json index d8c4c77e4..9431851bf 100644 --- a/packages/aether-sdk/package.json +++ b/packages/aether-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@aether-agent/sdk", - "version": "0.3.5", + "version": "0.3.6", "description": "TypeScript SDK for the Aether agent CLI", "repository": { "type": "git", @@ -27,7 +27,7 @@ "e2e": "node scripts/e2e.mjs" }, "dependencies": { - "@aether-agent/cli": "^0.7.14", + "@aether-agent/cli": "^0.7.22", "@agentclientprotocol/sdk": "^0.25.1", "@modelcontextprotocol/sdk": "^1.29.0", "express": "^5.1.0", diff --git a/packages/aether-sdk/src/session.ts b/packages/aether-sdk/src/session.ts index be978dea4..740a8ad39 100644 --- a/packages/aether-sdk/src/session.ts +++ b/packages/aether-sdk/src/session.ts @@ -16,7 +16,7 @@ import type { AgentSelection, } from "./types.js"; -const SDK_VERSION = "0.2.3"; +const SDK_VERSION = "0.3.6"; export type PermissionRequestHandler = ( request: acp.RequestPermissionRequest, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b1f657bc..296ab148d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ importers: packages/aether-sdk: dependencies: '@aether-agent/cli': - specifier: ^0.7.14 - version: 0.7.14 + specifier: ^0.7.22 + version: 0.7.22 '@agentclientprotocol/sdk': specifier: ^0.25.1 version: 0.25.1(zod@4.4.3) @@ -146,8 +146,8 @@ importers: packages: - '@aether-agent/cli@0.7.14': - resolution: {integrity: sha512-II19y/rxqj9+tkQK+KuZvHsbdzm0oGKd7/yBrFylUy/oUR2QhN0YTcb4x5t16dgazx+WiQRjB38pFmzV3rwJeg==} + '@aether-agent/cli@0.7.22': + resolution: {integrity: sha512-/TOSPbVNXsdSB8LPJFPq+qvtN227EyzYYYrwPvPsS1J79oTmDKxTCrGQpSgOG/z+2fdqXcQu+WKlEN4FSkcMcg==} engines: {node: '>=14', npm: '>=6'} hasBin: true @@ -4416,7 +4416,7 @@ packages: snapshots: - '@aether-agent/cli@0.7.14': + '@aether-agent/cli@0.7.22': dependencies: axios: 1.18.0 axios-proxy-builder: 0.1.2