Skip to main content

relay_event_normalization/normalize/span/
ai.rs

1//! AI cost calculation.
2
3use 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/// Amount of used tokens for a model call.
12#[derive(Debug, Copy, Clone)]
13pub struct UsedTokens {
14    /// Total amount of input tokens used.
15    pub input_tokens: f64,
16    /// Amount of cached tokens used.
17    ///
18    /// This is a subset of [`Self::input_tokens`].
19    pub input_cached_tokens: f64,
20    /// Amount of cache write tokens used.
21    ///
22    /// This is a subset of [`Self::input_tokens`].
23    pub input_cache_write_tokens: f64,
24    /// Total amount of output tokens.
25    pub output_tokens: f64,
26    /// Total amount of reasoning tokens.
27    ///
28    /// This is a subset of [`Self::output_tokens`].
29    pub output_reasoning_tokens: f64,
30}
31
32impl UsedTokens {
33    /// Extracts [`UsedTokens`] from [`SpanData`] attributes.
34    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    /// Returns `true` if any tokens were used.
51    pub fn has_usage(&self) -> bool {
52        self.input_tokens > 0.0 || self.output_tokens > 0.0
53    }
54
55    /// Calculates the total amount of input tokens billed at the standard rate.
56    ///
57    /// Both [`Self::input_cached_tokens`] and [`Self::input_cache_write_tokens`] are
58    /// subsets of [`Self::input_tokens`] and are billed separately at their own
59    /// (cached / cache-write) rates, so both are subtracted here to avoid charging
60    /// them twice.
61    pub fn raw_input_tokens(&self) -> f64 {
62        self.input_tokens - self.input_cached_tokens - self.input_cache_write_tokens
63    }
64
65    /// Calculates the total amount of raw, non-reasoning output tokens.
66    ///
67    /// Subtracts reasoning tokens from the total token count.
68    pub fn raw_output_tokens(&self) -> f64 {
69        self.output_tokens - self.output_reasoning_tokens
70    }
71}
72
73/// Calculated model call costs.
74#[derive(Debug, Copy, Clone)]
75pub struct CalculatedCost {
76    /// The cost of input tokens used.
77    pub input: f64,
78    /// The cost of output tokens used.
79    pub output: f64,
80}
81
82impl CalculatedCost {
83    /// The total, input and output, cost.
84    pub fn total(&self) -> f64 {
85        self.input + self.output
86    }
87}
88
89/// Calculates the total cost for a model call.
90///
91/// Returns `None` if no tokens were used.
92pub 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    // For now most of the models do not differentiate between reasoning and output token cost,
113    // it costs the same.
114    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
138/// Default AI operation stored in [`GEN_AI__OPERATION__TYPE`]
139/// for AI spans without a well known AI span op.
140///
141/// See also: [`infer_ai_operation_type`].
142pub const DEFAULT_AI_OPERATION: &str = "ai_client";
143
144/// Infers the AI operation from an AI operation name.
145///
146/// The operation name is usually inferred from the
147/// [`GEN_AI__OPERATION__NAME`] span attribute and the span
148/// operation.
149///
150/// Sentry expects the operation type in the [`GEN_AI__OPERATION__TYPE`] attribute.
151///
152/// The function returns `None` when the op is not a well known AI operation, callers likely want to default
153/// the value to [`DEFAULT_AI_OPERATION`] for AI spans.
154pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
155    let ai_op = match op_name {
156        // Full matches:
157        "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        // Prefix matches:
171        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        // No match:
182        _ => return None,
183    };
184
185    Some(ai_op)
186}
187
188/// Calculates the cost of an AI model based on the model cost and the tokens used.
189/// Calculated cost is in US dollars.
190fn 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    // Set individual cost components
220    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
230/// Maps AI-related measurements (legacy) to span data.
231fn 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    // It might be that 'total_tokens' is not set in which case we need to calculate it
264    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            // don't set total_tokens if there are no input nor output tokens
274            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
286/// Sets the context window size and utilization for the model.
287fn 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
313/// Extract the additional data into the span
314fn extract_ai_data(
315    data: &mut SpanData,
316    duration: f64,
317    model_metadata: &ModelMetadata,
318    origin: Option<&str>,
319    platform: Option<&str>,
320) {
321    // Extracts the response tokens per second
322    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    // Extracts the total cost of the AI model used
339    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
356/// Enrich the AI span data
357fn 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    // Default response model to request model if not set.
377    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    // Default agent name to function_id if not set.
387    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
419/// Enrich the AI span data
420pub 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
437/// Extract the ai data from all of an event's spans
438pub 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
482/// Returns true if the span is an AI span.
483/// AI spans are spans with either a gen_ai.operation.name attribute or op starting with "ai."
484/// (legacy) or "gen_ai." (new).
485fn 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                // Should fallback to output token cost for reasoning.
570                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    /// This test shows it is possible to produce negative costs if tokens are not aligned properly.
595    ///
596    /// The behaviour was desired when initially implemented.
597    #[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        // input: (100 - 20 - 30) * 1.0 + 20 * 0.5 + 30 * 0.75 = 50 + 10 + 22.5 = 82.5
650        //   (cache-write tokens are billed once at the cache-write rate, not also at
651        //    the standard input rate). output: 40 * 2.0 + 10 * 3.0 = 110.0
652        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        // Test that cost calculation works when cache_write field is missing (backward compatibility)
663        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        // Verify cache_write_tokens defaults to 0.0
681        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        // Cost should be calculated without cache_write_tokens
698        // input: (100 - 20) * 1.0 + 20 * 0.5 + 0 * 0.75 = 80 + 10 + 0 = 90
699        // output: 50 * 2.0 = 100
700        insta::assert_debug_snapshot!(cost, @r"
701        CalculatedCost {
702            input: 90.0,
703            output: 100.0,
704        }
705        ");
706    }
707
708    /// Test that the AI operation type is inferred from a gen_ai.operation.name attribute.
709    #[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 that the AI operation type is inferred from a span.op attribute.
726    #[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 that the AI operation type is inferred from a fallback.
743    #[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 that the response model is defaulted to the request model if not set.
760    #[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 that the response model is defaulted to the request model if not set.
778    #[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 that gen_ai.agent.name is defaulted from gen_ai.function_id.
797    #[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 that gen_ai.agent.name is not overridden when already set.
815    #[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 that an AI span is detected from a gen_ai.operation.name attribute.
834    #[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 that an AI span is detected from a span.op starting with "ai.".
848    #[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 that an AI span is detected from a span.op starting with "gen_ai.".
855    #[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 that a non-AI span is detected.
862    #[test]
863    fn test_is_ai_span_negative() {
864        assert!(!is_ai_span(&Annotated::default(), None));
865    }
866
867    /// Test enrich_ai_event_data with invoke_agent in trace context and a chat child span.
868    #[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 enrich_ai_event_data with non-AI trace context, invoke_agent parent span, and chat child span.
946    #[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 enrich_ai_event_data with legacy measurements and span op for operation type.
1040    #[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        // window_size should still be set even without tokens.
1244        assert_eq!(
1245            data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1246                .and_then(Value::as_f64),
1247            Some(100_000.0)
1248        );
1249        // But utilization cannot be computed without total_tokens.
1250        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}