Skip to content

Commit 6a0f237

Browse files
authored
chore(ai): Add metric for tracking ai cost calculation (#5560)
1 parent 75d7884 commit 6a0f237

File tree

4 files changed

+154
-8
lines changed

4 files changed

+154
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Use new processor architecture to process transactions. ([#5379](https://github.com/getsentry/relay/pull/5379))
1515
- Add `gen_ai_response_time_to_first_token` as a `SpanData` attribute. ([#5575](https://github.com/getsentry/relay/pull/5575))
1616
- Add sampling to expensive envelope buffer statsd metrics. ([#5576](https://github.com/getsentry/relay/pull/5576))
17+
- Add `gen_ai.cost_calculation.result` metric to track AI cost calculation outcomes by integration and platform. ([#5560](https://github.com/getsentry/relay/pull/5560))
1718
- Normalizes and validates trace metric names. ([#5589](https://github.com/getsentry/relay/pull/5589))
1819

1920
## 26.1.0

relay-event-normalization/src/eap/ai.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use relay_protocol::Annotated;
66

77
use crate::ModelCosts;
88
use crate::span::ai;
9+
use crate::statsd::{map_origin_to_integration, platform_tag};
910

1011
/// Normalizes AI attributes.
1112
///
@@ -110,6 +111,9 @@ fn normalize_tokens_per_second(attributes: &mut Attributes, duration: Option<Dur
110111

111112
/// Calculates model costs and serializes them into attributes.
112113
fn normalize_ai_costs(attributes: &mut Attributes, model_costs: Option<&ModelCosts>) {
114+
let origin = extract_string_value(attributes, ORIGIN);
115+
let platform = extract_string_value(attributes, PLATFORM);
116+
113117
let model_cost = attributes
114118
.get_value(GEN_AI_REQUEST_MODEL)
115119
.or_else(|| attributes.get_value(GEN_AI_RESPONSE_MODEL))
@@ -133,7 +137,10 @@ fn normalize_ai_costs(attributes: &mut Attributes, model_costs: Option<&ModelCos
133137
output_reasoning_tokens: get_tokens(GEN_AI_USAGE_OUTPUT_REASONING_TOKENS),
134138
};
135139

136-
let Some(costs) = ai::calculate_costs(model_cost, tokens) else {
140+
let integration = map_origin_to_integration(origin);
141+
let platform = platform_tag(platform);
142+
143+
let Some(costs) = ai::calculate_costs(model_cost, tokens, integration, platform) else {
137144
return;
138145
};
139146

@@ -143,6 +150,10 @@ fn normalize_ai_costs(attributes: &mut Attributes, model_costs: Option<&ModelCos
143150
attributes.insert(GEN_AI_COST_TOTAL_TOKENS, costs.total());
144151
}
145152

153+
fn extract_string_value<'a>(attributes: &'a Attributes, key: &str) -> Option<&'a str> {
154+
attributes.get_value(key).and_then(|v| v.as_str())
155+
}
156+
146157
#[cfg(test)]
147158
mod tests {
148159
use std::collections::HashMap;

relay-event-normalization/src/normalize/span/ai.rs

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! AI cost calculation.
22
3+
use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
34
use crate::{ModelCostV2, ModelCosts};
45
use relay_event_schema::protocol::{
56
Event, Measurements, OperationType, Span, SpanData, TraceContext,
@@ -84,8 +85,19 @@ impl CalculatedCost {
8485
/// Calculates the total cost for a model call.
8586
///
8687
/// Returns `None` if no tokens were used.
87-
pub fn calculate_costs(model_cost: &ModelCostV2, tokens: UsedTokens) -> Option<CalculatedCost> {
88+
pub fn calculate_costs(
89+
model_cost: &ModelCostV2,
90+
tokens: UsedTokens,
91+
integration: &str,
92+
platform: &str,
93+
) -> Option<CalculatedCost> {
8894
if !tokens.has_usage() {
95+
relay_statsd::metric!(
96+
counter(Counters::GenAiCostCalculationResult) += 1,
97+
result = "calculation_none",
98+
integration = integration,
99+
platform = platform,
100+
);
89101
return None;
90102
}
91103

@@ -103,6 +115,19 @@ pub fn calculate_costs(model_cost: &ModelCostV2, tokens: UsedTokens) -> Option<C
103115
let output = (tokens.raw_output_tokens() * model_cost.output_per_token)
104116
+ (tokens.output_reasoning_tokens * reasoning_cost);
105117

118+
let metric_label = match (input, output) {
119+
(x, y) if x < 0.0 || y < 0.0 => "calculation_negative",
120+
(0.0, 0.0) => "calculation_zero",
121+
_ => "calculation_positive",
122+
};
123+
124+
relay_statsd::metric!(
125+
counter(Counters::GenAiCostCalculationResult) += 1,
126+
result = metric_label,
127+
integration = integration,
128+
platform = platform,
129+
);
130+
106131
Some(CalculatedCost { input, output })
107132
}
108133

@@ -158,11 +183,18 @@ pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
158183

159184
/// Calculates the cost of an AI model based on the model cost and the tokens used.
160185
/// Calculated cost is in US dollars.
161-
fn extract_ai_model_cost_data(model_cost: Option<&ModelCostV2>, data: &mut SpanData) {
186+
fn extract_ai_model_cost_data(
187+
model_cost: Option<&ModelCostV2>,
188+
data: &mut SpanData,
189+
origin: Option<&str>,
190+
platform: Option<&str>,
191+
) {
162192
let Some(model_cost) = model_cost else { return };
163193

164194
let used_tokens = UsedTokens::from_span_data(&*data);
165-
let Some(costs) = calculate_costs(model_cost, used_tokens) else {
195+
let integration = map_origin_to_integration(origin);
196+
let platform = platform_tag(platform);
197+
let Some(costs) = calculate_costs(model_cost, used_tokens, integration, platform) else {
166198
return;
167199
};
168200

@@ -220,7 +252,13 @@ fn set_total_tokens(data: &mut SpanData) {
220252
}
221253

222254
/// Extract the additional data into the span
223-
fn extract_ai_data(data: &mut SpanData, duration: f64, ai_model_costs: &ModelCosts) {
255+
fn extract_ai_data(
256+
data: &mut SpanData,
257+
duration: f64,
258+
ai_model_costs: &ModelCosts,
259+
origin: Option<&str>,
260+
platform: Option<&str>,
261+
) {
224262
// Extracts the response tokens per second
225263
if data.gen_ai_response_tokens_per_second.value().is_none()
226264
&& duration > 0.0
@@ -244,7 +282,12 @@ fn extract_ai_data(data: &mut SpanData, duration: f64, ai_model_costs: &ModelCos
244282
.and_then(|val| val.as_str())
245283
})
246284
{
247-
extract_ai_model_cost_data(ai_model_costs.cost_per_token(model_id), data)
285+
extract_ai_model_cost_data(
286+
ai_model_costs.cost_per_token(model_id),
287+
data,
288+
origin,
289+
platform,
290+
)
248291
}
249292
}
250293

@@ -255,6 +298,8 @@ fn enrich_ai_span_data(
255298
measurements: &Annotated<Measurements>,
256299
duration: f64,
257300
model_costs: Option<&ModelCosts>,
301+
origin: Option<&str>,
302+
platform: Option<&str>,
258303
) {
259304
if !is_ai_span(span_data, span_op.value()) {
260305
return;
@@ -267,7 +312,7 @@ fn enrich_ai_span_data(
267312
set_total_tokens(data);
268313

269314
if let Some(model_costs) = model_costs {
270-
extract_ai_data(data, duration, model_costs);
315+
extract_ai_data(data, duration, model_costs, origin, platform);
271316
}
272317

273318
let ai_op_type = data
@@ -294,6 +339,8 @@ pub fn enrich_ai_span(span: &mut Span, model_costs: Option<&ModelCosts>) {
294339
&span.measurements,
295340
duration,
296341
model_costs,
342+
span.origin.as_str(),
343+
span.platform.as_str(),
297344
);
298345
}
299346

@@ -316,6 +363,8 @@ pub fn enrich_ai_event_data(event: &mut Event, model_costs: Option<&ModelCosts>)
316363
&event.measurements,
317364
event_duration,
318365
model_costs,
366+
trace_context.origin.as_str(),
367+
event.platform.as_str(),
319368
);
320369
}
321370
let spans = event.spans.value_mut().iter_mut().flatten();
@@ -326,13 +375,16 @@ pub fn enrich_ai_event_data(event: &mut Event, model_costs: Option<&ModelCosts>)
326375
.get_value("span.duration")
327376
.and_then(|v| v.as_f64())
328377
.unwrap_or(0.0);
378+
let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
329379

330380
enrich_ai_span_data(
331381
&mut span.data,
332382
&span.op,
333383
&span.measurements,
334384
span_duration,
335385
model_costs,
386+
span.origin.as_str(),
387+
span_platform,
336388
);
337389
}
338390
}
@@ -378,6 +430,8 @@ mod tests {
378430
input_cache_write_per_token: 1.0,
379431
},
380432
UsedTokens::from_span_data(&SpanData::default()),
433+
"test",
434+
"test",
381435
);
382436
assert!(cost.is_none());
383437
}
@@ -399,6 +453,8 @@ mod tests {
399453
output_tokens: 15.0,
400454
output_reasoning_tokens: 9.0,
401455
},
456+
"test",
457+
"test",
402458
)
403459
.unwrap();
404460

@@ -428,6 +484,8 @@ mod tests {
428484
output_tokens: 15.0,
429485
output_reasoning_tokens: 9.0,
430486
},
487+
"test",
488+
"test",
431489
)
432490
.unwrap();
433491

@@ -459,6 +517,8 @@ mod tests {
459517
output_tokens: 1.0,
460518
output_reasoning_tokens: 9.0,
461519
},
520+
"test",
521+
"test",
462522
)
463523
.unwrap();
464524

@@ -487,6 +547,8 @@ mod tests {
487547
output_tokens: 50.0,
488548
output_reasoning_tokens: 10.0,
489549
},
550+
"test",
551+
"test",
490552
)
491553
.unwrap();
492554

@@ -523,6 +585,8 @@ mod tests {
523585
input_cache_write_per_token: 0.75,
524586
},
525587
tokens,
588+
"test",
589+
"test",
526590
)
527591
.unwrap();
528592

relay-event-normalization/src/statsd.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
1-
use relay_statsd::TimerMetric;
1+
use relay_statsd::{CounterMetric, TimerMetric};
2+
3+
pub enum Counters {
4+
/// Records the status of the AI cost calculation.
5+
///
6+
/// The metric is tagged with:
7+
/// - `result`: The outcome of the cost calculation. Possible values are:
8+
/// `calculation_negative`,
9+
/// `calculation_zero`,
10+
/// `calculation_positive`,
11+
/// `calculation_none`
12+
/// - `integration`: The integration used for the cost calculation.
13+
/// - `platform`: The platform used for the cost calculation.
14+
GenAiCostCalculationResult,
15+
}
16+
17+
impl CounterMetric for Counters {
18+
fn name(&self) -> &'static str {
19+
match *self {
20+
Self::GenAiCostCalculationResult => "gen_ai.cost_calculation.result",
21+
}
22+
}
23+
}
224

325
pub enum Timers {
426
/// Measures how log normalization of SQL queries in span description take.
@@ -15,3 +37,51 @@ impl TimerMetric for Timers {
1537
}
1638
}
1739
}
40+
41+
/// Maps a span origin to a well-known AI integration name for metrics.
42+
///
43+
/// Origins follow the pattern `auto.<integration>.<source>` or `auto.<category>.<protocol>.<source>`.
44+
/// This function extracts recognized AI integrations for cleaner metric tagging.
45+
pub fn map_origin_to_integration(origin: Option<&str>) -> &'static str {
46+
match origin {
47+
Some(o) if o.starts_with("auto.ai.openai_agents") => "openai_agents",
48+
Some(o) if o.starts_with("auto.ai.openai") => "openai",
49+
Some(o) if o.starts_with("auto.ai.anthropic") => "anthropic",
50+
Some(o) if o.starts_with("auto.ai.cohere") => "cohere",
51+
Some(o) if o.starts_with("auto.vercelai.") => "vercelai",
52+
Some(o) if o.starts_with("auto.ai.langchain") => "langchain",
53+
Some(o) if o.starts_with("auto.ai.langgraph") => "langgraph",
54+
Some(o) if o.starts_with("auto.ai.google_genai") => "google_genai",
55+
Some(o) if o.starts_with("auto.ai.pydantic_ai") => "pydantic_ai",
56+
Some(o) if o.starts_with("auto.ai.huggingface_hub") => "huggingface_hub",
57+
Some(o) if o.starts_with("auto.ai.litellm") => "litellm",
58+
Some(o) if o.starts_with("auto.ai.mcp_server") => "mcp_server",
59+
Some(o) if o.starts_with("auto.ai.mcp") => "mcp",
60+
Some(o) if o.starts_with("auto.ai.claude_agent_sdk") => "claude_agent_sdk",
61+
Some(o) if o.starts_with("auto.ai.") => "other",
62+
Some(_) => "other",
63+
None => "unknown",
64+
}
65+
}
66+
67+
pub fn platform_tag(platform: Option<&str>) -> &'static str {
68+
match platform {
69+
Some("cocoa") => "cocoa",
70+
Some("csharp") => "csharp",
71+
Some("edge") => "edge",
72+
Some("go") => "go",
73+
Some("java") => "java",
74+
Some("javascript") => "javascript",
75+
Some("julia") => "julia",
76+
Some("native") => "native",
77+
Some("node") => "node",
78+
Some("objc") => "objc",
79+
Some("perl") => "perl",
80+
Some("php") => "php",
81+
Some("python") => "python",
82+
Some("ruby") => "ruby",
83+
Some("swift") => "swift",
84+
Some(_) => "other",
85+
None => "unknown",
86+
}
87+
}

0 commit comments

Comments
 (0)