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