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 const DEFAULT_AI_OPERATION: &str = "ai_client";
158
159pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
170 let ai_op = match op_name {
171 "ai.run.generateText"
173 | "ai.run.generateObject"
174 | "gen_ai.invoke_agent"
175 | "ai.pipeline.generate_text"
176 | "ai.pipeline.generate_object"
177 | "ai.pipeline.stream_text"
178 | "ai.pipeline.stream_object"
179 | "gen_ai.create_agent"
180 | "invoke_agent"
181 | "create_agent" => "agent",
182 "gen_ai.execute_tool" | "execute_tool" => "tool",
183 "gen_ai.handoff" | "handoff" => "handoff",
184 "ai.processor" | "processor_run" => "other",
185 op if op.starts_with("ai.streamText.doStream") => "ai_client",
187 op if op.starts_with("ai.streamText") => "agent",
188
189 op if op.starts_with("ai.generateText.doGenerate") => "ai_client",
190 op if op.starts_with("ai.generateText") => "agent",
191
192 op if op.starts_with("ai.generateObject.doGenerate") => "ai_client",
193 op if op.starts_with("ai.generateObject") => "agent",
194
195 op if op.starts_with("ai.toolCall") => "tool",
196 _ => return None,
198 };
199
200 Some(ai_op)
201}
202
203pub fn has_valid_total_cost(attributes: &impl AttributesLike) -> bool {
205 attributes
206 .get_value(GEN_AI__COST__TOTAL_TOKENS)
207 .and_then(Value::as_f64)
208 .is_some()
209}
210
211fn extract_ai_model_cost_data(
214 model_cost: Option<&ModelCostV2>,
215 data: &mut SpanData,
216 origin: Option<&str>,
217 platform: Option<&str>,
218) {
219 if has_valid_total_cost(data) {
221 return;
222 }
223
224 let integration = map_origin_to_integration(origin);
225 let platform = platform_tag(platform);
226
227 let Some(model_cost) = model_cost else {
228 relay_statsd::metric!(
229 counter(Counters::GenAiCostCalculationResult) += 1,
230 result = "calculation_no_model_cost_available",
231 integration = integration,
232 platform = platform,
233 );
234 return;
235 };
236
237 let used_tokens = UsedTokens::from_span_data(&*data);
238 let Some(costs) = calculate_costs(model_cost, used_tokens, integration, platform) else {
239 return;
240 };
241
242 data.other
243 .entry(GEN_AI__COST__TOTAL_TOKENS.to_owned())
244 .or_default()
245 .set_value(Value::F64(costs.total()).into());
246
247 data.other
249 .entry(GEN_AI__COST__INPUT_TOKENS.to_owned())
250 .or_default()
251 .set_value(Value::F64(costs.input).into());
252 data.other
253 .entry(GEN_AI__COST__CACHE_READ__INPUT_TOKENS.to_owned())
254 .or_default()
255 .set_value(Value::F64(costs.cache_read_input).into());
256 data.other
257 .entry(GEN_AI__COST__CACHE_CREATION__INPUT_TOKENS.to_owned())
258 .or_default()
259 .set_value(Value::F64(costs.cache_creation_input).into());
260
261 data.other
262 .entry(GEN_AI__COST__OUTPUT_TOKENS.to_owned())
263 .or_default()
264 .set_value(Value::F64(costs.output).into());
265
266 data.other
267 .entry(GEN_AI__COST__REASONING__OUTPUT_TOKENS.to_owned())
268 .or_default()
269 .set_value(Value::F64(costs.reasoning_output).into());
270}
271
272fn map_ai_measurements_to_data(data: &mut SpanData, measurements: Option<&Measurements>) {
274 let set_field_from_measurement = |target_field: &mut Annotated<Value>,
275 measurement_key: &str| {
276 if let Some(measurements) = measurements
277 && target_field.value().is_none()
278 && let Some(value) = measurements.get_value(measurement_key)
279 {
280 target_field.set_value(Value::F64(value.to_f64()).into());
281 }
282 };
283
284 set_field_from_measurement(
285 data.other
286 .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
287 .or_default(),
288 "ai_total_tokens_used",
289 );
290 set_field_from_measurement(
291 data.other
292 .entry(GEN_AI__USAGE__INPUT_TOKENS.to_owned())
293 .or_default(),
294 "ai_prompt_tokens_used",
295 );
296 set_field_from_measurement(
297 data.other
298 .entry(GEN_AI__USAGE__OUTPUT_TOKENS.to_owned())
299 .or_default(),
300 "ai_completion_tokens_used",
301 );
302}
303
304fn set_total_tokens(data: &mut SpanData) {
305 if data.get_value(GEN_AI__USAGE__TOTAL_TOKENS).is_none() {
307 let input_tokens = data
308 .get_value(GEN_AI__USAGE__INPUT_TOKENS)
309 .and_then(Value::as_f64);
310 let output_tokens = data
311 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
312 .and_then(Value::as_f64);
313
314 if input_tokens.is_none() && output_tokens.is_none() {
315 return;
317 }
318
319 data.other
320 .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
321 .or_default()
322 .set_value(
323 Value::F64(input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0)).into(),
324 );
325 }
326}
327
328fn extract_context_utilization(data: &mut SpanData, model_metadata: &ModelMetadata) {
330 let model_id = data.get_str(GEN_AI__RESPONSE__MODEL);
331
332 let context_size = model_id.and_then(|id| model_metadata.context_size(id));
333
334 let Some(context_size) = context_size else {
335 return;
336 };
337
338 data.other
339 .entry(GEN_AI__CONTEXT__WINDOW_SIZE.to_owned())
340 .or_default()
341 .set_value(Value::U64(context_size).into());
342
343 let total_tokens = data
344 .get_value(GEN_AI__USAGE__TOTAL_TOKENS)
345 .and_then(Value::as_f64);
346
347 if let Some(total_tokens) = total_tokens {
348 data.other
349 .entry(GEN_AI__CONTEXT__UTILIZATION.to_owned())
350 .or_default()
351 .set_value(Value::F64(total_tokens / context_size as f64).into());
352 }
353}
354
355fn extract_ai_data(
357 data: &mut SpanData,
358 duration: f64,
359 model_metadata: &ModelMetadata,
360 origin: Option<&str>,
361 platform: Option<&str>,
362) {
363 if data
365 .get_value(GEN_AI__RESPONSE__TOKENS_PER_SECOND)
366 .is_none()
367 && duration > 0.0
368 && let Some(output_tokens) = data
369 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
370 .and_then(Value::as_f64)
371 {
372 data.other
373 .entry(GEN_AI__RESPONSE__TOKENS_PER_SECOND.to_owned())
374 .or_default()
375 .set_value(Value::F64(output_tokens / (duration / 1000.0)).into());
376 }
377
378 extract_context_utilization(data, model_metadata);
379
380 if let Some(model_id) = data.get_str(GEN_AI__RESPONSE__MODEL) {
382 extract_ai_model_cost_data(
383 model_metadata.cost_per_token(model_id),
384 data,
385 origin,
386 platform,
387 )
388 } else {
389 relay_statsd::metric!(
390 counter(Counters::GenAiCostCalculationResult) += 1,
391 result = "calculation_no_model_id_available",
392 integration = map_origin_to_integration(origin),
393 platform = platform_tag(platform),
394 );
395 }
396}
397
398fn enrich_ai_span_data(
400 span_data: &mut Annotated<SpanData>,
401 span_op: &Annotated<OperationType>,
402 measurements: &Annotated<Measurements>,
403 duration: f64,
404 model_metadata: Option<&ModelMetadata>,
405 origin: Option<&str>,
406 platform: Option<&str>,
407) {
408 if !is_ai_span(span_data, span_op.value()) {
409 return;
410 }
411
412 let data = span_data.get_or_insert_with(SpanData::default);
413
414 map_ai_measurements_to_data(data, measurements.value());
415
416 set_total_tokens(data);
417
418 if data.get_value(GEN_AI__RESPONSE__MODEL).is_none()
420 && let Some(request_model) = data.get_value(GEN_AI__REQUEST__MODEL).cloned()
421 {
422 data.other
423 .entry(GEN_AI__RESPONSE__MODEL.to_owned())
424 .or_default()
425 .set_value(Some(request_model));
426 }
427
428 if data.get_value(GEN_AI__AGENT__NAME).is_none()
430 && let Some(function_id) = data.get_value(GEN_AI__FUNCTION_ID).cloned()
431 {
432 data.other
433 .entry(GEN_AI__AGENT__NAME.to_owned())
434 .or_default()
435 .set_value(Some(function_id));
436 }
437
438 if let Some(model_metadata) = model_metadata {
439 extract_ai_data(data, duration, model_metadata, origin, platform);
440 } else {
441 relay_statsd::metric!(
442 counter(Counters::GenAiCostCalculationResult) += 1,
443 result = "calculation_no_model_cost_available",
444 integration = map_origin_to_integration(origin),
445 platform = platform_tag(platform),
446 );
447 }
448
449 let ai_op_type = data
450 .get_str(GEN_AI__OPERATION__NAME)
451 .or(span_op.value().map(String::as_str))
452 .and_then(infer_ai_operation_type)
453 .unwrap_or(DEFAULT_AI_OPERATION);
454
455 data.other
456 .entry(GEN_AI__OPERATION__TYPE.to_owned())
457 .or_default()
458 .set_value(Some(Value::String(ai_op_type.to_owned())));
459}
460
461pub fn enrich_ai_span(span: &mut Span, model_metadata: Option<&ModelMetadata>) {
463 let duration = span
464 .get_value("span.duration")
465 .and_then(|v| v.as_f64())
466 .unwrap_or(0.0);
467
468 enrich_ai_span_data(
469 &mut span.data,
470 &span.op,
471 &span.measurements,
472 duration,
473 model_metadata,
474 span.origin.as_str(),
475 span.platform.as_str(),
476 );
477}
478
479pub fn enrich_ai_event_data(event: &mut Event, model_metadata: Option<&ModelMetadata>) {
481 let event_duration = event
482 .get_value("event.duration")
483 .and_then(|v| v.as_f64())
484 .unwrap_or(0.0);
485
486 if let Some(trace_context) = event
487 .contexts
488 .value_mut()
489 .as_mut()
490 .and_then(|c| c.get_mut::<TraceContext>())
491 {
492 enrich_ai_span_data(
493 &mut trace_context.data,
494 &trace_context.op,
495 &event.measurements,
496 event_duration,
497 model_metadata,
498 trace_context.origin.as_str(),
499 event.platform.as_str(),
500 );
501 }
502 let spans = event.spans.value_mut().iter_mut().flatten();
503 let spans = spans.filter_map(|span| span.value_mut().as_mut());
504
505 for span in spans {
506 let span_duration = span
507 .get_value("span.duration")
508 .and_then(|v| v.as_f64())
509 .unwrap_or(0.0);
510 let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
511
512 enrich_ai_span_data(
513 &mut span.data,
514 &span.op,
515 &span.measurements,
516 span_duration,
517 model_metadata,
518 span.origin.as_str(),
519 span_platform,
520 );
521 }
522}
523
524fn is_ai_span(span_data: &Annotated<SpanData>, span_op: Option<&OperationType>) -> bool {
528 let has_ai_op = span_data
529 .value()
530 .and_then(|data| data.get_value(GEN_AI__OPERATION__NAME))
531 .is_some();
532
533 let is_ai_span_op =
534 span_op.is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."));
535
536 has_ai_op || is_ai_span_op
537}
538
539#[cfg(test)]
540mod tests {
541 use std::collections::HashMap;
542
543 use relay_pattern::Pattern;
544 use relay_protocol::{FromValue, assert_annotated_snapshot};
545 use serde_json::json;
546
547 use super::*;
548 use crate::ModelMetadataEntry;
549
550 fn ai_span_with_data(data: serde_json::Value) -> Span {
551 Span {
552 op: "gen_ai.test".to_owned().into(),
553 data: SpanData::from_value(data.into()),
554 ..Default::default()
555 }
556 }
557
558 #[test]
559 fn test_has_valid_total_cost() {
560 let missing = ai_span_with_data(json!({}));
561 let invalid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": false}));
562 let valid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": 1.0}));
563
564 assert!(!has_valid_total_cost(missing.data.value().unwrap()));
565 assert!(!has_valid_total_cost(invalid.data.value().unwrap()));
566 assert!(has_valid_total_cost(valid.data.value().unwrap()));
567 }
568
569 #[test]
570 fn test_calculate_cost_no_tokens() {
571 let cost = calculate_costs(
572 &ModelCostV2 {
573 input_per_token: 1.0,
574 output_per_token: 1.0,
575 output_reasoning_per_token: 1.0,
576 input_cached_per_token: 1.0,
577 input_cache_write_per_token: 1.0,
578 },
579 UsedTokens::from_span_data(&SpanData::default()),
580 "test",
581 "test",
582 );
583 assert!(cost.is_none());
584 }
585
586 #[test]
587 fn test_calculate_cost_full() {
588 let cost = calculate_costs(
589 &ModelCostV2 {
590 input_per_token: 1.0,
591 output_per_token: 2.0,
592 output_reasoning_per_token: 3.0,
593 input_cached_per_token: 0.5,
594 input_cache_write_per_token: 0.75,
595 },
596 UsedTokens {
597 input_tokens: 8.0,
598 input_cached_tokens: 5.0,
599 input_cache_write_tokens: 0.0,
600 output_tokens: 15.0,
601 output_reasoning_tokens: 9.0,
602 },
603 "test",
604 "test",
605 )
606 .unwrap();
607
608 insta::assert_debug_snapshot!(cost, @r"
609 CalculatedCost {
610 input: 5.5,
611 output: 39.0,
612 cache_read_input: 2.5,
613 cache_creation_input: 0.0,
614 reasoning_output: 27.0,
615 }
616 ");
617 }
618
619 #[test]
620 fn test_calculate_cost_no_reasoning_cost() {
621 let cost = calculate_costs(
622 &ModelCostV2 {
623 input_per_token: 1.0,
624 output_per_token: 2.0,
625 output_reasoning_per_token: 0.0,
627 input_cached_per_token: 0.5,
628 input_cache_write_per_token: 0.0,
629 },
630 UsedTokens {
631 input_tokens: 8.0,
632 input_cached_tokens: 5.0,
633 input_cache_write_tokens: 0.0,
634 output_tokens: 15.0,
635 output_reasoning_tokens: 9.0,
636 },
637 "test",
638 "test",
639 )
640 .unwrap();
641
642 insta::assert_debug_snapshot!(cost, @r"
643 CalculatedCost {
644 input: 5.5,
645 output: 30.0,
646 cache_read_input: 2.5,
647 cache_creation_input: 0.0,
648 reasoning_output: 18.0,
649 }
650 ");
651 }
652
653 #[test]
657 fn test_calculate_cost_negative() {
658 let cost = calculate_costs(
659 &ModelCostV2 {
660 input_per_token: 2.0,
661 output_per_token: 2.0,
662 output_reasoning_per_token: 1.0,
663 input_cached_per_token: 1.0,
664 input_cache_write_per_token: 1.5,
665 },
666 UsedTokens {
667 input_tokens: 1.0,
668 input_cached_tokens: 11.0,
669 input_cache_write_tokens: 0.0,
670 output_tokens: 1.0,
671 output_reasoning_tokens: 9.0,
672 },
673 "test",
674 "test",
675 )
676 .unwrap();
677
678 insta::assert_debug_snapshot!(cost, @r"
679 CalculatedCost {
680 input: -9.0,
681 output: -7.0,
682 cache_read_input: 11.0,
683 cache_creation_input: 0.0,
684 reasoning_output: 9.0,
685 }
686 ");
687 }
688
689 #[test]
690 fn test_calculate_cost_with_cache_writes() {
691 let cost = calculate_costs(
692 &ModelCostV2 {
693 input_per_token: 1.0,
694 output_per_token: 2.0,
695 output_reasoning_per_token: 3.0,
696 input_cached_per_token: 0.5,
697 input_cache_write_per_token: 0.75,
698 },
699 UsedTokens {
700 input_tokens: 100.0,
701 input_cached_tokens: 20.0,
702 input_cache_write_tokens: 30.0,
703 output_tokens: 50.0,
704 output_reasoning_tokens: 10.0,
705 },
706 "test",
707 "test",
708 )
709 .unwrap();
710
711 insta::assert_debug_snapshot!(cost, @r"
715 CalculatedCost {
716 input: 82.5,
717 output: 110.0,
718 cache_read_input: 10.0,
719 cache_creation_input: 22.5,
720 reasoning_output: 30.0,
721 }
722 ");
723 }
724
725 #[test]
726 fn test_existing_cost_is_not_overwritten() {
727 let mut span = ai_span_with_data(json!({
728 "gen_ai.response.model": "claude-2.1",
729 "gen_ai.usage.input_tokens": 1000.0,
730 "gen_ai.cost.input_tokens": 99.0,
731 "gen_ai.cost.total_tokens": 123.0,
732 }));
733
734 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
735
736 let data = span.data.value().unwrap();
737 assert_eq!(
738 data.get_value(GEN_AI__COST__TOTAL_TOKENS)
739 .and_then(Value::as_f64),
740 Some(123.0)
741 );
742 assert!(data.get_value(GEN_AI__COST__OUTPUT_TOKENS).is_none());
743 }
744
745 #[test]
746 fn test_calculate_cost_backward_compatibility_no_cache_write() {
747 let span_data = SpanData::from([
749 (
750 GEN_AI__USAGE__INPUT_TOKENS.to_owned(),
751 Annotated::new(100.0.into()),
752 ),
753 (
754 GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS.to_owned(),
755 Annotated::new(20.0.into()),
756 ),
757 (
758 GEN_AI__USAGE__OUTPUT_TOKENS.to_owned(),
759 Annotated::new(50.0.into()),
760 ),
761 ]);
762
763 let tokens = UsedTokens::from_span_data(&span_data);
764
765 assert_eq!(tokens.input_cache_write_tokens, 0.0);
767
768 let cost = calculate_costs(
769 &ModelCostV2 {
770 input_per_token: 1.0,
771 output_per_token: 2.0,
772 output_reasoning_per_token: 0.0,
773 input_cached_per_token: 0.5,
774 input_cache_write_per_token: 0.75,
775 },
776 tokens,
777 "test",
778 "test",
779 )
780 .unwrap();
781
782 insta::assert_debug_snapshot!(cost, @r"
786 CalculatedCost {
787 input: 90.0,
788 output: 100.0,
789 cache_read_input: 10.0,
790 cache_creation_input: 0.0,
791 reasoning_output: 0.0,
792 }
793 ");
794 }
795
796 #[test]
798 fn test_infer_ai_operation_type_from_gen_ai_operation_name() {
799 let mut span = ai_span_with_data(json!({
800 "gen_ai.operation.name": "invoke_agent"
801 }));
802
803 enrich_ai_span(&mut span, None);
804
805 assert_annotated_snapshot!(&span.data, @r#"
806 {
807 "gen_ai.operation.name": "invoke_agent",
808 "gen_ai.operation.type": "agent"
809 }
810 "#);
811 }
812
813 #[test]
815 fn test_infer_ai_operation_type_from_span_op() {
816 let mut span = Span {
817 op: "gen_ai.invoke_agent".to_owned().into(),
818 ..Default::default()
819 };
820
821 enrich_ai_span(&mut span, None);
822
823 assert_annotated_snapshot!(span.data, @r#"
824 {
825 "gen_ai.operation.type": "agent"
826 }
827 "#);
828 }
829
830 #[test]
832 fn test_infer_ai_operation_type_from_fallback() {
833 let mut span = ai_span_with_data(json!({
834 "gen_ai.operation.name": "embeddings"
835 }));
836
837 enrich_ai_span(&mut span, None);
838
839 assert_annotated_snapshot!(&span.data, @r#"
840 {
841 "gen_ai.operation.name": "embeddings",
842 "gen_ai.operation.type": "ai_client"
843 }
844 "#);
845 }
846
847 #[test]
849 fn test_default_response_model_from_request_model() {
850 let mut span = ai_span_with_data(json!({
851 "gen_ai.request.model": "gpt-4",
852 }));
853
854 enrich_ai_span(&mut span, None);
855
856 assert_annotated_snapshot!(&span.data, @r#"
857 {
858 "gen_ai.operation.type": "ai_client",
859 "gen_ai.request.model": "gpt-4",
860 "gen_ai.response.model": "gpt-4"
861 }
862 "#);
863 }
864
865 #[test]
867 fn test_default_response_model_not_overridden() {
868 let mut span = ai_span_with_data(json!({
869 "gen_ai.request.model": "gpt-4",
870 "gen_ai.response.model": "gpt-4-abcd",
871 }));
872
873 enrich_ai_span(&mut span, None);
874
875 assert_annotated_snapshot!(&span.data, @r#"
876 {
877 "gen_ai.operation.type": "ai_client",
878 "gen_ai.request.model": "gpt-4",
879 "gen_ai.response.model": "gpt-4-abcd"
880 }
881 "#);
882 }
883
884 #[test]
886 fn test_default_agent_name_from_function_id() {
887 let mut span = ai_span_with_data(json!({
888 "gen_ai.function_id": "my-agent",
889 }));
890
891 enrich_ai_span(&mut span, None);
892
893 assert_annotated_snapshot!(&span.data, @r#"
894 {
895 "gen_ai.agent.name": "my-agent",
896 "gen_ai.function_id": "my-agent",
897 "gen_ai.operation.type": "ai_client"
898 }
899 "#);
900 }
901
902 #[test]
904 fn test_default_agent_name_not_overridden() {
905 let mut span = ai_span_with_data(json!({
906 "gen_ai.function_id": "my-function",
907 "gen_ai.agent.name": "my-agent",
908 }));
909
910 enrich_ai_span(&mut span, None);
911
912 assert_annotated_snapshot!(&span.data, @r#"
913 {
914 "gen_ai.agent.name": "my-agent",
915 "gen_ai.function_id": "my-function",
916 "gen_ai.operation.type": "ai_client"
917 }
918 "#);
919 }
920
921 #[test]
923 fn test_is_ai_span_from_gen_ai_operation_name() {
924 let mut span_data = Annotated::default();
925 span_data
926 .get_or_insert_with(SpanData::default)
927 .other
928 .insert(
929 GEN_AI__OPERATION__NAME.to_owned(),
930 Annotated::new(Value::String("chat".into())),
931 );
932 assert!(is_ai_span(&span_data, None));
933 }
934
935 #[test]
937 fn test_is_ai_span_from_span_op_ai() {
938 let span_op: OperationType = "ai.chat".into();
939 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
940 }
941
942 #[test]
944 fn test_is_ai_span_from_span_op_gen_ai() {
945 let span_op: OperationType = "gen_ai.chat".into();
946 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
947 }
948
949 #[test]
951 fn test_is_ai_span_negative() {
952 assert!(!is_ai_span(&Annotated::default(), None));
953 }
954
955 #[test]
957 fn test_enrich_ai_event_data_invoke_agent_trace_with_chat_span() {
958 let event_json = r#"{
959 "type": "transaction",
960 "timestamp": 1234567892.0,
961 "start_timestamp": 1234567889.0,
962 "contexts": {
963 "trace": {
964 "op": "gen_ai.invoke_agent",
965 "trace_id": "12345678901234567890123456789012",
966 "span_id": "1234567890123456",
967 "data": {
968 "gen_ai.operation.name": "gen_ai.invoke_agent",
969 "gen_ai.usage.input_tokens": 500,
970 "gen_ai.usage.output_tokens": 200
971 }
972 }
973 },
974 "spans": [
975 {
976 "op": "gen_ai.chat.completions",
977 "span_id": "1234567890123457",
978 "start_timestamp": 1234567889.5,
979 "timestamp": 1234567890.5,
980 "data": {
981 "gen_ai.operation.name": "chat",
982 "gen_ai.usage.input_tokens": 100,
983 "gen_ai.usage.output_tokens": 50
984 }
985 }
986 ]
987 }"#;
988
989 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
990 let event = annotated_event.value_mut().as_mut().unwrap();
991
992 enrich_ai_event_data(event, None);
993
994 assert_annotated_snapshot!(&annotated_event, @r#"
995 {
996 "type": "transaction",
997 "timestamp": 1234567892.0,
998 "start_timestamp": 1234567889.0,
999 "contexts": {
1000 "trace": {
1001 "trace_id": "12345678901234567890123456789012",
1002 "span_id": "1234567890123456",
1003 "op": "gen_ai.invoke_agent",
1004 "data": {
1005 "gen_ai.operation.name": "gen_ai.invoke_agent",
1006 "gen_ai.operation.type": "agent",
1007 "gen_ai.usage.input_tokens": 500,
1008 "gen_ai.usage.output_tokens": 200,
1009 "gen_ai.usage.total_tokens": 700.0
1010 },
1011 "type": "trace"
1012 }
1013 },
1014 "spans": [
1015 {
1016 "timestamp": 1234567890.5,
1017 "start_timestamp": 1234567889.5,
1018 "op": "gen_ai.chat.completions",
1019 "span_id": "1234567890123457",
1020 "data": {
1021 "gen_ai.operation.name": "chat",
1022 "gen_ai.operation.type": "ai_client",
1023 "gen_ai.usage.input_tokens": 100,
1024 "gen_ai.usage.output_tokens": 50,
1025 "gen_ai.usage.total_tokens": 150.0
1026 }
1027 }
1028 ]
1029 }
1030 "#);
1031 }
1032
1033 #[test]
1035 fn test_enrich_ai_event_data_nested_agent_and_chat_spans() {
1036 let event_json = r#"{
1037 "type": "transaction",
1038 "timestamp": 1234567892.0,
1039 "start_timestamp": 1234567889.0,
1040 "contexts": {
1041 "trace": {
1042 "op": "http.server",
1043 "trace_id": "12345678901234567890123456789012",
1044 "span_id": "1234567890123456"
1045 }
1046 },
1047 "spans": [
1048 {
1049 "op": "gen_ai.invoke_agent",
1050 "span_id": "1234567890123457",
1051 "parent_span_id": "1234567890123456",
1052 "start_timestamp": 1234567889.5,
1053 "timestamp": 1234567891.5,
1054 "data": {
1055 "gen_ai.operation.name": "invoke_agent",
1056 "gen_ai.usage.input_tokens": 500,
1057 "gen_ai.usage.output_tokens": 200
1058 }
1059 },
1060 {
1061 "op": "gen_ai.chat.completions",
1062 "span_id": "1234567890123458",
1063 "parent_span_id": "1234567890123457",
1064 "start_timestamp": 1234567890.0,
1065 "timestamp": 1234567891.0,
1066 "data": {
1067 "gen_ai.operation.name": "chat",
1068 "gen_ai.usage.input_tokens": 100,
1069 "gen_ai.usage.output_tokens": 50
1070 }
1071 }
1072 ]
1073 }"#;
1074
1075 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1076 let event = annotated_event.value_mut().as_mut().unwrap();
1077
1078 enrich_ai_event_data(event, None);
1079
1080 assert_annotated_snapshot!(&annotated_event, @r#"
1081 {
1082 "type": "transaction",
1083 "timestamp": 1234567892.0,
1084 "start_timestamp": 1234567889.0,
1085 "contexts": {
1086 "trace": {
1087 "trace_id": "12345678901234567890123456789012",
1088 "span_id": "1234567890123456",
1089 "op": "http.server",
1090 "type": "trace"
1091 }
1092 },
1093 "spans": [
1094 {
1095 "timestamp": 1234567891.5,
1096 "start_timestamp": 1234567889.5,
1097 "op": "gen_ai.invoke_agent",
1098 "span_id": "1234567890123457",
1099 "parent_span_id": "1234567890123456",
1100 "data": {
1101 "gen_ai.operation.name": "invoke_agent",
1102 "gen_ai.operation.type": "agent",
1103 "gen_ai.usage.input_tokens": 500,
1104 "gen_ai.usage.output_tokens": 200,
1105 "gen_ai.usage.total_tokens": 700.0
1106 }
1107 },
1108 {
1109 "timestamp": 1234567891.0,
1110 "start_timestamp": 1234567890.0,
1111 "op": "gen_ai.chat.completions",
1112 "span_id": "1234567890123458",
1113 "parent_span_id": "1234567890123457",
1114 "data": {
1115 "gen_ai.operation.name": "chat",
1116 "gen_ai.operation.type": "ai_client",
1117 "gen_ai.usage.input_tokens": 100,
1118 "gen_ai.usage.output_tokens": 50,
1119 "gen_ai.usage.total_tokens": 150.0
1120 }
1121 }
1122 ]
1123 }
1124 "#);
1125 }
1126
1127 #[test]
1129 fn test_enrich_ai_event_data_legacy_measurements_and_span_op() {
1130 let event_json = r#"{
1131 "type": "transaction",
1132 "timestamp": 1234567892.0,
1133 "start_timestamp": 1234567889.0,
1134 "contexts": {
1135 "trace": {
1136 "op": "http.server",
1137 "trace_id": "12345678901234567890123456789012",
1138 "span_id": "1234567890123456"
1139 }
1140 },
1141 "spans": [
1142 {
1143 "op": "gen_ai.invoke_agent",
1144 "span_id": "1234567890123457",
1145 "parent_span_id": "1234567890123456",
1146 "start_timestamp": 1234567889.5,
1147 "timestamp": 1234567891.5,
1148 "measurements": {
1149 "ai_prompt_tokens_used": {"value": 500.0},
1150 "ai_completion_tokens_used": {"value": 200.0}
1151 }
1152 },
1153 {
1154 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1155 "span_id": "1234567890123458",
1156 "parent_span_id": "1234567890123457",
1157 "start_timestamp": 1234567890.0,
1158 "timestamp": 1234567891.0,
1159 "measurements": {
1160 "ai_prompt_tokens_used": {"value": 100.0},
1161 "ai_completion_tokens_used": {"value": 50.0}
1162 }
1163 }
1164 ]
1165 }"#;
1166
1167 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1168 let event = annotated_event.value_mut().as_mut().unwrap();
1169
1170 enrich_ai_event_data(event, None);
1171
1172 assert_annotated_snapshot!(&annotated_event, @r#"
1173 {
1174 "type": "transaction",
1175 "timestamp": 1234567892.0,
1176 "start_timestamp": 1234567889.0,
1177 "contexts": {
1178 "trace": {
1179 "trace_id": "12345678901234567890123456789012",
1180 "span_id": "1234567890123456",
1181 "op": "http.server",
1182 "type": "trace"
1183 }
1184 },
1185 "spans": [
1186 {
1187 "timestamp": 1234567891.5,
1188 "start_timestamp": 1234567889.5,
1189 "op": "gen_ai.invoke_agent",
1190 "span_id": "1234567890123457",
1191 "parent_span_id": "1234567890123456",
1192 "data": {
1193 "gen_ai.operation.type": "agent",
1194 "gen_ai.usage.input_tokens": 500.0,
1195 "gen_ai.usage.output_tokens": 200.0,
1196 "gen_ai.usage.total_tokens": 700.0
1197 },
1198 "measurements": {
1199 "ai_completion_tokens_used": {
1200 "value": 200.0
1201 },
1202 "ai_prompt_tokens_used": {
1203 "value": 500.0
1204 }
1205 }
1206 },
1207 {
1208 "timestamp": 1234567891.0,
1209 "start_timestamp": 1234567890.0,
1210 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1211 "span_id": "1234567890123458",
1212 "parent_span_id": "1234567890123457",
1213 "data": {
1214 "gen_ai.operation.type": "ai_client",
1215 "gen_ai.usage.input_tokens": 100.0,
1216 "gen_ai.usage.output_tokens": 50.0,
1217 "gen_ai.usage.total_tokens": 150.0
1218 },
1219 "measurements": {
1220 "ai_completion_tokens_used": {
1221 "value": 50.0
1222 },
1223 "ai_prompt_tokens_used": {
1224 "value": 100.0
1225 }
1226 }
1227 }
1228 ]
1229 }
1230 "#);
1231 }
1232
1233 fn metadata_with_context_size() -> ModelMetadata {
1234 ModelMetadata {
1235 version: 1,
1236 models: HashMap::from([(
1237 Pattern::new("claude-2.1").unwrap(),
1238 ModelMetadataEntry {
1239 costs: Some(ModelCostV2 {
1240 input_per_token: 0.01,
1241 output_per_token: 0.02,
1242 output_reasoning_per_token: 0.0,
1243 input_cached_per_token: 0.0,
1244 input_cache_write_per_token: 0.0,
1245 }),
1246 context_size: Some(100_000),
1247 },
1248 )]),
1249 }
1250 }
1251
1252 #[test]
1253 fn test_context_utilization_with_total_tokens() {
1254 let mut span = Span {
1255 op: "gen_ai.test".to_owned().into(),
1256 data: SpanData::from_value(
1257 json!({
1258 "gen_ai.response.model": "claude-2.1",
1259 "gen_ai.usage.input_tokens": 30000.0,
1260 "gen_ai.usage.output_tokens": 12000.0,
1261 "gen_ai.usage.total_tokens": 42000.0,
1262 })
1263 .into(),
1264 ),
1265 ..Default::default()
1266 };
1267
1268 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1269
1270 let data = span.data.value().unwrap();
1271 assert_eq!(
1272 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1273 .and_then(Value::as_f64),
1274 Some(100_000.0)
1275 );
1276 assert_eq!(
1277 data.get_value(GEN_AI__CONTEXT__UTILIZATION)
1278 .and_then(Value::as_f64),
1279 Some(0.42)
1280 );
1281 }
1282
1283 #[test]
1284 fn test_context_utilization_no_context_size() {
1285 let metadata = ModelMetadata {
1286 version: 1,
1287 models: HashMap::from([(
1288 Pattern::new("claude-2.1").unwrap(),
1289 ModelMetadataEntry {
1290 costs: None,
1291 context_size: None,
1292 },
1293 )]),
1294 };
1295
1296 let mut span = Span {
1297 op: "gen_ai.test".to_owned().into(),
1298 data: SpanData::from_value(
1299 json!({
1300 "gen_ai.response.model": "claude-2.1",
1301 "gen_ai.usage.total_tokens": 1000.0,
1302 })
1303 .into(),
1304 ),
1305 ..Default::default()
1306 };
1307
1308 enrich_ai_span(&mut span, Some(&metadata));
1309
1310 let data = span.data.value().unwrap();
1311 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1312 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1313 }
1314
1315 #[test]
1316 fn test_context_utilization_no_total_tokens() {
1317 let mut span = Span {
1318 op: "gen_ai.test".to_owned().into(),
1319 data: SpanData::from_value(
1320 json!({
1321 "gen_ai.response.model": "claude-2.1",
1322 })
1323 .into(),
1324 ),
1325 ..Default::default()
1326 };
1327
1328 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1329
1330 let data = span.data.value().unwrap();
1331 assert_eq!(
1333 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1334 .and_then(Value::as_f64),
1335 Some(100_000.0)
1336 );
1337 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1339 }
1340
1341 #[test]
1342 fn test_context_utilization_unknown_model() {
1343 let mut span = Span {
1344 op: "gen_ai.test".to_owned().into(),
1345 data: SpanData::from_value(
1346 json!({
1347 "gen_ai.response.model": "unknown-model",
1348 "gen_ai.usage.total_tokens": 1000.0,
1349 })
1350 .into(),
1351 ),
1352 ..Default::default()
1353 };
1354
1355 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1356
1357 let data = span.data.value().unwrap();
1358 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1359 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1360 }
1361}