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
1 change: 1 addition & 0 deletions crates/aether-cli/src/headless/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ mod tests {
provider: None,
model: None,
display_name: "test".to_string(),
pricing: None,
attempt,
max_attempts: 3,
})
Expand Down
3 changes: 2 additions & 1 deletion crates/aether-core/src/core/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,8 @@ impl Agent {
AgentEvent::Turn(TurnEvent::LlmCallStarted {
purpose,
provider: model.as_ref().map(|m| m.provider().to_string()),
model: model.map(|m| m.model_id().into_owned()),
model: model.as_ref().map(|m| m.model_id().into_owned()),
pricing: model.and_then(|m| m.pricing()),
display_name: self.llm.display_name(),
attempt,
max_attempts: self.retry_config.max_attempts,
Expand Down
3 changes: 2 additions & 1 deletion crates/aether-core/src/events/turn_event.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use llm::{ContentBlock, StopReason, TokenUsage};
use llm::{ContentBlock, ModelPricing, StopReason, TokenUsage};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -61,6 +61,7 @@ pub enum TurnEvent {
provider: Option<String>,
model: Option<String>,
display_name: String,
pricing: Option<ModelPricing>,
/// 0 for the initial call, incrementing per retry.
attempt: u32,
max_attempts: u32,
Expand Down
6 changes: 6 additions & 0 deletions crates/aether-telemetry/src/genai_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ pub const TOOL_CALL_ID: &str = "tool_call.id";
pub const TOOL_CALL_NAME: &str = "tool_call.name";
pub const LLM_ATTEMPT: &str = "aether.llm.attempt";
pub const LLM_PURPOSE: &str = "aether.llm.purpose";
pub const AI_INPUT_TOKEN_PRICE: &str = "$ai_input_token_price";
pub const AI_OUTPUT_TOKEN_PRICE: &str = "$ai_output_token_price";
pub const AI_CACHE_READ_TOKEN_PRICE: &str = "$ai_cache_read_token_price";
pub const AI_CACHE_WRITE_TOKEN_PRICE: &str = "$ai_cache_write_token_price";
pub const AI_CACHE_REPORTING_EXCLUSIVE: &str = "$ai_cache_reporting_exclusive";
pub const AI_REASONING_TOKENS: &str = "$ai_reasoning_tokens";

pub fn genai_instrumentation_scope(version: impl Into<String>) -> InstrumentationScope {
InstrumentationScope::builder("aether.genai")
Expand Down
16 changes: 11 additions & 5 deletions crates/aether-telemetry/src/llm_call_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ use crate::content_capture::{ContentBuffer, ContentCapture};
use crate::content_json::output_messages_json;
use crate::gen_ai_metrics::GenAiMetrics;
use crate::genai_constants::{
ERROR_TYPE, GEN_AI_OUTPUT_MESSAGES, GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
GEN_AI_TOKEN_TYPE, GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
GENAI_RESPONSE_START_EVENT, GENAI_TOOL_CALL_START_EVENT, MESSAGE_ID, TOOL_CALL_ID, TOOL_CALL_NAME,
AI_CACHE_REPORTING_EXCLUSIVE, AI_REASONING_TOKENS, ERROR_TYPE, GEN_AI_OUTPUT_MESSAGES,
GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK, GEN_AI_TOKEN_TYPE,
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, GENAI_RESPONSE_START_EVENT,
GENAI_TOOL_CALL_START_EVENT, MESSAGE_ID, TOOL_CALL_ID, TOOL_CALL_NAME,
};
use crate::span_guard::{ErrorKind, SpanGuard};
use aether_core::events::{LlmCallOutcome, LlmCallPurpose};
Expand Down Expand Up @@ -122,8 +123,13 @@ impl LlmCallState {
self.span
.set_attribute(KeyValue::new(GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, i64::from(tokens)));
}
if let Some(exclusive) = usage.cache_reporting_exclusive {
self.span.set_attribute(KeyValue::new(AI_CACHE_REPORTING_EXCLUSIVE, exclusive));
}
if let Some(tokens) = usage.reasoning_tokens {
self.span.set_attribute(KeyValue::new(GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, i64::from(tokens)));
let tokens = i64::from(tokens);
self.span.set_attribute(KeyValue::new(GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, tokens));
self.span.set_attribute(KeyValue::new(AI_REASONING_TOKENS, tokens));
}
self.record_token_usage(*usage);
}
Expand Down
36 changes: 34 additions & 2 deletions crates/aether-telemetry/src/otel_observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::llm_call_state::LlmCallState;
use crate::span_guard::{ErrorKind, SpanGuard};
use aether_core::events::{AgentEvent, AgentObserver, LlmCallPurpose, MessageEvent, ToolEvent, TurnEvent, TurnOutcome};
use llm::catalog::Provider;
use llm::{ContentBlock, ToolCallError, ToolCallRequest, ToolCallResult, ToolDefinition};
use llm::{ContentBlock, ModelPricing, ToolCallError, ToolCallRequest, ToolCallResult, ToolDefinition};
use opentelemetry::trace::{SpanBuilder, SpanKind, TraceContextExt, Tracer as _};
use opentelemetry::{Context, KeyValue};
use opentelemetry_sdk::trace::SdkTracer;
Expand Down Expand Up @@ -121,13 +121,22 @@ impl TurnState {

fn on_event(&mut self, message: &AgentEvent, instrumentation: &OtelInstrumentation, tools: &[ToolDefinition]) {
match message {
AgentEvent::Turn(TurnEvent::LlmCallStarted { purpose, provider, model, display_name, attempt, .. }) => {
AgentEvent::Turn(TurnEvent::LlmCallStarted {
purpose,
provider,
model,
display_name,
pricing,
attempt,
..
}) => {
self.start_llm_call(
LlmCallStart {
purpose: *purpose,
provider: provider.as_deref(),
model: model.as_deref(),
display_name,
pricing: *pricing,
attempt: *attempt,
},
instrumentation,
Expand Down Expand Up @@ -207,6 +216,9 @@ impl TurnState {
let mut attributes = metric_attributes.clone();
attributes.push(KeyValue::new(semconv::GEN_AI_REQUEST_STREAM, true));
attributes.push(KeyValue::new(semconv::LLM_ATTEMPT, i64::from(call.attempt)));
if let Some(pricing) = call.pricing {
attributes.extend(pricing_attributes(pricing));
}
// Only chat calls carry the turn's input and tool definitions; a
// compaction call's actual input is the internal summarization prompt.
if call.purpose == LlmCallPurpose::Chat {
Expand Down Expand Up @@ -308,5 +320,25 @@ struct LlmCallStart<'a> {
provider: Option<&'a str>,
model: Option<&'a str>,
display_name: &'a str,
pricing: Option<ModelPricing>,
attempt: u32,
}

fn pricing_attributes(pricing: ModelPricing) -> Vec<KeyValue> {
let mut attributes = vec![
KeyValue::new(semconv::AI_INPUT_TOKEN_PRICE, pricing.input_per_million / TOKENS_PER_MILLION),
KeyValue::new(semconv::AI_OUTPUT_TOKEN_PRICE, pricing.output_per_million / TOKENS_PER_MILLION),
];

if let Some(price) = pricing.cache_read_per_million {
attributes.push(KeyValue::new(semconv::AI_CACHE_READ_TOKEN_PRICE, price / TOKENS_PER_MILLION));
}

if let Some(price) = pricing.cache_write_per_million {
attributes.push(KeyValue::new(semconv::AI_CACHE_WRITE_TOKEN_PRICE, price / TOKENS_PER_MILLION));
}

attributes
}

const TOKENS_PER_MILLION: f64 = 1_000_000.0;
50 changes: 49 additions & 1 deletion crates/aether-telemetry/tests/observer_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use aether_telemetry::{
GENAI_SEMCONV_SCHEMA_URL, GenAiMetrics, OtelInstrumentation, OtelObserver, genai_instrumentation_scope,
};
use llm::testing::llm_response;
use llm::{LlmError, LlmResponse, StopReason, TokenUsage};
use llm::{LlmError, LlmResponse, ModelPricing, StopReason, TokenUsage};
use opentelemetry::metrics::MeterProvider as _;
use opentelemetry::trace::{Status, TracerProvider as _};
use opentelemetry::{Array, Value};
Expand Down Expand Up @@ -179,6 +179,53 @@ async fn completed_llm_calls_capture_finish_reasons() -> Result<(), Box<dyn Erro
Ok(())
}

#[tokio::test]
async fn completed_llm_calls_emit_posthog_custom_pricing_and_token_properties() -> Result<(), Box<dyn Error>> {
let usage = TokenUsage {
cache_read_tokens: Some(40),
cache_creation_tokens: Some(10),
cache_reporting_exclusive: Some(true),
reasoning_tokens: Some(7),
..TokenUsage::new(100, 20)
};
let events = AgentTrace::from_events(vec![
AgentEvent::Turn(TurnEvent::Started { content: vec![] }),
AgentEvent::Turn(TurnEvent::LlmCallStarted {
purpose: LlmCallPurpose::Chat,
provider: Some("anthropic".to_string()),
model: Some("priced-model".to_string()),
display_name: "priced-model".to_string(),
pricing: Some(ModelPricing {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: Some(0.3),
cache_write_per_million: Some(3.75),
}),
attempt: 0,
max_attempts: 1,
}),
AgentEvent::Turn(TurnEvent::LlmCallEnded {
purpose: LlmCallPurpose::Chat,
outcome: LlmCallOutcome::Completed { stop_reason: None, usage: Some(usage) },
}),
AgentEvent::turn_ended(TurnOutcome::Completed),
]);

let spans = otel_test().redacting().observe_trace(&events).spans();
let chat = spans.named("chat priced-model");
chat.assert_attr("$ai_input_token_price", 0.000_003);
chat.assert_attr("$ai_output_token_price", 0.000_015);
chat.assert_attr("$ai_cache_read_token_price", 0.000_000_3);
chat.assert_attr("$ai_cache_write_token_price", 0.000_003_75);
chat.assert_attr("$ai_cache_reporting_exclusive", true);
chat.assert_attr("$ai_reasoning_tokens", 7);
// PostHog derives these from the semconv cache token attributes; duplicating
// them would be redundant.
chat.assert_no_attr("$ai_cache_read_input_tokens");
chat.assert_no_attr("$ai_cache_creation_input_tokens");
Ok(())
}

#[tokio::test]
async fn completed_llm_calls_capture_token_usage_breakdown() -> Result<(), Box<dyn Error>> {
let usage = TokenUsage {
Expand Down Expand Up @@ -372,6 +419,7 @@ fn chat_call(provider: &str, model: &str, outcome: LlmCallOutcome) -> [AgentEven
provider: Some(provider.to_string()),
model: Some(model.to_string()),
display_name: model.to_string(),
pricing: None,
attempt: 0,
max_attempts: 1,
}),
Expand Down
41 changes: 31 additions & 10 deletions crates/aether-telemetry/tests/otlp_collector_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use llm::TokenUsage;
use llm::{ModelPricing, TokenUsage};
use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest;
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
use opentelemetry_proto::tonic::trace::v1::Span;
Expand Down Expand Up @@ -154,14 +154,8 @@ async fn runtime_exports_genai_spans_and_metrics_to_an_otlp_collector() {

runtime.shutdown().expect("runtime flushes both signals");
let exports = collector.exports();
let span_names = exports
.traces
.iter()
.flat_map(|request| &request.resource_spans)
.flat_map(|resource| &resource.scope_spans)
.flat_map(|scope| &scope.spans)
.map(|span| span.name.as_str())
.collect::<Vec<_>>();
let spans = trace_spans(&exports);
let span_names = spans.iter().map(|span| span.name.as_str()).collect::<Vec<_>>();

let metric_names = exports
.metrics
Expand All @@ -174,6 +168,18 @@ async fn runtime_exports_genai_spans_and_metrics_to_an_otlp_collector() {

assert!(span_names.contains(&"invoke_agent"));
assert!(span_names.contains(&"chat test-model"));
let chat = spans.iter().find(|span| span.name == "chat test-model").expect("chat span exported");
let attribute_keys = chat.attributes.iter().map(|attribute| attribute.key.as_str()).collect::<Vec<_>>();
for expected in [
"$ai_input_token_price",
"$ai_output_token_price",
"$ai_cache_read_token_price",
"$ai_cache_write_token_price",
"$ai_cache_reporting_exclusive",
"$ai_reasoning_tokens",
] {
assert!(attribute_keys.contains(&expected), "OTLP attribute {expected} missing from {attribute_keys:?}");
}

assert!(metric_names.contains(&"gen_ai.client.operation.duration"));
assert!(metric_names.contains(&"gen_ai.client.token.usage"));
Expand Down Expand Up @@ -235,12 +241,27 @@ fn events() -> Vec<AgentEvent> {
provider: Some("anthropic".to_string()),
model: Some("test-model".to_string()),
display_name: "test-model".to_string(),
pricing: Some(ModelPricing {
input_per_million: 3.0,
output_per_million: 15.0,
cache_read_per_million: Some(0.3),
cache_write_per_million: Some(3.75),
}),
attempt: 0,
max_attempts: 1,
}),
AgentEvent::Turn(TurnEvent::LlmCallEnded {
purpose: LlmCallPurpose::Chat,
outcome: LlmCallOutcome::Completed { stop_reason: None, usage: Some(TokenUsage::new(10, 5)) },
outcome: LlmCallOutcome::Completed {
stop_reason: None,
usage: Some(TokenUsage {
cache_read_tokens: Some(4),
cache_creation_tokens: Some(2),
cache_reporting_exclusive: Some(true),
reasoning_tokens: Some(3),
..TokenUsage::new(10, 5)
}),
},
}),
AgentEvent::turn_ended(TurnOutcome::Completed),
]
Expand Down
Loading