Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/aether-cli/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
25 changes: 23 additions & 2 deletions crates/aether-project/src/aether_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// 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<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
}
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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": []
}"#
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions crates/aether-project/src/docs/aether_settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
15 changes: 15 additions & 0 deletions crates/aether-telemetry/src/telemetry_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Exact OTLP/HTTP trace export URL. When set, this takes precedence over the
/// trace URL derived from `endpoint`.
pub traces_endpoint: Option<String>,
/// Exact OTLP/HTTP metric export URL. When set, this takes precedence over the
/// metric URL derived from `endpoint`.
pub metrics_endpoint: Option<String>,
pub headers: HashMap<String, String>,
pub service_name: String,
pub service_version: String,
Expand Down Expand Up @@ -82,6 +88,15 @@ impl TelemetryRuntime {

impl TelemetryConfig {
fn signal_endpoint(&self, signal: &str) -> Result<String, TelemetryInitError> {
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('/')))
}

Expand Down
74 changes: 74 additions & 0 deletions crates/aether-telemetry/tests/otlp_collector_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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");
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions crates/aether-telemetry/tests/runtime_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions packages/aether-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/aether-sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.