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
475pub fn enrich_ai_span(span: &mut Span, model_metadata: Option<&ModelMetadata>) {
477 let duration = span
478 .get_value("span.duration")
479 .and_then(|v| v.as_f64())
480 .unwrap_or(0.0);
481
482 enrich_ai_span_data(
483 &mut span.data,
484 &span.op,
485 &span.measurements,
486 duration,
487 model_metadata,
488 span.origin.as_str(),
489 span.platform.as_str(),
490 );
491}
492
493pub fn enrich_ai_event_data(event: &mut Event, model_metadata: Option<&ModelMetadata>) {
495 let event_duration = event
496 .get_value("event.duration")
497 .and_then(|v| v.as_f64())
498 .unwrap_or(0.0);
499
500 if let Some(trace_context) = event
501 .contexts
502 .value_mut()
503 .as_mut()
504 .and_then(|c| c.get_mut::<TraceContext>())
505 {
506 enrich_ai_span_data(
507 &mut trace_context.data,
508 &trace_context.op,
509 &event.measurements,
510 event_duration,
511 model_metadata,
512 trace_context.origin.as_str(),
513 event.platform.as_str(),
514 );
515 }
516 let spans = event.spans.value_mut().iter_mut().flatten();
517 let spans = spans.filter_map(|span| span.value_mut().as_mut());
518
519 for span in spans {
520 let span_duration = span
521 .get_value("span.duration")
522 .and_then(|v| v.as_f64())
523 .unwrap_or(0.0);
524 let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
525
526 enrich_ai_span_data(
527 &mut span.data,
528 &span.op,
529 &span.measurements,
530 span_duration,
531 model_metadata,
532 span.origin.as_str(),
533 span_platform,
534 );
535 }
536}
537
538fn is_ai_span(span_data: &Annotated<SpanData>, span_op: Option<&OperationType>) -> bool {
542 let has_ai_op = span_data
543 .value()
544 .and_then(|data| data.get_value(GEN_AI__OPERATION__NAME))
545 .is_some();
546
547 let is_ai_span_op =
548 span_op.is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."));
549
550 has_ai_op || is_ai_span_op
551}
552
553#[cfg(test)]
554mod tests {
555 use std::collections::HashMap;
556
557 use relay_protocol::{FromValue, assert_annotated_snapshot};
558 use serde_json::json;
559
560 use super::*;
561 use crate::ModelMetadataEntry;
562
563 fn ai_span_with_data(data: serde_json::Value) -> Span {
564 Span {
565 op: "gen_ai.test".to_owned().into(),
566 data: SpanData::from_value(data.into()),
567 ..Default::default()
568 }
569 }
570
571 #[test]
572 fn test_has_valid_total_cost() {
573 let missing = ai_span_with_data(json!({}));
574 let invalid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": false}));
575 let valid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": 1.0}));
576
577 assert!(!has_valid_total_cost(missing.data.value().unwrap()));
578 assert!(!has_valid_total_cost(invalid.data.value().unwrap()));
579 assert!(has_valid_total_cost(valid.data.value().unwrap()));
580 }
581
582 #[test]
583 fn test_calculate_cost_no_tokens() {
584 let cost = calculate_costs(
585 &ModelCostV2 {
586 input_per_token: 1.0,
587 output_per_token: 1.0,
588 output_reasoning_per_token: 1.0,
589 input_cached_per_token: 1.0,
590 input_cache_write_per_token: 1.0,
591 },
592 UsedTokens::from_span_data(&SpanData::default()),
593 "test",
594 "test",
595 );
596 assert!(cost.is_none());
597 }
598
599 #[test]
600 fn test_calculate_cost_full() {
601 let cost = calculate_costs(
602 &ModelCostV2 {
603 input_per_token: 1.0,
604 output_per_token: 2.0,
605 output_reasoning_per_token: 3.0,
606 input_cached_per_token: 0.5,
607 input_cache_write_per_token: 0.75,
608 },
609 UsedTokens {
610 input_tokens: 8.0,
611 input_cached_tokens: 5.0,
612 input_cache_write_tokens: 0.0,
613 output_tokens: 15.0,
614 output_reasoning_tokens: 9.0,
615 },
616 "test",
617 "test",
618 )
619 .unwrap();
620
621 insta::assert_debug_snapshot!(cost, @r"
622 CalculatedCost {
623 input: 5.5,
624 output: 39.0,
625 cache_read_input: 2.5,
626 cache_creation_input: 0.0,
627 reasoning_output: 27.0,
628 }
629 ");
630 }
631
632 #[test]
633 fn test_calculate_cost_no_reasoning_cost() {
634 let cost = calculate_costs(
635 &ModelCostV2 {
636 input_per_token: 1.0,
637 output_per_token: 2.0,
638 output_reasoning_per_token: 0.0,
640 input_cached_per_token: 0.5,
641 input_cache_write_per_token: 0.0,
642 },
643 UsedTokens {
644 input_tokens: 8.0,
645 input_cached_tokens: 5.0,
646 input_cache_write_tokens: 0.0,
647 output_tokens: 15.0,
648 output_reasoning_tokens: 9.0,
649 },
650 "test",
651 "test",
652 )
653 .unwrap();
654
655 insta::assert_debug_snapshot!(cost, @r"
656 CalculatedCost {
657 input: 5.5,
658 output: 30.0,
659 cache_read_input: 2.5,
660 cache_creation_input: 0.0,
661 reasoning_output: 18.0,
662 }
663 ");
664 }
665
666 #[test]
670 fn test_calculate_cost_negative() {
671 let cost = calculate_costs(
672 &ModelCostV2 {
673 input_per_token: 2.0,
674 output_per_token: 2.0,
675 output_reasoning_per_token: 1.0,
676 input_cached_per_token: 1.0,
677 input_cache_write_per_token: 1.5,
678 },
679 UsedTokens {
680 input_tokens: 1.0,
681 input_cached_tokens: 11.0,
682 input_cache_write_tokens: 0.0,
683 output_tokens: 1.0,
684 output_reasoning_tokens: 9.0,
685 },
686 "test",
687 "test",
688 )
689 .unwrap();
690
691 insta::assert_debug_snapshot!(cost, @r"
692 CalculatedCost {
693 input: -9.0,
694 output: -7.0,
695 cache_read_input: 11.0,
696 cache_creation_input: 0.0,
697 reasoning_output: 9.0,
698 }
699 ");
700 }
701
702 #[test]
703 fn test_calculate_cost_with_cache_writes() {
704 let cost = calculate_costs(
705 &ModelCostV2 {
706 input_per_token: 1.0,
707 output_per_token: 2.0,
708 output_reasoning_per_token: 3.0,
709 input_cached_per_token: 0.5,
710 input_cache_write_per_token: 0.75,
711 },
712 UsedTokens {
713 input_tokens: 100.0,
714 input_cached_tokens: 20.0,
715 input_cache_write_tokens: 30.0,
716 output_tokens: 50.0,
717 output_reasoning_tokens: 10.0,
718 },
719 "test",
720 "test",
721 )
722 .unwrap();
723
724 insta::assert_debug_snapshot!(cost, @r"
728 CalculatedCost {
729 input: 82.5,
730 output: 110.0,
731 cache_read_input: 10.0,
732 cache_creation_input: 22.5,
733 reasoning_output: 30.0,
734 }
735 ");
736 }
737
738 #[test]
739 fn test_existing_cost_is_not_overwritten() {
740 let mut span = ai_span_with_data(json!({
741 "gen_ai.response.model": "claude-2.1",
742 "gen_ai.usage.input_tokens": 1000.0,
743 "gen_ai.cost.input_tokens": 99.0,
744 "gen_ai.cost.total_tokens": 123.0,
745 }));
746
747 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
748
749 let data = span.data.value().unwrap();
750 assert_eq!(
751 data.get_value(GEN_AI__COST__TOTAL_TOKENS)
752 .and_then(Value::as_f64),
753 Some(123.0)
754 );
755 assert!(data.get_value(GEN_AI__COST__OUTPUT_TOKENS).is_none());
756 }
757
758 #[test]
759 fn test_calculate_cost_backward_compatibility_no_cache_write() {
760 let span_data = SpanData::from([
762 (
763 GEN_AI__USAGE__INPUT_TOKENS.to_owned(),
764 Annotated::new(100.0.into()),
765 ),
766 (
767 GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS.to_owned(),
768 Annotated::new(20.0.into()),
769 ),
770 (
771 GEN_AI__USAGE__OUTPUT_TOKENS.to_owned(),
772 Annotated::new(50.0.into()),
773 ),
774 ]);
775
776 let tokens = UsedTokens::from_span_data(&span_data);
777
778 assert_eq!(tokens.input_cache_write_tokens, 0.0);
780
781 let cost = calculate_costs(
782 &ModelCostV2 {
783 input_per_token: 1.0,
784 output_per_token: 2.0,
785 output_reasoning_per_token: 0.0,
786 input_cached_per_token: 0.5,
787 input_cache_write_per_token: 0.75,
788 },
789 tokens,
790 "test",
791 "test",
792 )
793 .unwrap();
794
795 insta::assert_debug_snapshot!(cost, @r"
799 CalculatedCost {
800 input: 90.0,
801 output: 100.0,
802 cache_read_input: 10.0,
803 cache_creation_input: 0.0,
804 reasoning_output: 0.0,
805 }
806 ");
807 }
808
809 #[test]
811 fn test_infer_ai_operation_type_from_gen_ai_operation_name() {
812 let mut span = ai_span_with_data(json!({
813 "gen_ai.operation.name": "invoke_agent"
814 }));
815
816 enrich_ai_span(&mut span, None);
817
818 assert_annotated_snapshot!(&span.data, @r#"
819 {
820 "gen_ai.operation.name": "invoke_agent",
821 "gen_ai.operation.type": "agent"
822 }
823 "#);
824 }
825
826 #[test]
828 fn test_infer_ai_operation_type_from_span_op() {
829 let mut span = Span {
830 op: "gen_ai.invoke_agent".to_owned().into(),
831 ..Default::default()
832 };
833
834 enrich_ai_span(&mut span, None);
835
836 assert_annotated_snapshot!(span.data, @r#"
837 {
838 "gen_ai.operation.type": "agent"
839 }
840 "#);
841 }
842
843 #[test]
845 fn test_infer_ai_operation_type_memory_from_gen_ai_operation_name() {
846 let mut span = ai_span_with_data(json!({
847 "gen_ai.operation.name": "upsert_memory"
848 }));
849
850 enrich_ai_span(&mut span, None);
851
852 assert_annotated_snapshot!(&span.data, @r#"
853 {
854 "gen_ai.operation.name": "upsert_memory",
855 "gen_ai.operation.type": "memory"
856 }
857 "#);
858 }
859
860 #[test]
862 fn test_infer_ai_operation_type_memory_from_span_op() {
863 let mut span = Span {
864 op: "gen_ai.memory.client".to_owned().into(),
865 ..Default::default()
866 };
867
868 enrich_ai_span(&mut span, None);
869
870 assert_annotated_snapshot!(span.data, @r#"
871 {
872 "gen_ai.operation.type": "memory"
873 }
874 "#);
875 }
876
877 #[test]
879 fn test_infer_ai_operation_type_from_fallback() {
880 let mut span = ai_span_with_data(json!({
881 "gen_ai.operation.name": "embeddings"
882 }));
883
884 enrich_ai_span(&mut span, None);
885
886 assert_annotated_snapshot!(&span.data, @r#"
887 {
888 "gen_ai.operation.name": "embeddings",
889 "gen_ai.operation.type": "other"
890 }
891 "#);
892 }
893
894 #[test]
895 fn test_infer_ai_operation_type_fallback_with_model() {
896 for model_key in [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL] {
897 let mut span = ai_span_with_data(json!({
898 "gen_ai.operation.name": "embeddings",
899 (model_key): "gpt-4",
900 }));
901
902 enrich_ai_span(&mut span, None);
903
904 assert_eq!(
905 span.data.value().unwrap().get_str(GEN_AI__OPERATION__TYPE),
906 Some("ai_client"),
907 );
908 }
909 }
910
911 #[test]
913 fn test_default_response_model_from_request_model() {
914 let mut span = ai_span_with_data(json!({
915 "gen_ai.request.model": "gpt-4",
916 }));
917
918 enrich_ai_span(&mut span, None);
919
920 assert_annotated_snapshot!(&span.data, @r#"
921 {
922 "gen_ai.operation.type": "ai_client",
923 "gen_ai.request.model": "gpt-4",
924 "gen_ai.response.model": "gpt-4"
925 }
926 "#);
927 }
928
929 #[test]
931 fn test_default_response_model_not_overridden() {
932 let mut span = ai_span_with_data(json!({
933 "gen_ai.request.model": "gpt-4",
934 "gen_ai.response.model": "gpt-4-abcd",
935 }));
936
937 enrich_ai_span(&mut span, None);
938
939 assert_annotated_snapshot!(&span.data, @r#"
940 {
941 "gen_ai.operation.type": "ai_client",
942 "gen_ai.request.model": "gpt-4",
943 "gen_ai.response.model": "gpt-4-abcd"
944 }
945 "#);
946 }
947
948 #[test]
950 fn test_default_agent_name_from_function_id() {
951 let mut span = ai_span_with_data(json!({
952 "gen_ai.function_id": "my-agent",
953 }));
954
955 enrich_ai_span(&mut span, None);
956
957 assert_annotated_snapshot!(&span.data, @r#"
958 {
959 "gen_ai.agent.name": "my-agent",
960 "gen_ai.function_id": "my-agent",
961 "gen_ai.operation.type": "other"
962 }
963 "#);
964 }
965
966 #[test]
968 fn test_default_agent_name_not_overridden() {
969 let mut span = ai_span_with_data(json!({
970 "gen_ai.function_id": "my-function",
971 "gen_ai.agent.name": "my-agent",
972 }));
973
974 enrich_ai_span(&mut span, None);
975
976 assert_annotated_snapshot!(&span.data, @r#"
977 {
978 "gen_ai.agent.name": "my-agent",
979 "gen_ai.function_id": "my-function",
980 "gen_ai.operation.type": "other"
981 }
982 "#);
983 }
984
985 #[test]
987 fn test_is_ai_span_from_gen_ai_operation_name() {
988 let mut span_data = Annotated::default();
989 span_data
990 .get_or_insert_with(SpanData::default)
991 .other
992 .insert(
993 GEN_AI__OPERATION__NAME.to_owned(),
994 Annotated::new(Value::String("chat".into())),
995 );
996 assert!(is_ai_span(&span_data, None));
997 }
998
999 #[test]
1001 fn test_is_ai_span_from_span_op_ai() {
1002 let span_op: OperationType = "ai.chat".into();
1003 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1004 }
1005
1006 #[test]
1008 fn test_is_ai_span_from_span_op_gen_ai() {
1009 let span_op: OperationType = "gen_ai.chat".into();
1010 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1011 }
1012
1013 #[test]
1015 fn test_is_ai_span_negative() {
1016 assert!(!is_ai_span(&Annotated::default(), None));
1017 }
1018
1019 #[test]
1021 fn test_enrich_ai_event_data_invoke_agent_trace_with_chat_span() {
1022 let event_json = r#"{
1023 "type": "transaction",
1024 "timestamp": 1234567892.0,
1025 "start_timestamp": 1234567889.0,
1026 "contexts": {
1027 "trace": {
1028 "op": "gen_ai.invoke_agent",
1029 "trace_id": "12345678901234567890123456789012",
1030 "span_id": "1234567890123456",
1031 "data": {
1032 "gen_ai.operation.name": "gen_ai.invoke_agent",
1033 "gen_ai.usage.input_tokens": 500,
1034 "gen_ai.usage.output_tokens": 200
1035 }
1036 }
1037 },
1038 "spans": [
1039 {
1040 "op": "gen_ai.chat.completions",
1041 "span_id": "1234567890123457",
1042 "start_timestamp": 1234567889.5,
1043 "timestamp": 1234567890.5,
1044 "data": {
1045 "gen_ai.operation.name": "chat",
1046 "gen_ai.usage.input_tokens": 100,
1047 "gen_ai.usage.output_tokens": 50
1048 }
1049 }
1050 ]
1051 }"#;
1052
1053 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1054 let event = annotated_event.value_mut().as_mut().unwrap();
1055
1056 enrich_ai_event_data(event, None);
1057
1058 assert_annotated_snapshot!(&annotated_event, @r#"
1059 {
1060 "type": "transaction",
1061 "timestamp": 1234567892.0,
1062 "start_timestamp": 1234567889.0,
1063 "contexts": {
1064 "trace": {
1065 "trace_id": "12345678901234567890123456789012",
1066 "span_id": "1234567890123456",
1067 "op": "gen_ai.invoke_agent",
1068 "data": {
1069 "gen_ai.operation.name": "gen_ai.invoke_agent",
1070 "gen_ai.operation.type": "agent",
1071 "gen_ai.usage.input_tokens": 500,
1072 "gen_ai.usage.output_tokens": 200,
1073 "gen_ai.usage.total_tokens": 700.0
1074 },
1075 "type": "trace"
1076 }
1077 },
1078 "spans": [
1079 {
1080 "timestamp": 1234567890.5,
1081 "start_timestamp": 1234567889.5,
1082 "op": "gen_ai.chat.completions",
1083 "span_id": "1234567890123457",
1084 "data": {
1085 "gen_ai.operation.name": "chat",
1086 "gen_ai.operation.type": "other",
1087 "gen_ai.usage.input_tokens": 100,
1088 "gen_ai.usage.output_tokens": 50,
1089 "gen_ai.usage.total_tokens": 150.0
1090 }
1091 }
1092 ]
1093 }
1094 "#);
1095 }
1096
1097 #[test]
1099 fn test_enrich_ai_event_data_nested_agent_and_chat_spans() {
1100 let event_json = r#"{
1101 "type": "transaction",
1102 "timestamp": 1234567892.0,
1103 "start_timestamp": 1234567889.0,
1104 "contexts": {
1105 "trace": {
1106 "op": "http.server",
1107 "trace_id": "12345678901234567890123456789012",
1108 "span_id": "1234567890123456"
1109 }
1110 },
1111 "spans": [
1112 {
1113 "op": "gen_ai.invoke_agent",
1114 "span_id": "1234567890123457",
1115 "parent_span_id": "1234567890123456",
1116 "start_timestamp": 1234567889.5,
1117 "timestamp": 1234567891.5,
1118 "data": {
1119 "gen_ai.operation.name": "invoke_agent",
1120 "gen_ai.usage.input_tokens": 500,
1121 "gen_ai.usage.output_tokens": 200
1122 }
1123 },
1124 {
1125 "op": "gen_ai.chat.completions",
1126 "span_id": "1234567890123458",
1127 "parent_span_id": "1234567890123457",
1128 "start_timestamp": 1234567890.0,
1129 "timestamp": 1234567891.0,
1130 "data": {
1131 "gen_ai.operation.name": "chat",
1132 "gen_ai.usage.input_tokens": 100,
1133 "gen_ai.usage.output_tokens": 50
1134 }
1135 }
1136 ]
1137 }"#;
1138
1139 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1140 let event = annotated_event.value_mut().as_mut().unwrap();
1141
1142 enrich_ai_event_data(event, None);
1143
1144 assert_annotated_snapshot!(&annotated_event, @r#"
1145 {
1146 "type": "transaction",
1147 "timestamp": 1234567892.0,
1148 "start_timestamp": 1234567889.0,
1149 "contexts": {
1150 "trace": {
1151 "trace_id": "12345678901234567890123456789012",
1152 "span_id": "1234567890123456",
1153 "op": "http.server",
1154 "type": "trace"
1155 }
1156 },
1157 "spans": [
1158 {
1159 "timestamp": 1234567891.5,
1160 "start_timestamp": 1234567889.5,
1161 "op": "gen_ai.invoke_agent",
1162 "span_id": "1234567890123457",
1163 "parent_span_id": "1234567890123456",
1164 "data": {
1165 "gen_ai.operation.name": "invoke_agent",
1166 "gen_ai.operation.type": "agent",
1167 "gen_ai.usage.input_tokens": 500,
1168 "gen_ai.usage.output_tokens": 200,
1169 "gen_ai.usage.total_tokens": 700.0
1170 }
1171 },
1172 {
1173 "timestamp": 1234567891.0,
1174 "start_timestamp": 1234567890.0,
1175 "op": "gen_ai.chat.completions",
1176 "span_id": "1234567890123458",
1177 "parent_span_id": "1234567890123457",
1178 "data": {
1179 "gen_ai.operation.name": "chat",
1180 "gen_ai.operation.type": "other",
1181 "gen_ai.usage.input_tokens": 100,
1182 "gen_ai.usage.output_tokens": 50,
1183 "gen_ai.usage.total_tokens": 150.0
1184 }
1185 }
1186 ]
1187 }
1188 "#);
1189 }
1190
1191 #[test]
1193 fn test_enrich_ai_event_data_legacy_measurements_and_span_op() {
1194 let event_json = r#"{
1195 "type": "transaction",
1196 "timestamp": 1234567892.0,
1197 "start_timestamp": 1234567889.0,
1198 "contexts": {
1199 "trace": {
1200 "op": "http.server",
1201 "trace_id": "12345678901234567890123456789012",
1202 "span_id": "1234567890123456"
1203 }
1204 },
1205 "spans": [
1206 {
1207 "op": "gen_ai.invoke_agent",
1208 "span_id": "1234567890123457",
1209 "parent_span_id": "1234567890123456",
1210 "start_timestamp": 1234567889.5,
1211 "timestamp": 1234567891.5,
1212 "measurements": {
1213 "ai_prompt_tokens_used": {"value": 500.0},
1214 "ai_completion_tokens_used": {"value": 200.0}
1215 }
1216 },
1217 {
1218 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1219 "span_id": "1234567890123458",
1220 "parent_span_id": "1234567890123457",
1221 "start_timestamp": 1234567890.0,
1222 "timestamp": 1234567891.0,
1223 "measurements": {
1224 "ai_prompt_tokens_used": {"value": 100.0},
1225 "ai_completion_tokens_used": {"value": 50.0}
1226 }
1227 }
1228 ]
1229 }"#;
1230
1231 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1232 let event = annotated_event.value_mut().as_mut().unwrap();
1233
1234 enrich_ai_event_data(event, None);
1235
1236 assert_annotated_snapshot!(&annotated_event, @r#"
1237 {
1238 "type": "transaction",
1239 "timestamp": 1234567892.0,
1240 "start_timestamp": 1234567889.0,
1241 "contexts": {
1242 "trace": {
1243 "trace_id": "12345678901234567890123456789012",
1244 "span_id": "1234567890123456",
1245 "op": "http.server",
1246 "type": "trace"
1247 }
1248 },
1249 "spans": [
1250 {
1251 "timestamp": 1234567891.5,
1252 "start_timestamp": 1234567889.5,
1253 "op": "gen_ai.invoke_agent",
1254 "span_id": "1234567890123457",
1255 "parent_span_id": "1234567890123456",
1256 "data": {
1257 "gen_ai.operation.type": "agent",
1258 "gen_ai.usage.input_tokens": 500.0,
1259 "gen_ai.usage.output_tokens": 200.0,
1260 "gen_ai.usage.total_tokens": 700.0
1261 },
1262 "measurements": {
1263 "ai_completion_tokens_used": {
1264 "value": 200.0
1265 },
1266 "ai_prompt_tokens_used": {
1267 "value": 500.0
1268 }
1269 }
1270 },
1271 {
1272 "timestamp": 1234567891.0,
1273 "start_timestamp": 1234567890.0,
1274 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1275 "span_id": "1234567890123458",
1276 "parent_span_id": "1234567890123457",
1277 "data": {
1278 "gen_ai.operation.type": "other",
1279 "gen_ai.usage.input_tokens": 100.0,
1280 "gen_ai.usage.output_tokens": 50.0,
1281 "gen_ai.usage.total_tokens": 150.0
1282 },
1283 "measurements": {
1284 "ai_completion_tokens_used": {
1285 "value": 50.0
1286 },
1287 "ai_prompt_tokens_used": {
1288 "value": 100.0
1289 }
1290 }
1291 }
1292 ]
1293 }
1294 "#);
1295 }
1296
1297 fn metadata_with_context_size() -> ModelMetadata {
1298 ModelMetadata {
1299 version: 1,
1300 models: HashMap::from([(
1301 "claude-2.1".parse().unwrap(),
1302 ModelMetadataEntry {
1303 costs: Some(ModelCostV2 {
1304 input_per_token: 0.01,
1305 output_per_token: 0.02,
1306 output_reasoning_per_token: 0.0,
1307 input_cached_per_token: 0.0,
1308 input_cache_write_per_token: 0.0,
1309 }),
1310 context_size: Some(100_000),
1311 },
1312 )]),
1313 }
1314 }
1315
1316 #[test]
1317 fn test_context_utilization_with_total_tokens() {
1318 let mut span = Span {
1319 op: "gen_ai.test".to_owned().into(),
1320 data: SpanData::from_value(
1321 json!({
1322 "gen_ai.response.model": "claude-2.1",
1323 "gen_ai.usage.input_tokens": 30000.0,
1324 "gen_ai.usage.output_tokens": 12000.0,
1325 "gen_ai.usage.total_tokens": 42000.0,
1326 })
1327 .into(),
1328 ),
1329 ..Default::default()
1330 };
1331
1332 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1333
1334 let data = span.data.value().unwrap();
1335 assert_eq!(
1336 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1337 .and_then(Value::as_f64),
1338 Some(100_000.0)
1339 );
1340 assert_eq!(
1341 data.get_value(GEN_AI__CONTEXT__UTILIZATION)
1342 .and_then(Value::as_f64),
1343 Some(0.42)
1344 );
1345 }
1346
1347 #[test]
1348 fn test_context_utilization_no_context_size() {
1349 let metadata = ModelMetadata {
1350 version: 1,
1351 models: HashMap::from([(
1352 "claude-2.1".parse().unwrap(),
1353 ModelMetadataEntry {
1354 costs: None,
1355 context_size: None,
1356 },
1357 )]),
1358 };
1359
1360 let mut span = Span {
1361 op: "gen_ai.test".to_owned().into(),
1362 data: SpanData::from_value(
1363 json!({
1364 "gen_ai.response.model": "claude-2.1",
1365 "gen_ai.usage.total_tokens": 1000.0,
1366 })
1367 .into(),
1368 ),
1369 ..Default::default()
1370 };
1371
1372 enrich_ai_span(&mut span, Some(&metadata));
1373
1374 let data = span.data.value().unwrap();
1375 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1376 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1377 }
1378
1379 #[test]
1380 fn test_context_utilization_no_total_tokens() {
1381 let mut span = Span {
1382 op: "gen_ai.test".to_owned().into(),
1383 data: SpanData::from_value(
1384 json!({
1385 "gen_ai.response.model": "claude-2.1",
1386 })
1387 .into(),
1388 ),
1389 ..Default::default()
1390 };
1391
1392 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1393
1394 let data = span.data.value().unwrap();
1395 assert_eq!(
1397 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1398 .and_then(Value::as_f64),
1399 Some(100_000.0)
1400 );
1401 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1403 }
1404
1405 #[test]
1406 fn test_context_utilization_unknown_model() {
1407 let mut span = Span {
1408 op: "gen_ai.test".to_owned().into(),
1409 data: SpanData::from_value(
1410 json!({
1411 "gen_ai.response.model": "unknown-model",
1412 "gen_ai.usage.total_tokens": 1000.0,
1413 })
1414 .into(),
1415 ),
1416 ..Default::default()
1417 };
1418
1419 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1420
1421 let data = span.data.value().unwrap();
1422 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1423 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1424 }
1425}