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 total cost of all input tokens (raw + cached + cache_write).
77    pub input: f64,
78    /// The total cost of all output tokens (raw + reasoning).
79    pub output: f64,
80    /// The cost of cached input tokens only (subset of `input`).
81    pub cache_read_input: f64,
82    /// The cost of cache-write input tokens only (subset of `input`).
83    pub cache_creation_input: f64,
84    /// The cost of reasoning output tokens only (subset of `output`).
85    pub reasoning_output: f64,
86}
87
88impl CalculatedCost {
89    /// The total, input and output, cost.
90    pub fn total(&self) -> f64 {
91        self.input + self.output
92    }
93}
94
95/// Calculates the total cost for a model call.
96///
97/// Returns `None` if no tokens were used.
98pub 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    // For now most of the models do not differentiate between reasoning and output token cost,
122    // it costs the same.
123    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
152/// Default AI operation stored in [`GEN_AI__OPERATION__TYPE`]
153/// for AI spans without a well known AI span op.
154///
155/// See also: [`infer_ai_operation_type`].
156pub const DEFAULT_AI_OPERATION: &str = "ai_client";
157
158/// Infers the AI operation from an AI operation name.
159///
160/// The operation name is usually inferred from the
161/// [`GEN_AI__OPERATION__NAME`] span attribute and the span
162/// operation.
163///
164/// Sentry expects the operation type in the [`GEN_AI__OPERATION__TYPE`] attribute.
165///
166/// The function returns `None` when the op is not a well known AI operation, callers likely want to default
167/// the value to [`DEFAULT_AI_OPERATION`] for AI spans.
168pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
169    let ai_op = match op_name {
170        // Full matches:
171        "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        // Prefix matches:
185        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        // No match:
196        _ => return None,
197    };
198
199    Some(ai_op)
200}
201
202/// Calculates the cost of an AI model based on the model cost and the tokens used.
203/// Calculated cost is in US dollars.
204fn 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    // Set individual cost components
234    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
258/// Maps AI-related measurements (legacy) to span data.
259fn 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    // It might be that 'total_tokens' is not set in which case we need to calculate it
292    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            // don't set total_tokens if there are no input nor output tokens
302            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
314/// Sets the context window size and utilization for the model.
315fn 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
341/// Extract the additional data into the span
342fn extract_ai_data(
343    data: &mut SpanData,
344    duration: f64,
345    model_metadata: &ModelMetadata,
346    origin: Option<&str>,
347    platform: Option<&str>,
348) {
349    // Extracts the response tokens per second
350    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    // Extracts the total cost of the AI model used
367    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
384/// Enrich the AI span data
385fn 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    // Default response model to request model if not set.
405    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    // Default agent name to function_id if not set.
415    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
447/// Enrich the AI span data
448pub 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
465/// Extract the ai data from all of an event's spans
466pub 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
510/// Returns true if the span is an AI span.
511/// AI spans are spans with either a gen_ai.operation.name attribute or op starting with "ai."
512/// (legacy) or "gen_ai." (new).
513fn 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                // Should fallback to output token cost for reasoning.
601                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    /// This test shows it is possible to produce negative costs if tokens are not aligned properly.
629    ///
630    /// The behaviour was desired when initially implemented.
631    #[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        // input: (100 - 20 - 30) * 1.0 + 20 * 0.5 + 30 * 0.75 = 50 + 10 + 22.5 = 82.5
687        //   (cache-write tokens are billed once at the cache-write rate, not also at
688        //    the standard input rate). output: 40 * 2.0 + 10 * 3.0 = 110.0
689        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        // Test that cost calculation works when cache_write field is missing (backward compatibility)
703        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        // Verify cache_write_tokens defaults to 0.0
721        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        // Cost should be calculated without cache_write_tokens
738        // input: (100 - 20) * 1.0 + 20 * 0.5 + 0 * 0.75 = 80 + 10 + 0 = 90
739        // output: 50 * 2.0 = 100
740        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 that the AI operation type is inferred from a gen_ai.operation.name attribute.
752    #[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 that the AI operation type is inferred from a span.op attribute.
769    #[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 that the AI operation type is inferred from a fallback.
786    #[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 that the response model is defaulted to the request model if not set.
803    #[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 that the response model is defaulted to the request model if not set.
821    #[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 that gen_ai.agent.name is defaulted from gen_ai.function_id.
840    #[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 that gen_ai.agent.name is not overridden when already set.
858    #[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 that an AI span is detected from a gen_ai.operation.name attribute.
877    #[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 that an AI span is detected from a span.op starting with "ai.".
891    #[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 that an AI span is detected from a span.op starting with "gen_ai.".
898    #[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 that a non-AI span is detected.
905    #[test]
906    fn test_is_ai_span_negative() {
907        assert!(!is_ai_span(&Annotated::default(), None));
908    }
909
910    /// Test enrich_ai_event_data with invoke_agent in trace context and a chat child span.
911    #[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 enrich_ai_event_data with non-AI trace context, invoke_agent parent span, and chat child span.
989    #[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 enrich_ai_event_data with legacy measurements and span op for operation type.
1083    #[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        // window_size should still be set even without tokens.
1287        assert_eq!(
1288            data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1289                .and_then(Value::as_f64),
1290            Some(100_000.0)
1291        );
1292        // But utilization cannot be computed without total_tokens.
1293        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}