1use std::time::Duration;
2
3use relay_conventions::consts::*;
4use relay_event_schema::protocol::Attributes;
5use relay_protocol::Annotated;
6
7use crate::ModelCosts;
8use crate::span::ai;
9use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
10
11pub fn normalize_ai(
23 attributes: &mut Annotated<Attributes>,
24 duration: Option<Duration>,
25 costs: Option<&ModelCosts>,
26) {
27 let Some(attributes) = attributes.value_mut() else {
28 return;
29 };
30
31 if !is_ai_item(attributes) {
34 return;
35 }
36
37 normalize_model(attributes);
38 normalize_ai_type(attributes);
39 normalize_total_tokens(attributes);
40 normalize_tokens_per_second(attributes, duration);
41 normalize_ai_costs(attributes, costs);
42}
43
44fn is_ai_item(attributes: &mut Attributes) -> bool {
46 if attributes.get_value(GEN_AI_OPERATION_TYPE).is_some() {
48 return true;
49 }
50
51 if attributes.get_value(GEN_AI_OPERATION_NAME).is_some() {
53 return true;
54 }
55
56 let op = attributes.get_value(OP).and_then(|op| op.as_str());
58 if op.is_some_and(|op| op.starts_with("gen_ai.") || op.starts_with("ai.")) {
59 return true;
60 }
61
62 false
63}
64
65fn normalize_model(attributes: &mut Attributes) {
67 if attributes.contains_key(GEN_AI_RESPONSE_MODEL) {
68 return;
69 }
70 let Some(model) = attributes
71 .get_value(GEN_AI_REQUEST_MODEL)
72 .and_then(|v| v.as_str())
73 else {
74 return;
75 };
76 attributes.insert(GEN_AI_RESPONSE_MODEL, model.to_owned());
77}
78
79fn normalize_ai_type(attributes: &mut Attributes) {
81 let op_name = attributes
82 .get_value(GEN_AI_OPERATION_NAME)
83 .or_else(|| attributes.get_value(OP))
84 .and_then(|op| op.as_str())
85 .and_then(|op| ai::infer_ai_operation_type(op))
86 .unwrap_or(ai::DEFAULT_AI_OPERATION);
88
89 attributes.insert(GEN_AI_OPERATION_TYPE, op_name.to_owned());
90}
91
92fn normalize_total_tokens(attributes: &mut Attributes) {
94 let input_tokens = attributes
95 .get_value(GEN_AI_USAGE_INPUT_TOKENS)
96 .and_then(|v| v.as_f64());
97
98 let output_tokens = attributes
99 .get_value(GEN_AI_USAGE_OUTPUT_TOKENS)
100 .and_then(|v| v.as_f64());
101
102 if input_tokens.is_none() && output_tokens.is_none() {
103 return;
104 }
105
106 let total_tokens = input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0);
107 attributes.insert(GEN_AI_USAGE_TOTAL_TOKENS, total_tokens);
108}
109
110fn normalize_tokens_per_second(attributes: &mut Attributes, duration: Option<Duration>) {
112 let Some(duration) = duration.filter(|d| !d.is_zero()) else {
113 return;
114 };
115
116 let output_tokens = attributes
117 .get_value(GEN_AI_USAGE_OUTPUT_TOKENS)
118 .and_then(|v| v.as_f64())
119 .filter(|v| *v > 0.0);
120
121 if let Some(output_tokens) = output_tokens {
122 let tps = output_tokens / duration.as_secs_f64();
123 attributes.insert(GEN_AI_RESPONSE_TPS, tps);
124 }
125}
126
127fn normalize_ai_costs(attributes: &mut Attributes, model_costs: Option<&ModelCosts>) {
129 let origin = extract_string_value(attributes, ORIGIN);
130 let platform = extract_string_value(attributes, PLATFORM);
131
132 let integration = map_origin_to_integration(origin);
133 let platform_tag = platform_tag(platform);
134
135 let Some(model_id) = attributes
136 .get_value(GEN_AI_RESPONSE_MODEL)
137 .and_then(|v| v.as_str())
138 else {
139 relay_statsd::metric!(
140 counter(Counters::GenAiCostCalculationResult) += 1,
141 result = "calculation_no_model_id_available",
142 integration = integration,
143 platform = platform_tag,
144 );
145 return;
146 };
147
148 let Some(model_cost) = model_costs.and_then(|c| c.cost_per_token(model_id)) else {
149 relay_statsd::metric!(
150 counter(Counters::GenAiCostCalculationResult) += 1,
151 result = "calculation_no_model_cost_available",
152 integration = integration,
153 platform = platform_tag,
154 );
155 return;
156 };
157
158 let get_tokens = |key| {
159 attributes
160 .get_value(key)
161 .and_then(|v| v.as_f64())
162 .unwrap_or(0.0)
163 };
164
165 let tokens = ai::UsedTokens {
166 input_tokens: get_tokens(GEN_AI_USAGE_INPUT_TOKENS),
167 input_cached_tokens: get_tokens(GEN_AI_USAGE_INPUT_CACHED_TOKENS),
168 input_cache_write_tokens: get_tokens(GEN_AI_USAGE_INPUT_CACHE_WRITE_TOKENS),
169 output_tokens: get_tokens(GEN_AI_USAGE_OUTPUT_TOKENS),
170 output_reasoning_tokens: get_tokens(GEN_AI_USAGE_OUTPUT_REASONING_TOKENS),
171 };
172
173 let Some(costs) = ai::calculate_costs(model_cost, tokens, integration, platform_tag) else {
174 return;
175 };
176
177 attributes.insert(GEN_AI_COST_INPUT_TOKENS, costs.input);
179 attributes.insert(GEN_AI_COST_OUTPUT_TOKENS, costs.output);
180 attributes.insert(GEN_AI_COST_TOTAL_TOKENS, costs.total());
181}
182
183fn extract_string_value<'a>(attributes: &'a Attributes, key: &str) -> Option<&'a str> {
184 attributes.get_value(key).and_then(|v| v.as_str())
185}
186
187#[cfg(test)]
188mod tests {
189 use std::collections::HashMap;
190
191 use relay_pattern::Pattern;
192 use relay_protocol::{Empty, assert_annotated_snapshot};
193
194 use crate::ModelCostV2;
195
196 use super::*;
197
198 macro_rules! attributes {
199 ($($key:expr => $value:expr),* $(,)?) => {
200 Attributes::from([
201 $(($key.into(), Annotated::new($value.into())),)*
202 ])
203 };
204 }
205
206 fn model_costs() -> ModelCosts {
207 ModelCosts {
208 version: 2,
209 models: HashMap::from([
210 (
211 Pattern::new("claude-2.1").unwrap(),
212 ModelCostV2 {
213 input_per_token: 0.01,
214 output_per_token: 0.02,
215 output_reasoning_per_token: 0.03,
216 input_cached_per_token: 0.04,
217 input_cache_write_per_token: 0.0,
218 },
219 ),
220 (
221 Pattern::new("gpt4-21-04").unwrap(),
222 ModelCostV2 {
223 input_per_token: 0.09,
224 output_per_token: 0.05,
225 output_reasoning_per_token: 0.0,
226 input_cached_per_token: 0.0,
227 input_cache_write_per_token: 0.0,
228 },
229 ),
230 ]),
231 }
232 }
233
234 #[test]
235 fn test_normalize_ai_all_tokens() {
236 let mut attributes = Annotated::new(attributes! {
237 "gen_ai.operation.type" => "ai_client".to_owned(),
238 "gen_ai.usage.input_tokens" => 1000,
239 "gen_ai.usage.output_tokens" => 2000,
240 "gen_ai.usage.output_tokens.reasoning" => 1000,
241 "gen_ai.usage.input_tokens.cached" => 500,
242 "gen_ai.request.model" => "claude-2.1".to_owned(),
243 });
244
245 normalize_ai(
246 &mut attributes,
247 Some(Duration::from_secs(1)),
248 Some(&model_costs()),
249 );
250
251 assert_annotated_snapshot!(attributes, @r#"
252 {
253 "gen_ai.cost.input_tokens": {
254 "type": "double",
255 "value": 25.0
256 },
257 "gen_ai.cost.output_tokens": {
258 "type": "double",
259 "value": 50.0
260 },
261 "gen_ai.cost.total_tokens": {
262 "type": "double",
263 "value": 75.0
264 },
265 "gen_ai.operation.type": {
266 "type": "string",
267 "value": "ai_client"
268 },
269 "gen_ai.request.model": {
270 "type": "string",
271 "value": "claude-2.1"
272 },
273 "gen_ai.response.model": {
274 "type": "string",
275 "value": "claude-2.1"
276 },
277 "gen_ai.response.tokens_per_second": {
278 "type": "double",
279 "value": 2000.0
280 },
281 "gen_ai.usage.input_tokens": {
282 "type": "integer",
283 "value": 1000
284 },
285 "gen_ai.usage.input_tokens.cached": {
286 "type": "integer",
287 "value": 500
288 },
289 "gen_ai.usage.output_tokens": {
290 "type": "integer",
291 "value": 2000
292 },
293 "gen_ai.usage.output_tokens.reasoning": {
294 "type": "integer",
295 "value": 1000
296 },
297 "gen_ai.usage.total_tokens": {
298 "type": "double",
299 "value": 3000.0
300 }
301 }
302 "#);
303 }
304
305 #[test]
306 fn test_normalize_ai_basic_tokens() {
307 let mut attributes = Annotated::new(attributes! {
308 "gen_ai.operation.type" => "ai_client".to_owned(),
309 "gen_ai.usage.input_tokens" => 1000,
310 "gen_ai.usage.output_tokens" => 2000,
311 "gen_ai.request.model" => "gpt4-21-04".to_owned(),
312 });
313
314 normalize_ai(
315 &mut attributes,
316 Some(Duration::from_millis(500)),
317 Some(&model_costs()),
318 );
319
320 assert_annotated_snapshot!(attributes, @r#"
321 {
322 "gen_ai.cost.input_tokens": {
323 "type": "double",
324 "value": 90.0
325 },
326 "gen_ai.cost.output_tokens": {
327 "type": "double",
328 "value": 100.0
329 },
330 "gen_ai.cost.total_tokens": {
331 "type": "double",
332 "value": 190.0
333 },
334 "gen_ai.operation.type": {
335 "type": "string",
336 "value": "ai_client"
337 },
338 "gen_ai.request.model": {
339 "type": "string",
340 "value": "gpt4-21-04"
341 },
342 "gen_ai.response.model": {
343 "type": "string",
344 "value": "gpt4-21-04"
345 },
346 "gen_ai.response.tokens_per_second": {
347 "type": "double",
348 "value": 4000.0
349 },
350 "gen_ai.usage.input_tokens": {
351 "type": "integer",
352 "value": 1000
353 },
354 "gen_ai.usage.output_tokens": {
355 "type": "integer",
356 "value": 2000
357 },
358 "gen_ai.usage.total_tokens": {
359 "type": "double",
360 "value": 3000.0
361 }
362 }
363 "#);
364 }
365
366 #[test]
367 fn test_normalize_ai_basic_tokens_no_duration_no_cost() {
368 let mut attributes = Annotated::new(attributes! {
369 "gen_ai.operation.type" => "ai_client".to_owned(),
370 "gen_ai.usage.input_tokens" => 1000,
371 "gen_ai.usage.output_tokens" => 2000,
372 "gen_ai.request.model" => "unknown".to_owned(),
373 });
374
375 normalize_ai(&mut attributes, Some(Duration::ZERO), Some(&model_costs()));
376
377 assert_annotated_snapshot!(attributes, @r#"
378 {
379 "gen_ai.operation.type": {
380 "type": "string",
381 "value": "ai_client"
382 },
383 "gen_ai.request.model": {
384 "type": "string",
385 "value": "unknown"
386 },
387 "gen_ai.response.model": {
388 "type": "string",
389 "value": "unknown"
390 },
391 "gen_ai.usage.input_tokens": {
392 "type": "integer",
393 "value": 1000
394 },
395 "gen_ai.usage.output_tokens": {
396 "type": "integer",
397 "value": 2000
398 },
399 "gen_ai.usage.total_tokens": {
400 "type": "double",
401 "value": 3000.0
402 }
403 }
404 "#);
405 }
406
407 #[test]
408 fn test_normalize_ai_does_not_overwrite() {
409 let mut attributes = Annotated::new(attributes! {
410 "gen_ai.operation.type" => "ai_client".to_owned(),
411 "gen_ai.usage.input_tokens" => 1000,
412 "gen_ai.usage.output_tokens" => 2000,
413 "gen_ai.request.model" => "gpt4".to_owned(),
414 "gen_ai.response.model" => "gpt4-21-04".to_owned(),
415
416 "gen_ai.cost.input_tokens" => 999.0,
417 });
418
419 normalize_ai(
420 &mut attributes,
421 Some(Duration::from_millis(500)),
422 Some(&model_costs()),
423 );
424
425 assert_annotated_snapshot!(attributes, @r#"
426 {
427 "gen_ai.cost.input_tokens": {
428 "type": "double",
429 "value": 90.0
430 },
431 "gen_ai.cost.output_tokens": {
432 "type": "double",
433 "value": 100.0
434 },
435 "gen_ai.cost.total_tokens": {
436 "type": "double",
437 "value": 190.0
438 },
439 "gen_ai.operation.type": {
440 "type": "string",
441 "value": "ai_client"
442 },
443 "gen_ai.request.model": {
444 "type": "string",
445 "value": "gpt4"
446 },
447 "gen_ai.response.model": {
448 "type": "string",
449 "value": "gpt4-21-04"
450 },
451 "gen_ai.response.tokens_per_second": {
452 "type": "double",
453 "value": 4000.0
454 },
455 "gen_ai.usage.input_tokens": {
456 "type": "integer",
457 "value": 1000
458 },
459 "gen_ai.usage.output_tokens": {
460 "type": "integer",
461 "value": 2000
462 },
463 "gen_ai.usage.total_tokens": {
464 "type": "double",
465 "value": 3000.0
466 }
467 }
468 "#);
469 }
470
471 #[test]
472 fn test_normalize_ai_overwrite_costs() {
473 let mut attributes = Annotated::new(attributes! {
474 "gen_ai.operation.type" => "ai_client".to_owned(),
475 "gen_ai.usage.input_tokens" => 1000,
476 "gen_ai.usage.output_tokens" => 2000,
477 "gen_ai.request.model" => "gpt4-21-04".to_owned(),
478
479 "gen_ai.usage.total_tokens" => 1337,
480
481 "gen_ai.cost.input_tokens" => 99.0,
482 "gen_ai.cost.output_tokens" => 99.0,
483 "gen_ai.cost.total_tokens" => 123.0,
484
485 "gen_ai.response.tokens_per_second" => 42.0,
486 });
487
488 normalize_ai(
489 &mut attributes,
490 Some(Duration::from_millis(500)),
491 Some(&model_costs()),
492 );
493
494 assert_annotated_snapshot!(attributes, @r#"
495 {
496 "gen_ai.cost.input_tokens": {
497 "type": "double",
498 "value": 90.0
499 },
500 "gen_ai.cost.output_tokens": {
501 "type": "double",
502 "value": 100.0
503 },
504 "gen_ai.cost.total_tokens": {
505 "type": "double",
506 "value": 190.0
507 },
508 "gen_ai.operation.type": {
509 "type": "string",
510 "value": "ai_client"
511 },
512 "gen_ai.request.model": {
513 "type": "string",
514 "value": "gpt4-21-04"
515 },
516 "gen_ai.response.model": {
517 "type": "string",
518 "value": "gpt4-21-04"
519 },
520 "gen_ai.response.tokens_per_second": {
521 "type": "double",
522 "value": 4000.0
523 },
524 "gen_ai.usage.input_tokens": {
525 "type": "integer",
526 "value": 1000
527 },
528 "gen_ai.usage.output_tokens": {
529 "type": "integer",
530 "value": 2000
531 },
532 "gen_ai.usage.total_tokens": {
533 "type": "double",
534 "value": 3000.0
535 }
536 }
537 "#);
538 }
539
540 #[test]
541 fn test_normalize_ai_no_ai_attributes() {
542 let mut attributes = Annotated::new(attributes! {
543 "gen_ai.usage.input_tokens" => 1000,
544 "gen_ai.usage.output_tokens" => 2000,
545 });
546
547 normalize_ai(
548 &mut attributes,
549 Some(Duration::from_millis(500)),
550 Some(&model_costs()),
551 );
552
553 assert_annotated_snapshot!(&mut attributes, @r#"
554 {
555 "gen_ai.usage.input_tokens": {
556 "type": "integer",
557 "value": 1000
558 },
559 "gen_ai.usage.output_tokens": {
560 "type": "integer",
561 "value": 2000
562 }
563 }
564 "#);
565 }
566
567 #[test]
568 fn test_normalize_ai_no_ai_indicator_attribute() {
569 let mut attributes = Annotated::new(attributes! {
570 "foo" => 123,
571 });
572
573 normalize_ai(
574 &mut attributes,
575 Some(Duration::from_millis(500)),
576 Some(&model_costs()),
577 );
578
579 assert_annotated_snapshot!(&mut attributes, @r#"
580 {
581 "foo": {
582 "type": "integer",
583 "value": 123
584 }
585 }
586 "#);
587 }
588
589 #[test]
590 fn test_normalize_ai_empty() {
591 let mut attributes = Annotated::empty();
592
593 normalize_ai(
594 &mut attributes,
595 Some(Duration::from_millis(500)),
596 Some(&model_costs()),
597 );
598
599 assert!(attributes.is_empty());
600 }
601}