1use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
4use crate::{ModelCostV2, ModelMetadata};
5use relay_conventions::attributes::*;
6use relay_event_schema::protocol::{
7 Event, Measurements, OperationType, Span, SpanData, TraceContext,
8};
9use relay_protocol::{Annotated, Getter, Value};
10
11#[derive(Debug, Copy, Clone)]
13pub struct UsedTokens {
14 pub input_tokens: f64,
16 pub input_cached_tokens: f64,
20 pub input_cache_write_tokens: f64,
24 pub output_tokens: f64,
26 pub output_reasoning_tokens: f64,
30}
31
32impl UsedTokens {
33 pub fn from_span_data(data: &SpanData) -> Self {
35 macro_rules! get_value {
36 ($e:expr) => {
37 data.get_value($e).and_then(|v| v.as_f64()).unwrap_or(0.0)
38 };
39 }
40
41 Self {
42 input_tokens: get_value!(GEN_AI__USAGE__INPUT_TOKENS),
43 output_tokens: get_value!(GEN_AI__USAGE__OUTPUT_TOKENS),
44 output_reasoning_tokens: get_value!(GEN_AI__USAGE__REASONING__OUTPUT_TOKENS),
45 input_cached_tokens: get_value!(GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS),
46 input_cache_write_tokens: get_value!(GEN_AI__USAGE__CACHE_CREATION__INPUT_TOKENS),
47 }
48 }
49
50 pub fn has_usage(&self) -> bool {
52 self.input_tokens > 0.0 || self.output_tokens > 0.0
53 }
54
55 pub fn raw_input_tokens(&self) -> f64 {
62 self.input_tokens - self.input_cached_tokens - self.input_cache_write_tokens
63 }
64
65 pub fn raw_output_tokens(&self) -> f64 {
69 self.output_tokens - self.output_reasoning_tokens
70 }
71}
72
73#[derive(Debug, Copy, Clone)]
75pub struct CalculatedCost {
76 pub input: f64,
78 pub output: f64,
80}
81
82impl CalculatedCost {
83 pub fn total(&self) -> f64 {
85 self.input + self.output
86 }
87}
88
89pub fn calculate_costs(
93 model_cost: &ModelCostV2,
94 tokens: UsedTokens,
95 integration: &str,
96 platform: &str,
97) -> Option<CalculatedCost> {
98 if !tokens.has_usage() {
99 relay_statsd::metric!(
100 counter(Counters::GenAiCostCalculationResult) += 1,
101 result = "calculation_no_tokens",
102 integration = integration,
103 platform = platform,
104 );
105 return None;
106 }
107
108 let input = (tokens.raw_input_tokens() * model_cost.input_per_token)
109 + (tokens.input_cached_tokens * model_cost.input_cached_per_token)
110 + (tokens.input_cache_write_tokens * model_cost.input_cache_write_per_token);
111
112 let reasoning_cost = match model_cost.output_reasoning_per_token {
115 reasoning_cost if reasoning_cost > 0.0 => reasoning_cost,
116 _ => model_cost.output_per_token,
117 };
118
119 let output = (tokens.raw_output_tokens() * model_cost.output_per_token)
120 + (tokens.output_reasoning_tokens * reasoning_cost);
121
122 let metric_label = match (input, output) {
123 (x, y) if x < 0.0 || y < 0.0 => "calculation_negative",
124 (0.0, 0.0) => "calculation_zero",
125 _ => "calculation_positive",
126 };
127
128 relay_statsd::metric!(
129 counter(Counters::GenAiCostCalculationResult) += 1,
130 result = metric_label,
131 integration = integration,
132 platform = platform,
133 );
134
135 Some(CalculatedCost { input, output })
136}
137
138pub const DEFAULT_AI_OPERATION: &str = "ai_client";
143
144pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
155 let ai_op = match op_name {
156 "ai.run.generateText"
158 | "ai.run.generateObject"
159 | "gen_ai.invoke_agent"
160 | "ai.pipeline.generate_text"
161 | "ai.pipeline.generate_object"
162 | "ai.pipeline.stream_text"
163 | "ai.pipeline.stream_object"
164 | "gen_ai.create_agent"
165 | "invoke_agent"
166 | "create_agent" => "agent",
167 "gen_ai.execute_tool" | "execute_tool" => "tool",
168 "gen_ai.handoff" | "handoff" => "handoff",
169 "ai.processor" | "processor_run" => "other",
170 op if op.starts_with("ai.streamText.doStream") => "ai_client",
172 op if op.starts_with("ai.streamText") => "agent",
173
174 op if op.starts_with("ai.generateText.doGenerate") => "ai_client",
175 op if op.starts_with("ai.generateText") => "agent",
176
177 op if op.starts_with("ai.generateObject.doGenerate") => "ai_client",
178 op if op.starts_with("ai.generateObject") => "agent",
179
180 op if op.starts_with("ai.toolCall") => "tool",
181 _ => return None,
183 };
184
185 Some(ai_op)
186}
187
188fn extract_ai_model_cost_data(
191 model_cost: Option<&ModelCostV2>,
192 data: &mut SpanData,
193 origin: Option<&str>,
194 platform: Option<&str>,
195) {
196 let integration = map_origin_to_integration(origin);
197 let platform = platform_tag(platform);
198
199 let Some(model_cost) = model_cost else {
200 relay_statsd::metric!(
201 counter(Counters::GenAiCostCalculationResult) += 1,
202 result = "calculation_no_model_cost_available",
203 integration = integration,
204 platform = platform,
205 );
206 return;
207 };
208
209 let used_tokens = UsedTokens::from_span_data(&*data);
210 let Some(costs) = calculate_costs(model_cost, used_tokens, integration, platform) else {
211 return;
212 };
213
214 data.other
215 .entry(GEN_AI__COST__TOTAL_TOKENS.to_owned())
216 .or_default()
217 .set_value(Value::F64(costs.total()).into());
218
219 data.other
221 .entry(GEN_AI__COST__INPUT_TOKENS.to_owned())
222 .or_default()
223 .set_value(Value::F64(costs.input).into());
224 data.other
225 .entry(GEN_AI__COST__OUTPUT_TOKENS.to_owned())
226 .or_default()
227 .set_value(Value::F64(costs.output).into());
228}
229
230fn map_ai_measurements_to_data(data: &mut SpanData, measurements: Option<&Measurements>) {
232 let set_field_from_measurement = |target_field: &mut Annotated<Value>,
233 measurement_key: &str| {
234 if let Some(measurements) = measurements
235 && target_field.value().is_none()
236 && let Some(value) = measurements.get_value(measurement_key)
237 {
238 target_field.set_value(Value::F64(value.to_f64()).into());
239 }
240 };
241
242 set_field_from_measurement(
243 data.other
244 .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
245 .or_default(),
246 "ai_total_tokens_used",
247 );
248 set_field_from_measurement(
249 data.other
250 .entry(GEN_AI__USAGE__INPUT_TOKENS.to_owned())
251 .or_default(),
252 "ai_prompt_tokens_used",
253 );
254 set_field_from_measurement(
255 data.other
256 .entry(GEN_AI__USAGE__OUTPUT_TOKENS.to_owned())
257 .or_default(),
258 "ai_completion_tokens_used",
259 );
260}
261
262fn set_total_tokens(data: &mut SpanData) {
263 if data.get_value(GEN_AI__USAGE__TOTAL_TOKENS).is_none() {
265 let input_tokens = data
266 .get_value(GEN_AI__USAGE__INPUT_TOKENS)
267 .and_then(Value::as_f64);
268 let output_tokens = data
269 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
270 .and_then(Value::as_f64);
271
272 if input_tokens.is_none() && output_tokens.is_none() {
273 return;
275 }
276
277 data.other
278 .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
279 .or_default()
280 .set_value(
281 Value::F64(input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0)).into(),
282 );
283 }
284}
285
286fn extract_context_utilization(data: &mut SpanData, model_metadata: &ModelMetadata) {
288 let model_id = data.get_str(GEN_AI__RESPONSE__MODEL);
289
290 let context_size = model_id.and_then(|id| model_metadata.context_size(id));
291
292 let Some(context_size) = context_size else {
293 return;
294 };
295
296 data.other
297 .entry(GEN_AI__CONTEXT__WINDOW_SIZE.to_owned())
298 .or_default()
299 .set_value(Value::U64(context_size).into());
300
301 let total_tokens = data
302 .get_value(GEN_AI__USAGE__TOTAL_TOKENS)
303 .and_then(Value::as_f64);
304
305 if let Some(total_tokens) = total_tokens {
306 data.other
307 .entry(GEN_AI__CONTEXT__UTILIZATION.to_owned())
308 .or_default()
309 .set_value(Value::F64(total_tokens / context_size as f64).into());
310 }
311}
312
313fn extract_ai_data(
315 data: &mut SpanData,
316 duration: f64,
317 model_metadata: &ModelMetadata,
318 origin: Option<&str>,
319 platform: Option<&str>,
320) {
321 if data
323 .get_value(GEN_AI__RESPONSE__TOKENS_PER_SECOND)
324 .is_none()
325 && duration > 0.0
326 && let Some(output_tokens) = data
327 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
328 .and_then(Value::as_f64)
329 {
330 data.other
331 .entry(GEN_AI__RESPONSE__TOKENS_PER_SECOND.to_owned())
332 .or_default()
333 .set_value(Value::F64(output_tokens / (duration / 1000.0)).into());
334 }
335
336 extract_context_utilization(data, model_metadata);
337
338 if let Some(model_id) = data.get_str(GEN_AI__RESPONSE__MODEL) {
340 extract_ai_model_cost_data(
341 model_metadata.cost_per_token(model_id),
342 data,
343 origin,
344 platform,
345 )
346 } else {
347 relay_statsd::metric!(
348 counter(Counters::GenAiCostCalculationResult) += 1,
349 result = "calculation_no_model_id_available",
350 integration = map_origin_to_integration(origin),
351 platform = platform_tag(platform),
352 );
353 }
354}
355
356fn enrich_ai_span_data(
358 span_data: &mut Annotated<SpanData>,
359 span_op: &Annotated<OperationType>,
360 measurements: &Annotated<Measurements>,
361 duration: f64,
362 model_metadata: Option<&ModelMetadata>,
363 origin: Option<&str>,
364 platform: Option<&str>,
365) {
366 if !is_ai_span(span_data, span_op.value()) {
367 return;
368 }
369
370 let data = span_data.get_or_insert_with(SpanData::default);
371
372 map_ai_measurements_to_data(data, measurements.value());
373
374 set_total_tokens(data);
375
376 if data.get_value(GEN_AI__RESPONSE__MODEL).is_none()
378 && let Some(request_model) = data.get_value(GEN_AI__REQUEST__MODEL).cloned()
379 {
380 data.other
381 .entry(GEN_AI__RESPONSE__MODEL.to_owned())
382 .or_default()
383 .set_value(Some(request_model));
384 }
385
386 if data.get_value(GEN_AI__AGENT__NAME).is_none()
388 && let Some(function_id) = data.get_value(GEN_AI__FUNCTION_ID).cloned()
389 {
390 data.other
391 .entry(GEN_AI__AGENT__NAME.to_owned())
392 .or_default()
393 .set_value(Some(function_id));
394 }
395
396 if let Some(model_metadata) = model_metadata {
397 extract_ai_data(data, duration, model_metadata, origin, platform);
398 } else {
399 relay_statsd::metric!(
400 counter(Counters::GenAiCostCalculationResult) += 1,
401 result = "calculation_no_model_cost_available",
402 integration = map_origin_to_integration(origin),
403 platform = platform_tag(platform),
404 );
405 }
406
407 let ai_op_type = data
408 .get_str(GEN_AI__OPERATION__NAME)
409 .or(span_op.value().map(String::as_str))
410 .and_then(infer_ai_operation_type)
411 .unwrap_or(DEFAULT_AI_OPERATION);
412
413 data.other
414 .entry(GEN_AI__OPERATION__TYPE.to_owned())
415 .or_default()
416 .set_value(Some(Value::String(ai_op_type.to_owned())));
417}
418
419pub fn enrich_ai_span(span: &mut Span, model_metadata: Option<&ModelMetadata>) {
421 let duration = span
422 .get_value("span.duration")
423 .and_then(|v| v.as_f64())
424 .unwrap_or(0.0);
425
426 enrich_ai_span_data(
427 &mut span.data,
428 &span.op,
429 &span.measurements,
430 duration,
431 model_metadata,
432 span.origin.as_str(),
433 span.platform.as_str(),
434 );
435}
436
437pub fn enrich_ai_event_data(event: &mut Event, model_metadata: Option<&ModelMetadata>) {
439 let event_duration = event
440 .get_value("event.duration")
441 .and_then(|v| v.as_f64())
442 .unwrap_or(0.0);
443
444 if let Some(trace_context) = event
445 .contexts
446 .value_mut()
447 .as_mut()
448 .and_then(|c| c.get_mut::<TraceContext>())
449 {
450 enrich_ai_span_data(
451 &mut trace_context.data,
452 &trace_context.op,
453 &event.measurements,
454 event_duration,
455 model_metadata,
456 trace_context.origin.as_str(),
457 event.platform.as_str(),
458 );
459 }
460 let spans = event.spans.value_mut().iter_mut().flatten();
461 let spans = spans.filter_map(|span| span.value_mut().as_mut());
462
463 for span in spans {
464 let span_duration = span
465 .get_value("span.duration")
466 .and_then(|v| v.as_f64())
467 .unwrap_or(0.0);
468 let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
469
470 enrich_ai_span_data(
471 &mut span.data,
472 &span.op,
473 &span.measurements,
474 span_duration,
475 model_metadata,
476 span.origin.as_str(),
477 span_platform,
478 );
479 }
480}
481
482fn is_ai_span(span_data: &Annotated<SpanData>, span_op: Option<&OperationType>) -> bool {
486 let has_ai_op = span_data
487 .value()
488 .and_then(|data| data.get_value(GEN_AI__OPERATION__NAME))
489 .is_some();
490
491 let is_ai_span_op =
492 span_op.is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."));
493
494 has_ai_op || is_ai_span_op
495}
496
497#[cfg(test)]
498mod tests {
499 use std::collections::HashMap;
500
501 use relay_pattern::Pattern;
502 use relay_protocol::{FromValue, assert_annotated_snapshot};
503 use serde_json::json;
504
505 use super::*;
506 use crate::ModelMetadataEntry;
507
508 fn ai_span_with_data(data: serde_json::Value) -> Span {
509 Span {
510 op: "gen_ai.test".to_owned().into(),
511 data: SpanData::from_value(data.into()),
512 ..Default::default()
513 }
514 }
515
516 #[test]
517 fn test_calculate_cost_no_tokens() {
518 let cost = calculate_costs(
519 &ModelCostV2 {
520 input_per_token: 1.0,
521 output_per_token: 1.0,
522 output_reasoning_per_token: 1.0,
523 input_cached_per_token: 1.0,
524 input_cache_write_per_token: 1.0,
525 },
526 UsedTokens::from_span_data(&SpanData::default()),
527 "test",
528 "test",
529 );
530 assert!(cost.is_none());
531 }
532
533 #[test]
534 fn test_calculate_cost_full() {
535 let cost = calculate_costs(
536 &ModelCostV2 {
537 input_per_token: 1.0,
538 output_per_token: 2.0,
539 output_reasoning_per_token: 3.0,
540 input_cached_per_token: 0.5,
541 input_cache_write_per_token: 0.75,
542 },
543 UsedTokens {
544 input_tokens: 8.0,
545 input_cached_tokens: 5.0,
546 input_cache_write_tokens: 0.0,
547 output_tokens: 15.0,
548 output_reasoning_tokens: 9.0,
549 },
550 "test",
551 "test",
552 )
553 .unwrap();
554
555 insta::assert_debug_snapshot!(cost, @r"
556 CalculatedCost {
557 input: 5.5,
558 output: 39.0,
559 }
560 ");
561 }
562
563 #[test]
564 fn test_calculate_cost_no_reasoning_cost() {
565 let cost = calculate_costs(
566 &ModelCostV2 {
567 input_per_token: 1.0,
568 output_per_token: 2.0,
569 output_reasoning_per_token: 0.0,
571 input_cached_per_token: 0.5,
572 input_cache_write_per_token: 0.0,
573 },
574 UsedTokens {
575 input_tokens: 8.0,
576 input_cached_tokens: 5.0,
577 input_cache_write_tokens: 0.0,
578 output_tokens: 15.0,
579 output_reasoning_tokens: 9.0,
580 },
581 "test",
582 "test",
583 )
584 .unwrap();
585
586 insta::assert_debug_snapshot!(cost, @r"
587 CalculatedCost {
588 input: 5.5,
589 output: 30.0,
590 }
591 ");
592 }
593
594 #[test]
598 fn test_calculate_cost_negative() {
599 let cost = calculate_costs(
600 &ModelCostV2 {
601 input_per_token: 2.0,
602 output_per_token: 2.0,
603 output_reasoning_per_token: 1.0,
604 input_cached_per_token: 1.0,
605 input_cache_write_per_token: 1.5,
606 },
607 UsedTokens {
608 input_tokens: 1.0,
609 input_cached_tokens: 11.0,
610 input_cache_write_tokens: 0.0,
611 output_tokens: 1.0,
612 output_reasoning_tokens: 9.0,
613 },
614 "test",
615 "test",
616 )
617 .unwrap();
618
619 insta::assert_debug_snapshot!(cost, @r"
620 CalculatedCost {
621 input: -9.0,
622 output: -7.0,
623 }
624 ");
625 }
626
627 #[test]
628 fn test_calculate_cost_with_cache_writes() {
629 let cost = calculate_costs(
630 &ModelCostV2 {
631 input_per_token: 1.0,
632 output_per_token: 2.0,
633 output_reasoning_per_token: 3.0,
634 input_cached_per_token: 0.5,
635 input_cache_write_per_token: 0.75,
636 },
637 UsedTokens {
638 input_tokens: 100.0,
639 input_cached_tokens: 20.0,
640 input_cache_write_tokens: 30.0,
641 output_tokens: 50.0,
642 output_reasoning_tokens: 10.0,
643 },
644 "test",
645 "test",
646 )
647 .unwrap();
648
649 insta::assert_debug_snapshot!(cost, @r"
653 CalculatedCost {
654 input: 82.5,
655 output: 110.0,
656 }
657 ");
658 }
659
660 #[test]
661 fn test_calculate_cost_backward_compatibility_no_cache_write() {
662 let span_data = SpanData::from([
664 (
665 GEN_AI__USAGE__INPUT_TOKENS.to_owned(),
666 Annotated::new(100.0.into()),
667 ),
668 (
669 GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS.to_owned(),
670 Annotated::new(20.0.into()),
671 ),
672 (
673 GEN_AI__USAGE__OUTPUT_TOKENS.to_owned(),
674 Annotated::new(50.0.into()),
675 ),
676 ]);
677
678 let tokens = UsedTokens::from_span_data(&span_data);
679
680 assert_eq!(tokens.input_cache_write_tokens, 0.0);
682
683 let cost = calculate_costs(
684 &ModelCostV2 {
685 input_per_token: 1.0,
686 output_per_token: 2.0,
687 output_reasoning_per_token: 0.0,
688 input_cached_per_token: 0.5,
689 input_cache_write_per_token: 0.75,
690 },
691 tokens,
692 "test",
693 "test",
694 )
695 .unwrap();
696
697 insta::assert_debug_snapshot!(cost, @r"
701 CalculatedCost {
702 input: 90.0,
703 output: 100.0,
704 }
705 ");
706 }
707
708 #[test]
710 fn test_infer_ai_operation_type_from_gen_ai_operation_name() {
711 let mut span = ai_span_with_data(json!({
712 "gen_ai.operation.name": "invoke_agent"
713 }));
714
715 enrich_ai_span(&mut span, None);
716
717 assert_annotated_snapshot!(&span.data, @r#"
718 {
719 "gen_ai.operation.name": "invoke_agent",
720 "gen_ai.operation.type": "agent"
721 }
722 "#);
723 }
724
725 #[test]
727 fn test_infer_ai_operation_type_from_span_op() {
728 let mut span = Span {
729 op: "gen_ai.invoke_agent".to_owned().into(),
730 ..Default::default()
731 };
732
733 enrich_ai_span(&mut span, None);
734
735 assert_annotated_snapshot!(span.data, @r#"
736 {
737 "gen_ai.operation.type": "agent"
738 }
739 "#);
740 }
741
742 #[test]
744 fn test_infer_ai_operation_type_from_fallback() {
745 let mut span = ai_span_with_data(json!({
746 "gen_ai.operation.name": "embeddings"
747 }));
748
749 enrich_ai_span(&mut span, None);
750
751 assert_annotated_snapshot!(&span.data, @r#"
752 {
753 "gen_ai.operation.name": "embeddings",
754 "gen_ai.operation.type": "ai_client"
755 }
756 "#);
757 }
758
759 #[test]
761 fn test_default_response_model_from_request_model() {
762 let mut span = ai_span_with_data(json!({
763 "gen_ai.request.model": "gpt-4",
764 }));
765
766 enrich_ai_span(&mut span, None);
767
768 assert_annotated_snapshot!(&span.data, @r#"
769 {
770 "gen_ai.operation.type": "ai_client",
771 "gen_ai.request.model": "gpt-4",
772 "gen_ai.response.model": "gpt-4"
773 }
774 "#);
775 }
776
777 #[test]
779 fn test_default_response_model_not_overridden() {
780 let mut span = ai_span_with_data(json!({
781 "gen_ai.request.model": "gpt-4",
782 "gen_ai.response.model": "gpt-4-abcd",
783 }));
784
785 enrich_ai_span(&mut span, None);
786
787 assert_annotated_snapshot!(&span.data, @r#"
788 {
789 "gen_ai.operation.type": "ai_client",
790 "gen_ai.request.model": "gpt-4",
791 "gen_ai.response.model": "gpt-4-abcd"
792 }
793 "#);
794 }
795
796 #[test]
798 fn test_default_agent_name_from_function_id() {
799 let mut span = ai_span_with_data(json!({
800 "gen_ai.function_id": "my-agent",
801 }));
802
803 enrich_ai_span(&mut span, None);
804
805 assert_annotated_snapshot!(&span.data, @r#"
806 {
807 "gen_ai.agent.name": "my-agent",
808 "gen_ai.function_id": "my-agent",
809 "gen_ai.operation.type": "ai_client"
810 }
811 "#);
812 }
813
814 #[test]
816 fn test_default_agent_name_not_overridden() {
817 let mut span = ai_span_with_data(json!({
818 "gen_ai.function_id": "my-function",
819 "gen_ai.agent.name": "my-agent",
820 }));
821
822 enrich_ai_span(&mut span, None);
823
824 assert_annotated_snapshot!(&span.data, @r#"
825 {
826 "gen_ai.agent.name": "my-agent",
827 "gen_ai.function_id": "my-function",
828 "gen_ai.operation.type": "ai_client"
829 }
830 "#);
831 }
832
833 #[test]
835 fn test_is_ai_span_from_gen_ai_operation_name() {
836 let mut span_data = Annotated::default();
837 span_data
838 .get_or_insert_with(SpanData::default)
839 .other
840 .insert(
841 GEN_AI__OPERATION__NAME.to_owned(),
842 Annotated::new(Value::String("chat".into())),
843 );
844 assert!(is_ai_span(&span_data, None));
845 }
846
847 #[test]
849 fn test_is_ai_span_from_span_op_ai() {
850 let span_op: OperationType = "ai.chat".into();
851 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
852 }
853
854 #[test]
856 fn test_is_ai_span_from_span_op_gen_ai() {
857 let span_op: OperationType = "gen_ai.chat".into();
858 assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
859 }
860
861 #[test]
863 fn test_is_ai_span_negative() {
864 assert!(!is_ai_span(&Annotated::default(), None));
865 }
866
867 #[test]
869 fn test_enrich_ai_event_data_invoke_agent_trace_with_chat_span() {
870 let event_json = r#"{
871 "type": "transaction",
872 "timestamp": 1234567892.0,
873 "start_timestamp": 1234567889.0,
874 "contexts": {
875 "trace": {
876 "op": "gen_ai.invoke_agent",
877 "trace_id": "12345678901234567890123456789012",
878 "span_id": "1234567890123456",
879 "data": {
880 "gen_ai.operation.name": "gen_ai.invoke_agent",
881 "gen_ai.usage.input_tokens": 500,
882 "gen_ai.usage.output_tokens": 200
883 }
884 }
885 },
886 "spans": [
887 {
888 "op": "gen_ai.chat.completions",
889 "span_id": "1234567890123457",
890 "start_timestamp": 1234567889.5,
891 "timestamp": 1234567890.5,
892 "data": {
893 "gen_ai.operation.name": "chat",
894 "gen_ai.usage.input_tokens": 100,
895 "gen_ai.usage.output_tokens": 50
896 }
897 }
898 ]
899 }"#;
900
901 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
902 let event = annotated_event.value_mut().as_mut().unwrap();
903
904 enrich_ai_event_data(event, None);
905
906 assert_annotated_snapshot!(&annotated_event, @r#"
907 {
908 "type": "transaction",
909 "timestamp": 1234567892.0,
910 "start_timestamp": 1234567889.0,
911 "contexts": {
912 "trace": {
913 "trace_id": "12345678901234567890123456789012",
914 "span_id": "1234567890123456",
915 "op": "gen_ai.invoke_agent",
916 "data": {
917 "gen_ai.operation.name": "gen_ai.invoke_agent",
918 "gen_ai.operation.type": "agent",
919 "gen_ai.usage.input_tokens": 500,
920 "gen_ai.usage.output_tokens": 200,
921 "gen_ai.usage.total_tokens": 700.0
922 },
923 "type": "trace"
924 }
925 },
926 "spans": [
927 {
928 "timestamp": 1234567890.5,
929 "start_timestamp": 1234567889.5,
930 "op": "gen_ai.chat.completions",
931 "span_id": "1234567890123457",
932 "data": {
933 "gen_ai.operation.name": "chat",
934 "gen_ai.operation.type": "ai_client",
935 "gen_ai.usage.input_tokens": 100,
936 "gen_ai.usage.output_tokens": 50,
937 "gen_ai.usage.total_tokens": 150.0
938 }
939 }
940 ]
941 }
942 "#);
943 }
944
945 #[test]
947 fn test_enrich_ai_event_data_nested_agent_and_chat_spans() {
948 let event_json = r#"{
949 "type": "transaction",
950 "timestamp": 1234567892.0,
951 "start_timestamp": 1234567889.0,
952 "contexts": {
953 "trace": {
954 "op": "http.server",
955 "trace_id": "12345678901234567890123456789012",
956 "span_id": "1234567890123456"
957 }
958 },
959 "spans": [
960 {
961 "op": "gen_ai.invoke_agent",
962 "span_id": "1234567890123457",
963 "parent_span_id": "1234567890123456",
964 "start_timestamp": 1234567889.5,
965 "timestamp": 1234567891.5,
966 "data": {
967 "gen_ai.operation.name": "invoke_agent",
968 "gen_ai.usage.input_tokens": 500,
969 "gen_ai.usage.output_tokens": 200
970 }
971 },
972 {
973 "op": "gen_ai.chat.completions",
974 "span_id": "1234567890123458",
975 "parent_span_id": "1234567890123457",
976 "start_timestamp": 1234567890.0,
977 "timestamp": 1234567891.0,
978 "data": {
979 "gen_ai.operation.name": "chat",
980 "gen_ai.usage.input_tokens": 100,
981 "gen_ai.usage.output_tokens": 50
982 }
983 }
984 ]
985 }"#;
986
987 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
988 let event = annotated_event.value_mut().as_mut().unwrap();
989
990 enrich_ai_event_data(event, None);
991
992 assert_annotated_snapshot!(&annotated_event, @r#"
993 {
994 "type": "transaction",
995 "timestamp": 1234567892.0,
996 "start_timestamp": 1234567889.0,
997 "contexts": {
998 "trace": {
999 "trace_id": "12345678901234567890123456789012",
1000 "span_id": "1234567890123456",
1001 "op": "http.server",
1002 "type": "trace"
1003 }
1004 },
1005 "spans": [
1006 {
1007 "timestamp": 1234567891.5,
1008 "start_timestamp": 1234567889.5,
1009 "op": "gen_ai.invoke_agent",
1010 "span_id": "1234567890123457",
1011 "parent_span_id": "1234567890123456",
1012 "data": {
1013 "gen_ai.operation.name": "invoke_agent",
1014 "gen_ai.operation.type": "agent",
1015 "gen_ai.usage.input_tokens": 500,
1016 "gen_ai.usage.output_tokens": 200,
1017 "gen_ai.usage.total_tokens": 700.0
1018 }
1019 },
1020 {
1021 "timestamp": 1234567891.0,
1022 "start_timestamp": 1234567890.0,
1023 "op": "gen_ai.chat.completions",
1024 "span_id": "1234567890123458",
1025 "parent_span_id": "1234567890123457",
1026 "data": {
1027 "gen_ai.operation.name": "chat",
1028 "gen_ai.operation.type": "ai_client",
1029 "gen_ai.usage.input_tokens": 100,
1030 "gen_ai.usage.output_tokens": 50,
1031 "gen_ai.usage.total_tokens": 150.0
1032 }
1033 }
1034 ]
1035 }
1036 "#);
1037 }
1038
1039 #[test]
1041 fn test_enrich_ai_event_data_legacy_measurements_and_span_op() {
1042 let event_json = r#"{
1043 "type": "transaction",
1044 "timestamp": 1234567892.0,
1045 "start_timestamp": 1234567889.0,
1046 "contexts": {
1047 "trace": {
1048 "op": "http.server",
1049 "trace_id": "12345678901234567890123456789012",
1050 "span_id": "1234567890123456"
1051 }
1052 },
1053 "spans": [
1054 {
1055 "op": "gen_ai.invoke_agent",
1056 "span_id": "1234567890123457",
1057 "parent_span_id": "1234567890123456",
1058 "start_timestamp": 1234567889.5,
1059 "timestamp": 1234567891.5,
1060 "measurements": {
1061 "ai_prompt_tokens_used": {"value": 500.0},
1062 "ai_completion_tokens_used": {"value": 200.0}
1063 }
1064 },
1065 {
1066 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1067 "span_id": "1234567890123458",
1068 "parent_span_id": "1234567890123457",
1069 "start_timestamp": 1234567890.0,
1070 "timestamp": 1234567891.0,
1071 "measurements": {
1072 "ai_prompt_tokens_used": {"value": 100.0},
1073 "ai_completion_tokens_used": {"value": 50.0}
1074 }
1075 }
1076 ]
1077 }"#;
1078
1079 let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1080 let event = annotated_event.value_mut().as_mut().unwrap();
1081
1082 enrich_ai_event_data(event, None);
1083
1084 assert_annotated_snapshot!(&annotated_event, @r#"
1085 {
1086 "type": "transaction",
1087 "timestamp": 1234567892.0,
1088 "start_timestamp": 1234567889.0,
1089 "contexts": {
1090 "trace": {
1091 "trace_id": "12345678901234567890123456789012",
1092 "span_id": "1234567890123456",
1093 "op": "http.server",
1094 "type": "trace"
1095 }
1096 },
1097 "spans": [
1098 {
1099 "timestamp": 1234567891.5,
1100 "start_timestamp": 1234567889.5,
1101 "op": "gen_ai.invoke_agent",
1102 "span_id": "1234567890123457",
1103 "parent_span_id": "1234567890123456",
1104 "data": {
1105 "gen_ai.operation.type": "agent",
1106 "gen_ai.usage.input_tokens": 500.0,
1107 "gen_ai.usage.output_tokens": 200.0,
1108 "gen_ai.usage.total_tokens": 700.0
1109 },
1110 "measurements": {
1111 "ai_completion_tokens_used": {
1112 "value": 200.0
1113 },
1114 "ai_prompt_tokens_used": {
1115 "value": 500.0
1116 }
1117 }
1118 },
1119 {
1120 "timestamp": 1234567891.0,
1121 "start_timestamp": 1234567890.0,
1122 "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1123 "span_id": "1234567890123458",
1124 "parent_span_id": "1234567890123457",
1125 "data": {
1126 "gen_ai.operation.type": "ai_client",
1127 "gen_ai.usage.input_tokens": 100.0,
1128 "gen_ai.usage.output_tokens": 50.0,
1129 "gen_ai.usage.total_tokens": 150.0
1130 },
1131 "measurements": {
1132 "ai_completion_tokens_used": {
1133 "value": 50.0
1134 },
1135 "ai_prompt_tokens_used": {
1136 "value": 100.0
1137 }
1138 }
1139 }
1140 ]
1141 }
1142 "#);
1143 }
1144
1145 fn metadata_with_context_size() -> ModelMetadata {
1146 ModelMetadata {
1147 version: 1,
1148 models: HashMap::from([(
1149 Pattern::new("claude-2.1").unwrap(),
1150 ModelMetadataEntry {
1151 costs: Some(ModelCostV2 {
1152 input_per_token: 0.01,
1153 output_per_token: 0.02,
1154 output_reasoning_per_token: 0.0,
1155 input_cached_per_token: 0.0,
1156 input_cache_write_per_token: 0.0,
1157 }),
1158 context_size: Some(100_000),
1159 },
1160 )]),
1161 }
1162 }
1163
1164 #[test]
1165 fn test_context_utilization_with_total_tokens() {
1166 let mut span = Span {
1167 op: "gen_ai.test".to_owned().into(),
1168 data: SpanData::from_value(
1169 json!({
1170 "gen_ai.response.model": "claude-2.1",
1171 "gen_ai.usage.input_tokens": 30000.0,
1172 "gen_ai.usage.output_tokens": 12000.0,
1173 "gen_ai.usage.total_tokens": 42000.0,
1174 })
1175 .into(),
1176 ),
1177 ..Default::default()
1178 };
1179
1180 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1181
1182 let data = span.data.value().unwrap();
1183 assert_eq!(
1184 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1185 .and_then(Value::as_f64),
1186 Some(100_000.0)
1187 );
1188 assert_eq!(
1189 data.get_value(GEN_AI__CONTEXT__UTILIZATION)
1190 .and_then(Value::as_f64),
1191 Some(0.42)
1192 );
1193 }
1194
1195 #[test]
1196 fn test_context_utilization_no_context_size() {
1197 let metadata = ModelMetadata {
1198 version: 1,
1199 models: HashMap::from([(
1200 Pattern::new("claude-2.1").unwrap(),
1201 ModelMetadataEntry {
1202 costs: None,
1203 context_size: None,
1204 },
1205 )]),
1206 };
1207
1208 let mut span = Span {
1209 op: "gen_ai.test".to_owned().into(),
1210 data: SpanData::from_value(
1211 json!({
1212 "gen_ai.response.model": "claude-2.1",
1213 "gen_ai.usage.total_tokens": 1000.0,
1214 })
1215 .into(),
1216 ),
1217 ..Default::default()
1218 };
1219
1220 enrich_ai_span(&mut span, Some(&metadata));
1221
1222 let data = span.data.value().unwrap();
1223 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1224 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1225 }
1226
1227 #[test]
1228 fn test_context_utilization_no_total_tokens() {
1229 let mut span = Span {
1230 op: "gen_ai.test".to_owned().into(),
1231 data: SpanData::from_value(
1232 json!({
1233 "gen_ai.response.model": "claude-2.1",
1234 })
1235 .into(),
1236 ),
1237 ..Default::default()
1238 };
1239
1240 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1241
1242 let data = span.data.value().unwrap();
1243 assert_eq!(
1245 data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1246 .and_then(Value::as_f64),
1247 Some(100_000.0)
1248 );
1249 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1251 }
1252
1253 #[test]
1254 fn test_context_utilization_unknown_model() {
1255 let mut span = Span {
1256 op: "gen_ai.test".to_owned().into(),
1257 data: SpanData::from_value(
1258 json!({
1259 "gen_ai.response.model": "unknown-model",
1260 "gen_ai.usage.total_tokens": 1000.0,
1261 })
1262 .into(),
1263 ),
1264 ..Default::default()
1265 };
1266
1267 enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1268
1269 let data = span.data.value().unwrap();
1270 assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1271 assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1272 }
1273}