1use crate::eap::AttributesLike;
4use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
5use crate::{ModelCostV2, ModelMetadata};
6use relay_conventions::attributes::*;
7use relay_event_schema::protocol::{
8 Event, Measurements, OperationType, Span, SpanData, TraceContext,
9};
10use relay_protocol::{Annotated, Getter, Value};
11
12#[derive(Debug, Copy, Clone)]
14pub struct UsedTokens {
15 pub input_tokens: f64,
17 pub input_cached_tokens: f64,
21 pub input_cache_write_tokens: f64,
25 pub output_tokens: f64,
27 pub output_reasoning_tokens: f64,
31}
32
33impl UsedTokens {
34 pub fn from_span_data(data: &SpanData) -> Self {
36 macro_rules! get_value {
37 ($e:expr) => {
38 data.get_value($e).and_then(|v| v.as_f64()).unwrap_or(0.0)
39 };
40 }
41
42 Self {
43 input_tokens: get_value!(GEN_AI__USAGE__INPUT_TOKENS),
44 output_tokens: get_value!(GEN_AI__USAGE__OUTPUT_TOKENS),
45 output_reasoning_tokens: get_value!(GEN_AI__USAGE__REASONING__OUTPUT_TOKENS),
46 input_cached_tokens: get_value!(GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS),
47 input_cache_write_tokens: get_value!(GEN_AI__USAGE__CACHE_CREATION__INPUT_TOKENS),
48 }
49 }
50
51 pub fn has_usage(&self) -> bool {
53 self.input_tokens > 0.0 || self.output_tokens > 0.0
54 }
55
56 pub fn raw_input_tokens(&self) -> f64 {
63 self.input_tokens - self.input_cached_tokens - self.input_cache_write_tokens
64 }
65
66 pub fn raw_output_tokens(&self) -> f64 {
70 self.output_tokens - self.output_reasoning_tokens
71 }
72}
73
74#[derive(Debug, Copy, Clone)]
76pub struct CalculatedCost {
77 pub input: f64,
79 pub output: f64,
81 pub cache_read_input: f64,
83 pub cache_creation_input: f64,
85 pub reasoning_output: f64,
87}
88
89impl CalculatedCost {
90 pub fn total(&self) -> f64 {
92 self.input + self.output
93 }
94}
95
96pub fn calculate_costs(
100 model_cost: &ModelCostV2,
101 tokens: UsedTokens,
102 integration: &str,
103 platform: &str,
104) -> Option<CalculatedCost> {
105 if !tokens.has_usage() {
106 relay_statsd::metric!(
107 counter(Counters::GenAiCostCalculationResult) += 1,
108 result = "calculation_no_tokens",
109 integration = integration,
110 platform = platform,
111 );
112 return None;
113 }
114
115 let cache_read_input = tokens.input_cached_tokens * model_cost.input_cached_per_token;
116 let cache_creation_input =
117 tokens.input_cache_write_tokens * model_cost.input_cache_write_per_token;
118 let input = (tokens.raw_input_tokens() * model_cost.input_per_token)
119 + cache_read_input
120 + cache_creation_input;
121
122 let reasoning_per_token = match model_cost.output_reasoning_per_token {
125 r if r > 0.0 => r,
126 _ => model_cost.output_per_token,
127 };
128 let reasoning_output = tokens.output_reasoning_tokens * reasoning_per_token;
129 let output = (tokens.raw_output_tokens() * model_cost.output_per_token) + reasoning_output;
130
131 let metric_label = match (input, output) {
132 (x, y) if x < 0.0 || y < 0.0 => "calculation_negative",
133 (0.0, 0.0) => "calculation_zero",
134 _ => "calculation_positive",
135 };
136
137 relay_statsd::metric!(
138 counter(Counters::GenAiCostCalculationResult) += 1,
139 result = metric_label,
140 integration = integration,
141 platform = platform,
142 );
143
144 Some(CalculatedCost {
145 input,
146 output,
147 cache_read_input,
148 cache_creation_input,
149 reasoning_output,
150 })
151}
152
153pub fn default_ai_operation_type(attributes: &impl AttributesLike) -> &'static str {
155 if [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL]
156 .iter()
157 .any(|key| attributes.get_value(key).and_then(Value::as_str).is_some())
158 {
159 "ai_client"
160 } else {
161 "other"
162 }
163}
164
165pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
176 let ai_op = match op_name {
177 "ai.run.generateText"
179 | "ai.run.generateObject"
180 | "gen_ai.invoke_agent"
181 | "ai.pipeline.generate_text"
182 | "ai.pipeline.generate_object"
183 | "ai.pipeline.stream_text"
184 | "ai.pipeline.stream_object"
185 | "gen_ai.create_agent"
186 | "invoke_agent"
187 | "create_agent" => "agent",
188 "gen_ai.execute_tool" | "execute_tool" => "tool",
189 "gen_ai.handoff" | "handoff" => "handoff",
190 "gen_ai.memory.client"
191 | "create_memory"
192 | "create_memory_store"
193 | "delete_memory"
194 | "delete_memory_store"
195 | "search_memory"
196 | "update_memory"
197 | "upsert_memory" => "memory",
198 "ai.processor" | "processor_run" => "other",
199 op if op.starts_with("ai.streamText.doStream") => "ai_client",
201 op if op.starts_with("ai.streamText") => "agent",
202
203 op if op.starts_with("ai.generateText.doGenerate") => "ai_client",
204 op if op.starts_with("ai.generateText") => "agent",
205
206 op if op.starts_with("ai.generateObject.doGenerate") => "ai_client",
207 op if op.starts_with("ai.generateObject") => "agent",
208
209 op if op.starts_with("ai.toolCall") => "tool",
210 _ => return None,
212 };
213
214 Some(ai_op)
215}
216
217pub fn has_valid_total_cost(attributes: &impl AttributesLike) -> bool {
219 attributes
220 .get_value(GEN_AI__COST__TOTAL_TOKENS)
221 .and_then(Value::as_f64)
222 .is_some()
223}
224
225fn extract_ai_model_cost_data(
228 model_cost: Option<&ModelCostV2>,
229 data: &mut SpanData,
230 origin: Option<&str>,
231 platform: Option<&str>,
232) {
233 if has_valid_total_cost(data) {
235 return;
236 }
237
238 let integration = map_origin_to_integration(origin);
239 let platform = platform_tag(platform);
240
241 let Some(model_cost) = model_cost else {
242 relay_statsd::metric!(
243 counter(Counters::GenAiCostCalculationResult) += 1,
244 result = "calculation_no_model_cost_available",
245 integration = integration,
246 platform = platform,
247 );
248 return;
249 };
250
251 let used_tokens = UsedTokens::from_span_data(&*data);
252 let Some(costs) = calculate_costs(model_cost, used_tokens, integration, platform) else {
253 return;
254 };
255
256 data.other
257 .entry(GEN_AI__COST__TOTAL_TOKENS.to_owned())
258 .or_default()
259 .set_value(Value::F64(costs.total()).into());
260
261 data.other
263 .entry(GEN_AI__COST__INPUT_TOKENS.to_owned())
264 .or_default()
265 .set_value(Value::F64(costs.input).into());
266 data.other
267 .entry(GEN_AI__COST__CACHE_READ__INPUT_TOKENS.to_owned())
268 .or_default()
269 .set_value(Value::F64(costs.cache_read_input).into());
270 data.other
271 .entry(GEN_AI__COST__CACHE_CREATION__INPUT_TOKENS.to_owned())
272 .or_default()
273 .set_value(Value::F64(costs.cache_creation_input).into());
274
275 data.other
276 .entry(GEN_AI__COST__OUTPUT_TOKENS.to_owned())
277 .or_default()
278 .set_value(Value::F64(costs.output).into());
279
280 data.other
281 .entry(GEN_AI__COST__REASONING__OUTPUT_TOKENS.to_owned())
282 .or_default()
283 .set_value(Value::F64(costs.reasoning_output).into());
284}
285
286fn map_ai_measurements_to_data(data: &mut SpanData, measurements: Option<&Measurements>) {
288 let set_field_from_measurement = |target_field: &mut Annotated<Value>,
289 measurement_key: &str| {
290 if let Some(measurements) = measurements
291 && target_field.value().is_none()
292 && let Some(value) = measurements.get_value(measurement_key)
293 {
294 target_field.set_value(Value::F64(value.to_f64()).into());
295 }
296 };
297
298 set_field_from_measurement(
299 data.other
300 .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
301 .or_default(),
302 "ai_total_tokens_used",
303 );
304 set_field_from_measurement(
305 data.other
306 .entry(GEN_AI__USAGE__INPUT_TOKENS.to_owned())
307 .or_default(),
308 "ai_prompt_tokens_used",
309 );
310 set_field_from_measurement(
311 data.other
312 .entry(GEN_AI__USAGE__OUTPUT_TOKENS.to_owned())
313 .or_default(),
314 "ai_completion_tokens_used",
315 );
316}
317
318fn set_total_tokens(data: &mut SpanData) {
319 if data.get_value(GEN_AI__USAGE__TOTAL_TOKENS).is_none() {
321 let input_tokens = data
322 .get_value(GEN_AI__USAGE__INPUT_TOKENS)
323 .and_then(Value::as_f64);
324 let output_tokens = data
325 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
326 .and_then(Value::as_f64);
327
328 if input_tokens.is_none() && output_tokens.is_none() {
329 return;
331 }
332
333 data.other
334 .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
335 .or_default()
336 .set_value(
337 Value::F64(input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0)).into(),
338 );
339 }
340}
341
342fn extract_context_utilization(data: &mut SpanData, model_metadata: &ModelMetadata) {
344 let model_id = data.get_str(GEN_AI__RESPONSE__MODEL);
345
346 let context_size = model_id.and_then(|id| model_metadata.context_size(id));
347
348 let Some(context_size) = context_size else {
349 return;
350 };
351
352 data.other
353 .entry(GEN_AI__CONTEXT__WINDOW_SIZE.to_owned())
354 .or_default()
355 .set_value(Value::U64(context_size).into());
356
357 let total_tokens = data
358 .get_value(GEN_AI__USAGE__TOTAL_TOKENS)
359 .and_then(Value::as_f64);
360
361 if let Some(total_tokens) = total_tokens {
362 data.other
363 .entry(GEN_AI__CONTEXT__UTILIZATION.to_owned())
364 .or_default()
365 .set_value(Value::F64(total_tokens / context_size as f64).into());
366 }
367}
368
369fn extract_ai_data(
371 data: &mut SpanData,
372 duration: f64,
373 model_metadata: &ModelMetadata,
374 origin: Option<&str>,
375 platform: Option<&str>,
376) {
377 if data
379 .get_value(GEN_AI__RESPONSE__TOKENS_PER_SECOND)
380 .is_none()
381 && duration > 0.0
382 && let Some(output_tokens) = data
383 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
384 .and_then(Value::as_f64)
385 {
386 data.other
387 .entry(GEN_AI__RESPONSE__TOKENS_PER_SECOND.to_owned())
388 .or_default()
389 .set_value(Value::F64(output_tokens / (duration / 1000.0)).into());
390 }
391
392 extract_context_utilization(data, model_metadata);
393
394 if let Some(model_id) = data.get_str(GEN_AI__RESPONSE__MODEL) {
396 extract_ai_model_cost_data(
397 model_metadata.cost_per_token(model_id),
398 data,
399 origin,
400 platform,
401 )
402 } else {
403 relay_statsd::metric!(
404 counter(Counters::GenAiCostCalculationResult) += 1,
405 result = "calculation_no_model_id_available",
406 integration = map_origin_to_integration(origin),
407 platform = platform_tag(platform),
408 );
409 }
410}
411
412fn enrich_ai_span_data(
414 span_data: &mut Annotated<SpanData>,
415 span_op: &Annotated<OperationType>,
416 measurements: &Annotated<Measurements>,
417 duration: f64,
418 model_metadata: Option<&ModelMetadata>,
419 origin: Option<&str>,
420 platform: Option<&str>,
421) {
422 if !is_ai_span(span_data, span_op.value()) {
423 return;
424 }
425
426 let data = span_data.get_or_insert_with(SpanData::default);
427
428 map_ai_measurements_to_data(data, measurements.value());
429
430 set_total_tokens(data);
431
432 if data.get_value(GEN_AI__RESPONSE__MODEL).is_none()
434 && let Some(request_model) = data.get_value(GEN_AI__REQUEST__MODEL).cloned()
435 {
436 data.other
437 .entry(GEN_AI__RESPONSE__MODEL.to_owned())
438 .or_default()
439 .set_value(Some(request_model));
440 }
441
442 if data.get_value(GEN_AI__AGENT__NAME).is_none()
444 && let Some(function_id) = data.get_value(GEN_AI__FUNCTION_ID).cloned()
445 {
446 data.other
447 .entry(GEN_AI__AGENT__NAME.to_owned())
448 .or_default()
449 .set_value(Some(function_id));
450 }
451
452 if let Some(model_metadata) = model_metadata {
453 extract_ai_data(data, duration, model_metadata, origin, platform);
454 } else {
455 relay_statsd::metric!(
456 counter(Counters::GenAiCostCalculationResult) += 1,
457 result = "calculation_no_model_cost_available",
458 integration = map_origin_to_integration(origin),
459 platform = platform_tag(platform),
460 );
461 }
462
463 let ai_op_type = data
464 .get_str(GEN_AI__OPERATION__NAME)
465 .or(span_op.value().map(String::as_str))
466 .and_then(infer_ai_operation_type)
467 .unwrap_or_else(|| default_ai_operation_type(data));
468
469 data.other
470 .entry(GEN_AI__OPERATION__TYPE.to_owned())
471 .or_default()
472 .set_value(Some(Value::String(ai_op_type.to_owned())));
473
474 if ai_op_type == "tool"
476 && !matches!(
477 data.get_value(GEN_AI__TOOL__CALL__RESULT__SIZE),
478 Some(Value::U64(_)) | Some(Value::I64(0..))
479 )
480 && let Some(result) = data.get_str(GEN_AI__TOOL__CALL__RESULT)
481 {
482 let size = result.len() as u64;
483 data.other
484 .entry(GEN_AI__TOOL__CALL__RESULT__SIZE.to_owned())
485 .or_default()
486 .set_value(Some(Value::U64(size)));
487 }
488}
489
490pub fn enrich_ai_span(span: &mut Span, model_metadata: Option<&ModelMetadata>) {
492 let duration = span
493 .get_value("span.duration")
494 .and_then(|v| v.as_f64())
495 .unwrap_or(0.0);
496
497 enrich_ai_span_data(
498 &mut span.data,
499 &span.op,
500 &span.measurements,
501 duration,
502 model_metadata,
503 span.origin.as_str(),
504 span.platform.as_str(),
505 );
506}
507
508pub fn enrich_ai_event_data(event: &mut Event, model_metadata: Option<&ModelMetadata>) {
510 let event_duration = event
511 .get_value("event.duration")
512 .and_then(|v| v.as_f64())
513 .unwrap_or(0.0);
514
515 if let Some(trace_context) = event
516 .contexts
517 .value_mut()
518 .as_mut()
519 .and_then(|c| c.get_mut::<TraceContext>())
520 {
521 enrich_ai_span_data(
522 &mut trace_context.data,
523 &trace_context.op,
524 &event.measurements,
525 event_duration,
526 model_metadata,
527 trace_context.origin.as_str(),
528 event.platform.as_str(),
529 );
530 }
531 let spans = event.spans.value_mut().iter_mut().flatten();
532 let spans = spans.filter_map(|span| span.value_mut().as_mut());
533
534 for span in spans {
535 let span_duration = span
536 .get_value("span.duration")
537 .and_then(|v| v.as_f64())
538 .unwrap_or(0.0);
539 let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
540
541 enrich_ai_span_data(
542 &mut span.data,
543 &span.op,
544 &span.measurements,
545 span_duration,
546 model_metadata,
547 span.origin.as_str(),
548 span_platform,
549 );
550 }
551}
552
553fn is_ai_span(span_data: &Annotated<SpanData>, span_op: Option<&OperationType>) -> bool {
557 let has_ai_op = span_data
558 .value()
559 .and_then(|data| data.get_value(GEN_AI__OPERATION__NAME))
560 .is_some();
561
562 let is_ai_span_op =
563 span_op.is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."));
564
565 has_ai_op || is_ai_span_op
566}
567
568#[cfg(test)]
569mod tests {
570 use std::collections::HashMap;
571
572 use relay_protocol::{FromValue, assert_annotated_snapshot};
573 use serde_json::json;
574
575 use super::*;
576 use crate::ModelMetadataEntry;
577
578 fn ai_span_with_data(data: serde_json::Value) -> Span {
579 Span {
580 op: "gen_ai.test".to_owned().into(),
581 data: SpanData::from_value(data.into()),
582 ..Default::default()
583 }
584 }
585
586 #[test]
587 fn test_tool_call_result_size_both_pipelines() {
588 use relay_event_schema::protocol::{Attribute, Attributes};
589
590 for (op, result, size, expected) in [
591 ("gen_ai.execute_tool", Some("hello"), None, Some(5)),
592 ("gen_ai.execute_tool", Some("雨🌧️"), None, Some(10)),
593 ("gen_ai.execute_tool", Some(""), None, Some(0)),
594 ("gen_ai.execute_tool", None, None, None),
595 ("gen_ai.execute_tool", Some("short"), Some(1000), Some(1000)),
596 ("gen_ai.chat", Some("hello"), None, None),
597 ] {
598 let mut span = ai_span_with_data(json!({
599 "gen_ai.tool.call.result": result,
600 "gen_ai.tool.call.result.size": size,
601 }));
602 span.op = op.to_owned().into();
603 enrich_ai_span(&mut span, None);
604 assert_eq!(
605 span.data
606 .value()
607 .unwrap()
608 .get_value(GEN_AI__TOOL__CALL__RESULT__SIZE)
609 .and_then(Value::as_f64),
610 expected.map(f64::from),
611 "legacy: {op}"
612 );
613
614 let mut attributes = Attributes::new();
615 attributes.insert(SENTRY__OP, op.to_owned());
616 if let Some(result) = result {
617 attributes.0.insert(
618 GEN_AI__TOOL__CALL__RESULT.to_owned(),
619 Annotated::new(Attribute::from(result)),
620 );
621 }
622 if let Some(size) = size {
623 attributes.insert(GEN_AI__TOOL__CALL__RESULT__SIZE, i64::from(size));
624 }
625 let mut attributes = Annotated::new(attributes);
626 crate::eap::normalize_ai(&mut attributes, None, None);
627 assert_eq!(
628 attributes
629 .value()
630 .unwrap()
631 .get_value(GEN_AI__TOOL__CALL__RESULT__SIZE)
632 .and_then(Value::as_f64),
633 expected.map(f64::from),
634 "EAP: {op}"
635 );
636 }
637 }
638
639 #[test]
640 fn test_has_valid_total_cost() {
641 let missing = ai_span_with_data(json!({}));
642 let invalid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": false}));
643 let valid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": 1.0}));
644
645 assert!(!has_valid_total_cost(missing.data.value().unwrap()));
646 assert!(!has_valid_total_cost(invalid.data.value().unwrap()));
647 assert!(has_valid_total_cost(valid.data.value().unwrap()));
648 }
649
650 #[test]
651 fn test_calculate_cost_no_tokens() {
652 let cost = calculate_costs(
653 &ModelCostV2 {
654 input_per_token: 1.0,
655 output_per_token: 1.0,
656 output_reasoning_per_token: 1.0,
657 input_cached_per_token: 1.0,
658 input_cache_write_per_token: 1.0,
659 },
660 UsedTokens::from_span_data(&SpanData::default()),
661 "test",
662 "test",
663 );
664 assert!(cost.is_none());
665 }
666
667 #[test]
668 fn test_calculate_cost_full() {
669 let cost = calculate_costs(
670 &ModelCostV2 {
671 input_per_token: 1.0,
672 output_per_token: 2.0,
673 output_reasoning_per_token: 3.0,
674 input_cached_per_token: 0.5,
675 input_cache_write_per_token: 0.75,
676 },
677 UsedTokens {
678 input_tokens: 8.0,
679 input_cached_tokens: 5.0,
680 input_cache_write_tokens: 0.0,
681 output_tokens: 15.0,
682 output_reasoning_tokens: 9.0,
683 },
684 "test",
685 "test",
686 )
687 .unwrap();
688
689 insta::assert_debug_snapshot!(cost, @r"
690 CalculatedCost {
691 input: 5.5,
692 output: 39.0,
693 cache_read_input: 2.5,
694 cache_creation_input: 0.0,
695 reasoning_output: 27.0,
696 }
697 ");
698 }
699
700 #[test]
701 fn test_calculate_cost_no_reasoning_cost() {
702 let cost = calculate_costs(
703 &ModelCostV2 {
704 input_per_token: 1.0,
705 output_per_token: 2.0,
706 output_reasoning_per_token: 0.0,
708 input_cached_per_token: 0.5,
709 input_cache_write_per_token: 0.0,
710 },
711 UsedTokens {
712 input_tokens: 8.0,
713 input_cached_tokens: 5.0,
714 input_cache_write_tokens: 0.0,
715 output_tokens: 15.0,
716 output_reasoning_tokens: 9.0,
717 },
718 "test",
719 "test",
720 )
721 .unwrap();
722
723 insta::assert_debug_snapshot!(cost, @r"
724 CalculatedCost {
725 input: 5.5,
726 output: 30.0,
727 cache_read_input: 2.5,
728 cache_creation_input: 0.0,
729 reasoning_output: 18.0,
730 }
731 ");
732 }
733
734 #[test]
738 fn test_calculate_cost_negative() {
739 let cost = calculate_costs(
740 &ModelCostV2 {
741 input_per_token: 2.0,
742 output_per_token: 2.0,
743 output_reasoning_per_token: 1.0,
744 input_cached_per_token: 1.0,
745 input_cache_write_per_token: 1.5,
746 },
747 UsedTokens {
748 input_tokens: 1.0,
749 input_cached_tokens: 11.0,
750 input_cache_write_tokens: 0.0,
751 output_tokens: 1.0,
752 output_reasoning_tokens: 9.0,
753 },
754 "test",
755 "test",
756 )
757 .unwrap();
758
759 insta::assert_debug_snapshot!(cost, @r"
760 CalculatedCost {
761 input: -9.0,
762 output: -7.0,
763 cache_read_input: 11.0,
764 cache_creation_input: 0.0,
765 reasoning_output: 9.0,
766 }
767 ");
768 }
769
770 #[test]
771 fn test_calculate_cost_with_cache_writes() {
772 let cost = calculate_costs(
773 &ModelCostV2 {
774 input_per_token: 1.0,
775 output_per_token: 2.0,
776 output_reasoning_per_token: 3.0,
777 input_cached_per_token: 0.5,
778 input_cache_write_per_token: 0.75,
779 },
780 UsedTokens {
781 input_tokens: 100.0,
782 input_cached_tokens: 20.0,
783 input_cache_write_tokens: 30.0,
784 output_tokens: 50.0,
785 output_reasoning_tokens: 10.0,
786 },
787 "test",
788 "test",
789 )
790 .unwrap();
791
792 insta::assert_debug_snapshot!(cost, @r"
796 CalculatedCost {
797 input: 82.5,
798 output: 110.0,
799 cache_read_input: 10.0,
800 cache_creation_input: 22.5,
801 reasoning_output: 30.0,
802 }
803 ");
804 }
805
806 #[test]
807 fn test_existing_cost_is_not_overwritten() {
808 let mut span = ai_span_with_data(json!({
809 "gen_ai.response.model": "claude-2.1",
810 "gen_ai.usage.input_tokens": 1000.0,
811 "gen_ai.cost.input_tokens": 99.0,
812 "gen_ai.cost.total_tokens": 123.0,
813 }));
814
815 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
816
817 let data = span.data.value().unwrap();
818 assert_eq!(
819 data.get_value(GEN_AI__COST__TOTAL_TOKENS)
820 .and_then(Value::as_f64),
821 Some(123.0)
822 );
823 assert!(data.get_value(GEN_AI__COST__OUTPUT_TOKENS).is_none());
824 }
825
826 #[test]
827 fn test_calculate_cost_backward_compatibility_no_cache_write() {
828 let span_data = SpanData::from([
830 (
831 GEN_AI__USAGE__INPUT_TOKENS.to_owned(),
832 Annotated::new(100.0.into()),
833 ),
834 (
835 GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS.to_owned(),
836 Annotated::new(20.0.into()),
837 ),
838 (
839 GEN_AI__USAGE__OUTPUT_TOKENS.to_owned(),
840 Annotated::new(50.0.into()),
841 ),
842 ]);
843
844 let tokens = UsedTokens::from_span_data(&span_data);
845
846 assert_eq!(tokens.input_cache_write_tokens, 0.0);
848
849 let cost = calculate_costs(
850 &ModelCostV2 {
851 input_per_token: 1.0,
852 output_per_token: 2.0,
853 output_reasoning_per_token: 0.0,
854 input_cached_per_token: 0.5,
855 input_cache_write_per_token: 0.75,
856 },
857 tokens,
858 "test",
859 "test",
860 )
861 .unwrap();
862
863 insta::assert_debug_snapshot!(cost, @r"
867 CalculatedCost {
868 input: 90.0,
869 output: 100.0,
870 cache_read_input: 10.0,
871 cache_creation_input: 0.0,
872 reasoning_output: 0.0,
873 }
874 ");
875 }
876
877 #[test]
879 fn test_infer_ai_operation_type_from_gen_ai_operation_name() {
880 let mut span = ai_span_with_data(json!({
881 "gen_ai.operation.name": "invoke_agent"
882 }));
883
884 enrich_ai_span(&mut span, None);
885
886 assert_annotated_snapshot!(&span.data, @r#"
887 {
888 "gen_ai.operation.name": "invoke_agent",
889 "gen_ai.operation.type": "agent"
890 }
891 "#);
892 }
893
894 #[test]
896 fn test_infer_ai_operation_type_from_span_op() {
897 let mut span = Span {
898 op: "gen_ai.invoke_agent".to_owned().into(),
899 ..Default::default()
900 };
901
902 enrich_ai_span(&mut span, None);
903
904 assert_annotated_snapshot!(span.data, @r#"
905 {
906 "gen_ai.operation.type": "agent"
907 }
908 "#);
909 }
910
911 #[test]
913 fn test_infer_ai_operation_type_memory_from_gen_ai_operation_name() {
914 let mut span = ai_span_with_data(json!({
915 "gen_ai.operation.name": "upsert_memory"
916 }));
917
918 enrich_ai_span(&mut span, None);
919
920 assert_annotated_snapshot!(&span.data, @r#"
921 {
922 "gen_ai.operation.name": "upsert_memory",
923 "gen_ai.operation.type": "memory"
924 }
925 "#);
926 }
927
928 #[test]
930 fn test_infer_ai_operation_type_memory_from_span_op() {
931 let mut span = Span {
932 op: "gen_ai.memory.client".to_owned().into(),
933 ..Default::default()
934 };
935
936 enrich_ai_span(&mut span, None);
937
938 assert_annotated_snapshot!(span.data, @r#"
939 {
940 "gen_ai.operation.type": "memory"
941 }
942 "#);
943 }
944
945 #[test]
947 fn test_infer_ai_operation_type_from_fallback() {
948 let mut span = ai_span_with_data(json!({
949 "gen_ai.operation.name": "embeddings"
950 }));
951
952 enrich_ai_span(&mut span, None);
953
954 assert_annotated_snapshot!(&span.data, @r#"
955 {
956 "gen_ai.operation.name": "embeddings",
957 "gen_ai.operation.type": "other"
958 }
959 "#);
960 }
961
962 #[test]
963 fn test_infer_ai_operation_type_fallback_with_model() {
964 for model_key in [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL] {
965 let mut span = ai_span_with_data(json!({
966 "gen_ai.operation.name": "embeddings",
967 (model_key): "gpt-4",
968 }));
969
970 enrich_ai_span(&mut span, None);
971
972 assert_eq!(
973 span.data.value().unwrap().get_str(GEN_AI__OPERATION__TYPE),
974 Some("ai_client"),
975 );
976 }
977 }
978
979 #[test]
981 fn test_default_response_model_from_request_model() {
982 let mut span = ai_span_with_data(json!({
983 "gen_ai.request.model": "gpt-4",
984 }));
985
986 enrich_ai_span(&mut span, None);
987
988 assert_annotated_snapshot!(&span.data, @r#"
989 {
990 "gen_ai.operation.type": "ai_client",
991 "gen_ai.request.model": "gpt-4",
992 "gen_ai.response.model": "gpt-4"
993 }
994 "#);
995 }
996
997 #[test]
999 fn test_default_response_model_not_overridden() {
1000 let mut span = ai_span_with_data(json!({
1001 "gen_ai.request.model": "gpt-4",
1002 "gen_ai.response.model": "gpt-4-abcd",
1003 }));
1004
1005 enrich_ai_span(&mut span, None);
1006
1007 assert_annotated_snapshot!(&span.data, @r#"
1008 {
1009 "gen_ai.operation.type": "ai_client",
1010 "gen_ai.request.model": "gpt-4",
1011 "gen_ai.response.model": "gpt-4-abcd"
1012 }
1013 "#);
1014 }
1015
1016 #[test]
1018 fn test_default_agent_name_from_function_id() {
1019 let mut span = ai_span_with_data(json!({
1020 "gen_ai.function_id": "my-agent",
1021 }));
1022
1023 enrich_ai_span(&mut span, None);
1024
1025 assert_annotated_snapshot!(&span.data, @r#"
1026 {
1027 "gen_ai.agent.name": "my-agent",
1028 "gen_ai.function_id": "my-agent",
1029 "gen_ai.operation.type": "other"
1030 }
1031 "#);
1032 }
1033
1034 #[test]
1036 fn test_default_agent_name_not_overridden() {
1037 let mut span = ai_span_with_data(json!({
1038 "gen_ai.function_id": "my-function",
1039 "gen_ai.agent.name": "my-agent",
1040 }));
1041
1042 enrich_ai_span(&mut span, None);
1043
1044 assert_annotated_snapshot!(&span.data, @r#"
1045 {
1046 "gen_ai.agent.name": "my-agent",
1047 "gen_ai.function_id": "my-function",
1048 "gen_ai.operation.type": "other"
1049 }
1050 "#);
1051 }
1052
1053 #[test]
1055 fn test_is_ai_span_from_gen_ai_operation_name() {
1056 let mut span_data = Annotated::default();
1057 span_data
1058 .get_or_insert_with(SpanData::default)
1059 .other
1060 .insert(
1061 GEN_AI__OPERATION__NAME.to_owned(),
1062 Annotated::new(Value::String("chat".into())),
1063 );
1064 assert!(is_ai_span(&span_data, None));
1065 }
1066
1067 #[test]
1069 fn test_is_ai_span_from_span_op_ai() {
1070 let span_op: OperationType = "ai.chat".into();
1071 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1072 }
1073
1074 #[test]
1076 fn test_is_ai_span_from_span_op_gen_ai() {
1077 let span_op: OperationType = "gen_ai.chat".into();
1078 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1079 }
1080
1081 #[test]
1083 fn test_is_ai_span_negative() {
1084 assert!(!is_ai_span(&Annotated::default(), None));
1085 }
1086
1087 #[test]
1089 fn test_enrich_ai_event_data_invoke_agent_trace_with_chat_span() {
1090 let event_json = r#"{
1091 "type": "transaction",
1092 "timestamp": 1234567892.0,
1093 "start_timestamp": 1234567889.0,
1094 "contexts": {
1095 "trace": {
1096 "op": "gen_ai.invoke_agent",
1097 "trace_id": "12345678901234567890123456789012",
1098 "span_id": "1234567890123456",
1099 "data": {
1100 "gen_ai.operation.name": "gen_ai.invoke_agent",
1101 "gen_ai.usage.input_tokens": 500,
1102 "gen_ai.usage.output_tokens": 200
1103 }
1104 }
1105 },
1106 "spans": [
1107 {
1108 "op": "gen_ai.chat.completions",
1109 "span_id": "1234567890123457",
1110 "start_timestamp": 1234567889.5,
1111 "timestamp": 1234567890.5,
1112 "data": {
1113 "gen_ai.operation.name": "chat",
1114 "gen_ai.usage.input_tokens": 100,
1115 "gen_ai.usage.output_tokens": 50
1116 }
1117 }
1118 ]
1119 }"#;
1120
1121 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1122 let event = annotated_event.value_mut().as_mut().unwrap();
1123
1124 enrich_ai_event_data(event, None);
1125
1126 assert_annotated_snapshot!(&annotated_event, @r#"
1127 {
1128 "type": "transaction",
1129 "timestamp": 1234567892.0,
1130 "start_timestamp": 1234567889.0,
1131 "contexts": {
1132 "trace": {
1133 "trace_id": "12345678901234567890123456789012",
1134 "span_id": "1234567890123456",
1135 "op": "gen_ai.invoke_agent",
1136 "data": {
1137 "gen_ai.operation.name": "gen_ai.invoke_agent",
1138 "gen_ai.operation.type": "agent",
1139 "gen_ai.usage.input_tokens": 500,
1140 "gen_ai.usage.output_tokens": 200,
1141 "gen_ai.usage.total_tokens": 700.0
1142 },
1143 "type": "trace"
1144 }
1145 },
1146 "spans": [
1147 {
1148 "timestamp": 1234567890.5,
1149 "start_timestamp": 1234567889.5,
1150 "op": "gen_ai.chat.completions",
1151 "span_id": "1234567890123457",
1152 "data": {
1153 "gen_ai.operation.name": "chat",
1154 "gen_ai.operation.type": "other",
1155 "gen_ai.usage.input_tokens": 100,
1156 "gen_ai.usage.output_tokens": 50,
1157 "gen_ai.usage.total_tokens": 150.0
1158 }
1159 }
1160 ]
1161 }
1162 "#);
1163 }
1164
1165 #[test]
1167 fn test_enrich_ai_event_data_nested_agent_and_chat_spans() {
1168 let event_json = r#"{
1169 "type": "transaction",
1170 "timestamp": 1234567892.0,
1171 "start_timestamp": 1234567889.0,
1172 "contexts": {
1173 "trace": {
1174 "op": "http.server",
1175 "trace_id": "12345678901234567890123456789012",
1176 "span_id": "1234567890123456"
1177 }
1178 },
1179 "spans": [
1180 {
1181 "op": "gen_ai.invoke_agent",
1182 "span_id": "1234567890123457",
1183 "parent_span_id": "1234567890123456",
1184 "start_timestamp": 1234567889.5,
1185 "timestamp": 1234567891.5,
1186 "data": {
1187 "gen_ai.operation.name": "invoke_agent",
1188 "gen_ai.usage.input_tokens": 500,
1189 "gen_ai.usage.output_tokens": 200
1190 }
1191 },
1192 {
1193 "op": "gen_ai.chat.completions",
1194 "span_id": "1234567890123458",
1195 "parent_span_id": "1234567890123457",
1196 "start_timestamp": 1234567890.0,
1197 "timestamp": 1234567891.0,
1198 "data": {
1199 "gen_ai.operation.name": "chat",
1200 "gen_ai.usage.input_tokens": 100,
1201 "gen_ai.usage.output_tokens": 50
1202 }
1203 }
1204 ]
1205 }"#;
1206
1207 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1208 let event = annotated_event.value_mut().as_mut().unwrap();
1209
1210 enrich_ai_event_data(event, None);
1211
1212 assert_annotated_snapshot!(&annotated_event, @r#"
1213 {
1214 "type": "transaction",
1215 "timestamp": 1234567892.0,
1216 "start_timestamp": 1234567889.0,
1217 "contexts": {
1218 "trace": {
1219 "trace_id": "12345678901234567890123456789012",
1220 "span_id": "1234567890123456",
1221 "op": "http.server",
1222 "type": "trace"
1223 }
1224 },
1225 "spans": [
1226 {
1227 "timestamp": 1234567891.5,
1228 "start_timestamp": 1234567889.5,
1229 "op": "gen_ai.invoke_agent",
1230 "span_id": "1234567890123457",
1231 "parent_span_id": "1234567890123456",
1232 "data": {
1233 "gen_ai.operation.name": "invoke_agent",
1234 "gen_ai.operation.type": "agent",
1235 "gen_ai.usage.input_tokens": 500,
1236 "gen_ai.usage.output_tokens": 200,
1237 "gen_ai.usage.total_tokens": 700.0
1238 }
1239 },
1240 {
1241 "timestamp": 1234567891.0,
1242 "start_timestamp": 1234567890.0,
1243 "op": "gen_ai.chat.completions",
1244 "span_id": "1234567890123458",
1245 "parent_span_id": "1234567890123457",
1246 "data": {
1247 "gen_ai.operation.name": "chat",
1248 "gen_ai.operation.type": "other",
1249 "gen_ai.usage.input_tokens": 100,
1250 "gen_ai.usage.output_tokens": 50,
1251 "gen_ai.usage.total_tokens": 150.0
1252 }
1253 }
1254 ]
1255 }
1256 "#);
1257 }
1258
1259 #[test]
1261 fn test_enrich_ai_event_data_legacy_measurements_and_span_op() {
1262 let event_json = r#"{
1263 "type": "transaction",
1264 "timestamp": 1234567892.0,
1265 "start_timestamp": 1234567889.0,
1266 "contexts": {
1267 "trace": {
1268 "op": "http.server",
1269 "trace_id": "12345678901234567890123456789012",
1270 "span_id": "1234567890123456"
1271 }
1272 },
1273 "spans": [
1274 {
1275 "op": "gen_ai.invoke_agent",
1276 "span_id": "1234567890123457",
1277 "parent_span_id": "1234567890123456",
1278 "start_timestamp": 1234567889.5,
1279 "timestamp": 1234567891.5,
1280 "measurements": {
1281 "ai_prompt_tokens_used": {"value": 500.0},
1282 "ai_completion_tokens_used": {"value": 200.0}
1283 }
1284 },
1285 {
1286 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1287 "span_id": "1234567890123458",
1288 "parent_span_id": "1234567890123457",
1289 "start_timestamp": 1234567890.0,
1290 "timestamp": 1234567891.0,
1291 "measurements": {
1292 "ai_prompt_tokens_used": {"value": 100.0},
1293 "ai_completion_tokens_used": {"value": 50.0}
1294 }
1295 }
1296 ]
1297 }"#;
1298
1299 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1300 let event = annotated_event.value_mut().as_mut().unwrap();
1301
1302 enrich_ai_event_data(event, None);
1303
1304 assert_annotated_snapshot!(&annotated_event, @r#"
1305 {
1306 "type": "transaction",
1307 "timestamp": 1234567892.0,
1308 "start_timestamp": 1234567889.0,
1309 "contexts": {
1310 "trace": {
1311 "trace_id": "12345678901234567890123456789012",
1312 "span_id": "1234567890123456",
1313 "op": "http.server",
1314 "type": "trace"
1315 }
1316 },
1317 "spans": [
1318 {
1319 "timestamp": 1234567891.5,
1320 "start_timestamp": 1234567889.5,
1321 "op": "gen_ai.invoke_agent",
1322 "span_id": "1234567890123457",
1323 "parent_span_id": "1234567890123456",
1324 "data": {
1325 "gen_ai.operation.type": "agent",
1326 "gen_ai.usage.input_tokens": 500.0,
1327 "gen_ai.usage.output_tokens": 200.0,
1328 "gen_ai.usage.total_tokens": 700.0
1329 },
1330 "measurements": {
1331 "ai_completion_tokens_used": {
1332 "value": 200.0
1333 },
1334 "ai_prompt_tokens_used": {
1335 "value": 500.0
1336 }
1337 }
1338 },
1339 {
1340 "timestamp": 1234567891.0,
1341 "start_timestamp": 1234567890.0,
1342 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1343 "span_id": "1234567890123458",
1344 "parent_span_id": "1234567890123457",
1345 "data": {
1346 "gen_ai.operation.type": "other",
1347 "gen_ai.usage.input_tokens": 100.0,
1348 "gen_ai.usage.output_tokens": 50.0,
1349 "gen_ai.usage.total_tokens": 150.0
1350 },
1351 "measurements": {
1352 "ai_completion_tokens_used": {
1353 "value": 50.0
1354 },
1355 "ai_prompt_tokens_used": {
1356 "value": 100.0
1357 }
1358 }
1359 }
1360 ]
1361 }
1362 "#);
1363 }
1364
1365 fn metadata_with_context_size() -> ModelMetadata {
1366 ModelMetadata {
1367 version: 1,
1368 models: HashMap::from([(
1369 "claude-2.1".parse().unwrap(),
1370 ModelMetadataEntry {
1371 costs: Some(ModelCostV2 {
1372 input_per_token: 0.01,
1373 output_per_token: 0.02,
1374 output_reasoning_per_token: 0.0,
1375 input_cached_per_token: 0.0,
1376 input_cache_write_per_token: 0.0,
1377 }),
1378 context_size: Some(100_000),
1379 },
1380 )]),
1381 }
1382 }
1383
1384 #[test]
1385 fn test_context_utilization_with_total_tokens() {
1386 let mut span = Span {
1387 op: "gen_ai.test".to_owned().into(),
1388 data: SpanData::from_value(
1389 json!({
1390 "gen_ai.response.model": "claude-2.1",
1391 "gen_ai.usage.input_tokens": 30000.0,
1392 "gen_ai.usage.output_tokens": 12000.0,
1393 "gen_ai.usage.total_tokens": 42000.0,
1394 })
1395 .into(),
1396 ),
1397 ..Default::default()
1398 };
1399
1400 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1401
1402 let data = span.data.value().unwrap();
1403 assert_eq!(
1404 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1405 .and_then(Value::as_f64),
1406 Some(100_000.0)
1407 );
1408 assert_eq!(
1409 data.get_value(GEN_AI__CONTEXT__UTILIZATION)
1410 .and_then(Value::as_f64),
1411 Some(0.42)
1412 );
1413 }
1414
1415 #[test]
1416 fn test_context_utilization_no_context_size() {
1417 let metadata = ModelMetadata {
1418 version: 1,
1419 models: HashMap::from([(
1420 "claude-2.1".parse().unwrap(),
1421 ModelMetadataEntry {
1422 costs: None,
1423 context_size: None,
1424 },
1425 )]),
1426 };
1427
1428 let mut span = Span {
1429 op: "gen_ai.test".to_owned().into(),
1430 data: SpanData::from_value(
1431 json!({
1432 "gen_ai.response.model": "claude-2.1",
1433 "gen_ai.usage.total_tokens": 1000.0,
1434 })
1435 .into(),
1436 ),
1437 ..Default::default()
1438 };
1439
1440 enrich_ai_span(&mut span, Some(&metadata));
1441
1442 let data = span.data.value().unwrap();
1443 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1444 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1445 }
1446
1447 #[test]
1448 fn test_context_utilization_no_total_tokens() {
1449 let mut span = Span {
1450 op: "gen_ai.test".to_owned().into(),
1451 data: SpanData::from_value(
1452 json!({
1453 "gen_ai.response.model": "claude-2.1",
1454 })
1455 .into(),
1456 ),
1457 ..Default::default()
1458 };
1459
1460 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1461
1462 let data = span.data.value().unwrap();
1463 assert_eq!(
1465 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1466 .and_then(Value::as_f64),
1467 Some(100_000.0)
1468 );
1469 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1471 }
1472
1473 #[test]
1474 fn test_context_utilization_unknown_model() {
1475 let mut span = Span {
1476 op: "gen_ai.test".to_owned().into(),
1477 data: SpanData::from_value(
1478 json!({
1479 "gen_ai.response.model": "unknown-model",
1480 "gen_ai.usage.total_tokens": 1000.0,
1481 })
1482 .into(),
1483 ),
1484 ..Default::default()
1485 };
1486
1487 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1488
1489 let data = span.data.value().unwrap();
1490 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1491 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1492 }
1493}