Skip to main content

relay_event_normalization/normalize/span/
ai.rs

1//! AI cost calculation.
2
3use crate::eap::AttributesLike;
4use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
5use crate::{ModelCostV2, ModelMetadata};
6use relay_conventions::attributes::*;
7use relay_event_schema::protocol::{
8    Event, Measurements, OperationType, Span, SpanData, TraceContext,
9};
10use relay_protocol::{Annotated, Getter, Value};
11
12/// Amount of used tokens for a model call.
13#[derive(Debug, Copy, Clone)]
14pub struct UsedTokens {
15    /// Total amount of input tokens used.
16    pub input_tokens: f64,
17    /// Amount of cached tokens used.
18    ///
19    /// This is a subset of [`Self::input_tokens`].
20    pub input_cached_tokens: f64,
21    /// Amount of cache write tokens used.
22    ///
23    /// This is a subset of [`Self::input_tokens`].
24    pub input_cache_write_tokens: f64,
25    /// Total amount of output tokens.
26    pub output_tokens: f64,
27    /// Total amount of reasoning tokens.
28    ///
29    /// This is a subset of [`Self::output_tokens`].
30    pub output_reasoning_tokens: f64,
31}
32
33impl UsedTokens {
34    /// Extracts [`UsedTokens`] from [`SpanData`] attributes.
35    pub fn from_span_data(data: &SpanData) -> Self {
36        macro_rules! get_value {
37            ($e:expr) => {
38                data.get_value($e).and_then(|v| v.as_f64()).unwrap_or(0.0)
39            };
40        }
41
42        Self {
43            input_tokens: get_value!(GEN_AI__USAGE__INPUT_TOKENS),
44            output_tokens: get_value!(GEN_AI__USAGE__OUTPUT_TOKENS),
45            output_reasoning_tokens: get_value!(GEN_AI__USAGE__REASONING__OUTPUT_TOKENS),
46            input_cached_tokens: get_value!(GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS),
47            input_cache_write_tokens: get_value!(GEN_AI__USAGE__CACHE_CREATION__INPUT_TOKENS),
48        }
49    }
50
51    /// Returns `true` if any tokens were used.
52    pub fn has_usage(&self) -> bool {
53        self.input_tokens > 0.0 || self.output_tokens > 0.0
54    }
55
56    /// Calculates the total amount of input tokens billed at the standard rate.
57    ///
58    /// Both [`Self::input_cached_tokens`] and [`Self::input_cache_write_tokens`] are
59    /// subsets of [`Self::input_tokens`] and are billed separately at their own
60    /// (cached / cache-write) rates, so both are subtracted here to avoid charging
61    /// them twice.
62    pub fn raw_input_tokens(&self) -> f64 {
63        self.input_tokens - self.input_cached_tokens - self.input_cache_write_tokens
64    }
65
66    /// Calculates the total amount of raw, non-reasoning output tokens.
67    ///
68    /// Subtracts reasoning tokens from the total token count.
69    pub fn raw_output_tokens(&self) -> f64 {
70        self.output_tokens - self.output_reasoning_tokens
71    }
72}
73
74/// Calculated model call costs.
75#[derive(Debug, Copy, Clone)]
76pub struct CalculatedCost {
77    /// The total cost of all input tokens (raw + cached + cache_write).
78    pub input: f64,
79    /// The total cost of all output tokens (raw + reasoning).
80    pub output: f64,
81    /// The cost of cached input tokens only (subset of `input`).
82    pub cache_read_input: f64,
83    /// The cost of cache-write input tokens only (subset of `input`).
84    pub cache_creation_input: f64,
85    /// The cost of reasoning output tokens only (subset of `output`).
86    pub reasoning_output: f64,
87}
88
89impl CalculatedCost {
90    /// The total, input and output, cost.
91    pub fn total(&self) -> f64 {
92        self.input + self.output
93    }
94}
95
96/// Calculates the total cost for a model call.
97///
98/// Returns `None` if no tokens were used.
99pub fn calculate_costs(
100    model_cost: &ModelCostV2,
101    tokens: UsedTokens,
102    integration: &str,
103    platform: &str,
104) -> Option<CalculatedCost> {
105    if !tokens.has_usage() {
106        relay_statsd::metric!(
107            counter(Counters::GenAiCostCalculationResult) += 1,
108            result = "calculation_no_tokens",
109            integration = integration,
110            platform = platform,
111        );
112        return None;
113    }
114
115    let cache_read_input = tokens.input_cached_tokens * model_cost.input_cached_per_token;
116    let cache_creation_input =
117        tokens.input_cache_write_tokens * model_cost.input_cache_write_per_token;
118    let input = (tokens.raw_input_tokens() * model_cost.input_per_token)
119        + cache_read_input
120        + cache_creation_input;
121
122    // For now most of the models do not differentiate between reasoning and output token cost,
123    // it costs the same.
124    let reasoning_per_token = match model_cost.output_reasoning_per_token {
125        r if r > 0.0 => r,
126        _ => model_cost.output_per_token,
127    };
128    let reasoning_output = tokens.output_reasoning_tokens * reasoning_per_token;
129    let output = (tokens.raw_output_tokens() * model_cost.output_per_token) + reasoning_output;
130
131    let metric_label = match (input, output) {
132        (x, y) if x < 0.0 || y < 0.0 => "calculation_negative",
133        (0.0, 0.0) => "calculation_zero",
134        _ => "calculation_positive",
135    };
136
137    relay_statsd::metric!(
138        counter(Counters::GenAiCostCalculationResult) += 1,
139        result = metric_label,
140        integration = integration,
141        platform = platform,
142    );
143
144    Some(CalculatedCost {
145        input,
146        output,
147        cache_read_input,
148        cache_creation_input,
149        reasoning_output,
150    })
151}
152
153/// Returns the fallback AI operation type based on whether model info is available.
154pub fn default_ai_operation_type(attributes: &impl AttributesLike) -> &'static str {
155    if [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL]
156        .iter()
157        .any(|key| attributes.get_value(key).and_then(Value::as_str).is_some())
158    {
159        "ai_client"
160    } else {
161        "other"
162    }
163}
164
165/// Infers the AI operation from an AI operation name.
166///
167/// The operation name is usually inferred from the
168/// [`GEN_AI__OPERATION__NAME`] span attribute and the span
169/// operation.
170///
171/// Sentry expects the operation type in the [`GEN_AI__OPERATION__TYPE`] attribute.
172///
173/// The function returns `None` when the op is not a well known AI operation, callers likely want to default
174/// the value using [`default_ai_operation_type`] for AI spans.
175pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
176    let ai_op = match op_name {
177        // Full matches:
178        "ai.run.generateText"
179        | "ai.run.generateObject"
180        | "gen_ai.invoke_agent"
181        | "ai.pipeline.generate_text"
182        | "ai.pipeline.generate_object"
183        | "ai.pipeline.stream_text"
184        | "ai.pipeline.stream_object"
185        | "gen_ai.create_agent"
186        | "invoke_agent"
187        | "create_agent" => "agent",
188        "gen_ai.execute_tool" | "execute_tool" => "tool",
189        "gen_ai.handoff" | "handoff" => "handoff",
190        "gen_ai.memory.client"
191        | "create_memory"
192        | "create_memory_store"
193        | "delete_memory"
194        | "delete_memory_store"
195        | "search_memory"
196        | "update_memory"
197        | "upsert_memory" => "memory",
198        "ai.processor" | "processor_run" => "other",
199        // Prefix matches:
200        op if op.starts_with("ai.streamText.doStream") => "ai_client",
201        op if op.starts_with("ai.streamText") => "agent",
202
203        op if op.starts_with("ai.generateText.doGenerate") => "ai_client",
204        op if op.starts_with("ai.generateText") => "agent",
205
206        op if op.starts_with("ai.generateObject.doGenerate") => "ai_client",
207        op if op.starts_with("ai.generateObject") => "agent",
208
209        op if op.starts_with("ai.toolCall") => "tool",
210        // No match:
211        _ => return None,
212    };
213
214    Some(ai_op)
215}
216
217/// Returns whether a valid total cost is attached.
218pub fn has_valid_total_cost(attributes: &impl AttributesLike) -> bool {
219    attributes
220        .get_value(GEN_AI__COST__TOTAL_TOKENS)
221        .and_then(Value::as_f64)
222        .is_some()
223}
224
225/// Calculates the cost of an AI model based on the model cost and the tokens used.
226/// Calculated cost is in US dollars.
227fn extract_ai_model_cost_data(
228    model_cost: Option<&ModelCostV2>,
229    data: &mut SpanData,
230    origin: Option<&str>,
231    platform: Option<&str>,
232) {
233    // Preserve existing total cost instead of recalculating and overwriting it.
234    if has_valid_total_cost(data) {
235        return;
236    }
237
238    let integration = map_origin_to_integration(origin);
239    let platform = platform_tag(platform);
240
241    let Some(model_cost) = model_cost else {
242        relay_statsd::metric!(
243            counter(Counters::GenAiCostCalculationResult) += 1,
244            result = "calculation_no_model_cost_available",
245            integration = integration,
246            platform = platform,
247        );
248        return;
249    };
250
251    let used_tokens = UsedTokens::from_span_data(&*data);
252    let Some(costs) = calculate_costs(model_cost, used_tokens, integration, platform) else {
253        return;
254    };
255
256    data.other
257        .entry(GEN_AI__COST__TOTAL_TOKENS.to_owned())
258        .or_default()
259        .set_value(Value::F64(costs.total()).into());
260
261    // Set individual cost components
262    data.other
263        .entry(GEN_AI__COST__INPUT_TOKENS.to_owned())
264        .or_default()
265        .set_value(Value::F64(costs.input).into());
266    data.other
267        .entry(GEN_AI__COST__CACHE_READ__INPUT_TOKENS.to_owned())
268        .or_default()
269        .set_value(Value::F64(costs.cache_read_input).into());
270    data.other
271        .entry(GEN_AI__COST__CACHE_CREATION__INPUT_TOKENS.to_owned())
272        .or_default()
273        .set_value(Value::F64(costs.cache_creation_input).into());
274
275    data.other
276        .entry(GEN_AI__COST__OUTPUT_TOKENS.to_owned())
277        .or_default()
278        .set_value(Value::F64(costs.output).into());
279
280    data.other
281        .entry(GEN_AI__COST__REASONING__OUTPUT_TOKENS.to_owned())
282        .or_default()
283        .set_value(Value::F64(costs.reasoning_output).into());
284}
285
286/// Maps AI-related measurements (legacy) to span data.
287fn map_ai_measurements_to_data(data: &mut SpanData, measurements: Option<&Measurements>) {
288    let set_field_from_measurement = |target_field: &mut Annotated<Value>,
289                                      measurement_key: &str| {
290        if let Some(measurements) = measurements
291            && target_field.value().is_none()
292            && let Some(value) = measurements.get_value(measurement_key)
293        {
294            target_field.set_value(Value::F64(value.to_f64()).into());
295        }
296    };
297
298    set_field_from_measurement(
299        data.other
300            .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
301            .or_default(),
302        "ai_total_tokens_used",
303    );
304    set_field_from_measurement(
305        data.other
306            .entry(GEN_AI__USAGE__INPUT_TOKENS.to_owned())
307            .or_default(),
308        "ai_prompt_tokens_used",
309    );
310    set_field_from_measurement(
311        data.other
312            .entry(GEN_AI__USAGE__OUTPUT_TOKENS.to_owned())
313            .or_default(),
314        "ai_completion_tokens_used",
315    );
316}
317
318fn set_total_tokens(data: &mut SpanData) {
319    // It might be that 'total_tokens' is not set in which case we need to calculate it
320    if data.get_value(GEN_AI__USAGE__TOTAL_TOKENS).is_none() {
321        let input_tokens = data
322            .get_value(GEN_AI__USAGE__INPUT_TOKENS)
323            .and_then(Value::as_f64);
324        let output_tokens = data
325            .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
326            .and_then(Value::as_f64);
327
328        if input_tokens.is_none() && output_tokens.is_none() {
329            // don't set total_tokens if there are no input nor output tokens
330            return;
331        }
332
333        data.other
334            .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
335            .or_default()
336            .set_value(
337                Value::F64(input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0)).into(),
338            );
339    }
340}
341
342/// Sets the context window size and utilization for the model.
343fn extract_context_utilization(data: &mut SpanData, model_metadata: &ModelMetadata) {
344    let model_id = data.get_str(GEN_AI__RESPONSE__MODEL);
345
346    let context_size = model_id.and_then(|id| model_metadata.context_size(id));
347
348    let Some(context_size) = context_size else {
349        return;
350    };
351
352    data.other
353        .entry(GEN_AI__CONTEXT__WINDOW_SIZE.to_owned())
354        .or_default()
355        .set_value(Value::U64(context_size).into());
356
357    let total_tokens = data
358        .get_value(GEN_AI__USAGE__TOTAL_TOKENS)
359        .and_then(Value::as_f64);
360
361    if let Some(total_tokens) = total_tokens {
362        data.other
363            .entry(GEN_AI__CONTEXT__UTILIZATION.to_owned())
364            .or_default()
365            .set_value(Value::F64(total_tokens / context_size as f64).into());
366    }
367}
368
369/// Extract the additional data into the span
370fn extract_ai_data(
371    data: &mut SpanData,
372    duration: f64,
373    model_metadata: &ModelMetadata,
374    origin: Option<&str>,
375    platform: Option<&str>,
376) {
377    // Extracts the response tokens per second
378    if data
379        .get_value(GEN_AI__RESPONSE__TOKENS_PER_SECOND)
380        .is_none()
381        && duration > 0.0
382        && let Some(output_tokens) = data
383            .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
384            .and_then(Value::as_f64)
385    {
386        data.other
387            .entry(GEN_AI__RESPONSE__TOKENS_PER_SECOND.to_owned())
388            .or_default()
389            .set_value(Value::F64(output_tokens / (duration / 1000.0)).into());
390    }
391
392    extract_context_utilization(data, model_metadata);
393
394    // Extracts the total cost of the AI model used
395    if let Some(model_id) = data.get_str(GEN_AI__RESPONSE__MODEL) {
396        extract_ai_model_cost_data(
397            model_metadata.cost_per_token(model_id),
398            data,
399            origin,
400            platform,
401        )
402    } else {
403        relay_statsd::metric!(
404            counter(Counters::GenAiCostCalculationResult) += 1,
405            result = "calculation_no_model_id_available",
406            integration = map_origin_to_integration(origin),
407            platform = platform_tag(platform),
408        );
409    }
410}
411
412/// Enrich the AI span data
413fn enrich_ai_span_data(
414    span_data: &mut Annotated<SpanData>,
415    span_op: &Annotated<OperationType>,
416    measurements: &Annotated<Measurements>,
417    duration: f64,
418    model_metadata: Option<&ModelMetadata>,
419    origin: Option<&str>,
420    platform: Option<&str>,
421) {
422    if !is_ai_span(span_data, span_op.value()) {
423        return;
424    }
425
426    let data = span_data.get_or_insert_with(SpanData::default);
427
428    map_ai_measurements_to_data(data, measurements.value());
429
430    set_total_tokens(data);
431
432    // Default response model to request model if not set.
433    if data.get_value(GEN_AI__RESPONSE__MODEL).is_none()
434        && let Some(request_model) = data.get_value(GEN_AI__REQUEST__MODEL).cloned()
435    {
436        data.other
437            .entry(GEN_AI__RESPONSE__MODEL.to_owned())
438            .or_default()
439            .set_value(Some(request_model));
440    }
441
442    // Default agent name to function_id if not set.
443    if data.get_value(GEN_AI__AGENT__NAME).is_none()
444        && let Some(function_id) = data.get_value(GEN_AI__FUNCTION_ID).cloned()
445    {
446        data.other
447            .entry(GEN_AI__AGENT__NAME.to_owned())
448            .or_default()
449            .set_value(Some(function_id));
450    }
451
452    if let Some(model_metadata) = model_metadata {
453        extract_ai_data(data, duration, model_metadata, origin, platform);
454    } else {
455        relay_statsd::metric!(
456            counter(Counters::GenAiCostCalculationResult) += 1,
457            result = "calculation_no_model_cost_available",
458            integration = map_origin_to_integration(origin),
459            platform = platform_tag(platform),
460        );
461    }
462
463    let ai_op_type = data
464        .get_str(GEN_AI__OPERATION__NAME)
465        .or(span_op.value().map(String::as_str))
466        .and_then(infer_ai_operation_type)
467        .unwrap_or_else(|| default_ai_operation_type(data));
468
469    data.other
470        .entry(GEN_AI__OPERATION__TYPE.to_owned())
471        .or_default()
472        .set_value(Some(Value::String(ai_op_type.to_owned())));
473}
474
475/// Enrich the AI span data
476pub fn enrich_ai_span(span: &mut Span, model_metadata: Option<&ModelMetadata>) {
477    let duration = span
478        .get_value("span.duration")
479        .and_then(|v| v.as_f64())
480        .unwrap_or(0.0);
481
482    enrich_ai_span_data(
483        &mut span.data,
484        &span.op,
485        &span.measurements,
486        duration,
487        model_metadata,
488        span.origin.as_str(),
489        span.platform.as_str(),
490    );
491}
492
493/// Extract the ai data from all of an event's spans
494pub fn enrich_ai_event_data(event: &mut Event, model_metadata: Option<&ModelMetadata>) {
495    let event_duration = event
496        .get_value("event.duration")
497        .and_then(|v| v.as_f64())
498        .unwrap_or(0.0);
499
500    if let Some(trace_context) = event
501        .contexts
502        .value_mut()
503        .as_mut()
504        .and_then(|c| c.get_mut::<TraceContext>())
505    {
506        enrich_ai_span_data(
507            &mut trace_context.data,
508            &trace_context.op,
509            &event.measurements,
510            event_duration,
511            model_metadata,
512            trace_context.origin.as_str(),
513            event.platform.as_str(),
514        );
515    }
516    let spans = event.spans.value_mut().iter_mut().flatten();
517    let spans = spans.filter_map(|span| span.value_mut().as_mut());
518
519    for span in spans {
520        let span_duration = span
521            .get_value("span.duration")
522            .and_then(|v| v.as_f64())
523            .unwrap_or(0.0);
524        let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
525
526        enrich_ai_span_data(
527            &mut span.data,
528            &span.op,
529            &span.measurements,
530            span_duration,
531            model_metadata,
532            span.origin.as_str(),
533            span_platform,
534        );
535    }
536}
537
538/// Returns true if the span is an AI span.
539/// AI spans are spans with either a gen_ai.operation.name attribute or op starting with "ai."
540/// (legacy) or "gen_ai." (new).
541fn is_ai_span(span_data: &Annotated<SpanData>, span_op: Option<&OperationType>) -> bool {
542    let has_ai_op = span_data
543        .value()
544        .and_then(|data| data.get_value(GEN_AI__OPERATION__NAME))
545        .is_some();
546
547    let is_ai_span_op =
548        span_op.is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."));
549
550    has_ai_op || is_ai_span_op
551}
552
553#[cfg(test)]
554mod tests {
555    use std::collections::HashMap;
556
557    use relay_protocol::{FromValue, assert_annotated_snapshot};
558    use serde_json::json;
559
560    use super::*;
561    use crate::ModelMetadataEntry;
562
563    fn ai_span_with_data(data: serde_json::Value) -> Span {
564        Span {
565            op: "gen_ai.test".to_owned().into(),
566            data: SpanData::from_value(data.into()),
567            ..Default::default()
568        }
569    }
570
571    #[test]
572    fn test_has_valid_total_cost() {
573        let missing = ai_span_with_data(json!({}));
574        let invalid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": false}));
575        let valid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": 1.0}));
576
577        assert!(!has_valid_total_cost(missing.data.value().unwrap()));
578        assert!(!has_valid_total_cost(invalid.data.value().unwrap()));
579        assert!(has_valid_total_cost(valid.data.value().unwrap()));
580    }
581
582    #[test]
583    fn test_calculate_cost_no_tokens() {
584        let cost = calculate_costs(
585            &ModelCostV2 {
586                input_per_token: 1.0,
587                output_per_token: 1.0,
588                output_reasoning_per_token: 1.0,
589                input_cached_per_token: 1.0,
590                input_cache_write_per_token: 1.0,
591            },
592            UsedTokens::from_span_data(&SpanData::default()),
593            "test",
594            "test",
595        );
596        assert!(cost.is_none());
597    }
598
599    #[test]
600    fn test_calculate_cost_full() {
601        let cost = calculate_costs(
602            &ModelCostV2 {
603                input_per_token: 1.0,
604                output_per_token: 2.0,
605                output_reasoning_per_token: 3.0,
606                input_cached_per_token: 0.5,
607                input_cache_write_per_token: 0.75,
608            },
609            UsedTokens {
610                input_tokens: 8.0,
611                input_cached_tokens: 5.0,
612                input_cache_write_tokens: 0.0,
613                output_tokens: 15.0,
614                output_reasoning_tokens: 9.0,
615            },
616            "test",
617            "test",
618        )
619        .unwrap();
620
621        insta::assert_debug_snapshot!(cost, @r"
622        CalculatedCost {
623            input: 5.5,
624            output: 39.0,
625            cache_read_input: 2.5,
626            cache_creation_input: 0.0,
627            reasoning_output: 27.0,
628        }
629        ");
630    }
631
632    #[test]
633    fn test_calculate_cost_no_reasoning_cost() {
634        let cost = calculate_costs(
635            &ModelCostV2 {
636                input_per_token: 1.0,
637                output_per_token: 2.0,
638                // Should fallback to output token cost for reasoning.
639                output_reasoning_per_token: 0.0,
640                input_cached_per_token: 0.5,
641                input_cache_write_per_token: 0.0,
642            },
643            UsedTokens {
644                input_tokens: 8.0,
645                input_cached_tokens: 5.0,
646                input_cache_write_tokens: 0.0,
647                output_tokens: 15.0,
648                output_reasoning_tokens: 9.0,
649            },
650            "test",
651            "test",
652        )
653        .unwrap();
654
655        insta::assert_debug_snapshot!(cost, @r"
656        CalculatedCost {
657            input: 5.5,
658            output: 30.0,
659            cache_read_input: 2.5,
660            cache_creation_input: 0.0,
661            reasoning_output: 18.0,
662        }
663        ");
664    }
665
666    /// This test shows it is possible to produce negative costs if tokens are not aligned properly.
667    ///
668    /// The behaviour was desired when initially implemented.
669    #[test]
670    fn test_calculate_cost_negative() {
671        let cost = calculate_costs(
672            &ModelCostV2 {
673                input_per_token: 2.0,
674                output_per_token: 2.0,
675                output_reasoning_per_token: 1.0,
676                input_cached_per_token: 1.0,
677                input_cache_write_per_token: 1.5,
678            },
679            UsedTokens {
680                input_tokens: 1.0,
681                input_cached_tokens: 11.0,
682                input_cache_write_tokens: 0.0,
683                output_tokens: 1.0,
684                output_reasoning_tokens: 9.0,
685            },
686            "test",
687            "test",
688        )
689        .unwrap();
690
691        insta::assert_debug_snapshot!(cost, @r"
692        CalculatedCost {
693            input: -9.0,
694            output: -7.0,
695            cache_read_input: 11.0,
696            cache_creation_input: 0.0,
697            reasoning_output: 9.0,
698        }
699        ");
700    }
701
702    #[test]
703    fn test_calculate_cost_with_cache_writes() {
704        let cost = calculate_costs(
705            &ModelCostV2 {
706                input_per_token: 1.0,
707                output_per_token: 2.0,
708                output_reasoning_per_token: 3.0,
709                input_cached_per_token: 0.5,
710                input_cache_write_per_token: 0.75,
711            },
712            UsedTokens {
713                input_tokens: 100.0,
714                input_cached_tokens: 20.0,
715                input_cache_write_tokens: 30.0,
716                output_tokens: 50.0,
717                output_reasoning_tokens: 10.0,
718            },
719            "test",
720            "test",
721        )
722        .unwrap();
723
724        // input: (100 - 20 - 30) * 1.0 + 20 * 0.5 + 30 * 0.75 = 50 + 10 + 22.5 = 82.5
725        //   (cache-write tokens are billed once at the cache-write rate, not also at
726        //    the standard input rate). output: 40 * 2.0 + 10 * 3.0 = 110.0
727        insta::assert_debug_snapshot!(cost, @r"
728        CalculatedCost {
729            input: 82.5,
730            output: 110.0,
731            cache_read_input: 10.0,
732            cache_creation_input: 22.5,
733            reasoning_output: 30.0,
734        }
735        ");
736    }
737
738    #[test]
739    fn test_existing_cost_is_not_overwritten() {
740        let mut span = ai_span_with_data(json!({
741            "gen_ai.response.model": "claude-2.1",
742            "gen_ai.usage.input_tokens": 1000.0,
743            "gen_ai.cost.input_tokens": 99.0,
744            "gen_ai.cost.total_tokens": 123.0,
745        }));
746
747        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
748
749        let data = span.data.value().unwrap();
750        assert_eq!(
751            data.get_value(GEN_AI__COST__TOTAL_TOKENS)
752                .and_then(Value::as_f64),
753            Some(123.0)
754        );
755        assert!(data.get_value(GEN_AI__COST__OUTPUT_TOKENS).is_none());
756    }
757
758    #[test]
759    fn test_calculate_cost_backward_compatibility_no_cache_write() {
760        // Test that cost calculation works when cache_write field is missing (backward compatibility)
761        let span_data = SpanData::from([
762            (
763                GEN_AI__USAGE__INPUT_TOKENS.to_owned(),
764                Annotated::new(100.0.into()),
765            ),
766            (
767                GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS.to_owned(),
768                Annotated::new(20.0.into()),
769            ),
770            (
771                GEN_AI__USAGE__OUTPUT_TOKENS.to_owned(),
772                Annotated::new(50.0.into()),
773            ),
774        ]);
775
776        let tokens = UsedTokens::from_span_data(&span_data);
777
778        // Verify cache_write_tokens defaults to 0.0
779        assert_eq!(tokens.input_cache_write_tokens, 0.0);
780
781        let cost = calculate_costs(
782            &ModelCostV2 {
783                input_per_token: 1.0,
784                output_per_token: 2.0,
785                output_reasoning_per_token: 0.0,
786                input_cached_per_token: 0.5,
787                input_cache_write_per_token: 0.75,
788            },
789            tokens,
790            "test",
791            "test",
792        )
793        .unwrap();
794
795        // Cost should be calculated without cache_write_tokens
796        // input: (100 - 20) * 1.0 + 20 * 0.5 + 0 * 0.75 = 80 + 10 + 0 = 90
797        // output: 50 * 2.0 = 100
798        insta::assert_debug_snapshot!(cost, @r"
799        CalculatedCost {
800            input: 90.0,
801            output: 100.0,
802            cache_read_input: 10.0,
803            cache_creation_input: 0.0,
804            reasoning_output: 0.0,
805        }
806        ");
807    }
808
809    /// Test that the AI operation type is inferred from a gen_ai.operation.name attribute.
810    #[test]
811    fn test_infer_ai_operation_type_from_gen_ai_operation_name() {
812        let mut span = ai_span_with_data(json!({
813            "gen_ai.operation.name": "invoke_agent"
814        }));
815
816        enrich_ai_span(&mut span, None);
817
818        assert_annotated_snapshot!(&span.data, @r#"
819        {
820          "gen_ai.operation.name": "invoke_agent",
821          "gen_ai.operation.type": "agent"
822        }
823        "#);
824    }
825
826    /// Test that the AI operation type is inferred from a span.op attribute.
827    #[test]
828    fn test_infer_ai_operation_type_from_span_op() {
829        let mut span = Span {
830            op: "gen_ai.invoke_agent".to_owned().into(),
831            ..Default::default()
832        };
833
834        enrich_ai_span(&mut span, None);
835
836        assert_annotated_snapshot!(span.data, @r#"
837        {
838          "gen_ai.operation.type": "agent"
839        }
840        "#);
841    }
842
843    /// Test that the memory operation type is inferred from a gen_ai.operation.name attribute.
844    #[test]
845    fn test_infer_ai_operation_type_memory_from_gen_ai_operation_name() {
846        let mut span = ai_span_with_data(json!({
847            "gen_ai.operation.name": "upsert_memory"
848        }));
849
850        enrich_ai_span(&mut span, None);
851
852        assert_annotated_snapshot!(&span.data, @r#"
853        {
854          "gen_ai.operation.name": "upsert_memory",
855          "gen_ai.operation.type": "memory"
856        }
857        "#);
858    }
859
860    /// Test that the memory operation type is inferred from the gen_ai.memory.client span op.
861    #[test]
862    fn test_infer_ai_operation_type_memory_from_span_op() {
863        let mut span = Span {
864            op: "gen_ai.memory.client".to_owned().into(),
865            ..Default::default()
866        };
867
868        enrich_ai_span(&mut span, None);
869
870        assert_annotated_snapshot!(span.data, @r#"
871        {
872          "gen_ai.operation.type": "memory"
873        }
874        "#);
875    }
876
877    /// Test that the AI operation type is inferred from a fallback.
878    #[test]
879    fn test_infer_ai_operation_type_from_fallback() {
880        let mut span = ai_span_with_data(json!({
881            "gen_ai.operation.name": "embeddings"
882        }));
883
884        enrich_ai_span(&mut span, None);
885
886        assert_annotated_snapshot!(&span.data, @r#"
887        {
888          "gen_ai.operation.name": "embeddings",
889          "gen_ai.operation.type": "other"
890        }
891        "#);
892    }
893
894    #[test]
895    fn test_infer_ai_operation_type_fallback_with_model() {
896        for model_key in [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL] {
897            let mut span = ai_span_with_data(json!({
898                "gen_ai.operation.name": "embeddings",
899                (model_key): "gpt-4",
900            }));
901
902            enrich_ai_span(&mut span, None);
903
904            assert_eq!(
905                span.data.value().unwrap().get_str(GEN_AI__OPERATION__TYPE),
906                Some("ai_client"),
907            );
908        }
909    }
910
911    /// Test that the response model is defaulted to the request model if not set.
912    #[test]
913    fn test_default_response_model_from_request_model() {
914        let mut span = ai_span_with_data(json!({
915            "gen_ai.request.model": "gpt-4",
916        }));
917
918        enrich_ai_span(&mut span, None);
919
920        assert_annotated_snapshot!(&span.data, @r#"
921        {
922          "gen_ai.operation.type": "ai_client",
923          "gen_ai.request.model": "gpt-4",
924          "gen_ai.response.model": "gpt-4"
925        }
926        "#);
927    }
928
929    /// Test that the response model is defaulted to the request model if not set.
930    #[test]
931    fn test_default_response_model_not_overridden() {
932        let mut span = ai_span_with_data(json!({
933            "gen_ai.request.model": "gpt-4",
934            "gen_ai.response.model": "gpt-4-abcd",
935        }));
936
937        enrich_ai_span(&mut span, None);
938
939        assert_annotated_snapshot!(&span.data, @r#"
940        {
941          "gen_ai.operation.type": "ai_client",
942          "gen_ai.request.model": "gpt-4",
943          "gen_ai.response.model": "gpt-4-abcd"
944        }
945        "#);
946    }
947
948    /// Test that gen_ai.agent.name is defaulted from gen_ai.function_id.
949    #[test]
950    fn test_default_agent_name_from_function_id() {
951        let mut span = ai_span_with_data(json!({
952            "gen_ai.function_id": "my-agent",
953        }));
954
955        enrich_ai_span(&mut span, None);
956
957        assert_annotated_snapshot!(&span.data, @r#"
958        {
959          "gen_ai.agent.name": "my-agent",
960          "gen_ai.function_id": "my-agent",
961          "gen_ai.operation.type": "other"
962        }
963        "#);
964    }
965
966    /// Test that gen_ai.agent.name is not overridden when already set.
967    #[test]
968    fn test_default_agent_name_not_overridden() {
969        let mut span = ai_span_with_data(json!({
970            "gen_ai.function_id": "my-function",
971            "gen_ai.agent.name": "my-agent",
972        }));
973
974        enrich_ai_span(&mut span, None);
975
976        assert_annotated_snapshot!(&span.data, @r#"
977        {
978          "gen_ai.agent.name": "my-agent",
979          "gen_ai.function_id": "my-function",
980          "gen_ai.operation.type": "other"
981        }
982        "#);
983    }
984
985    /// Test that an AI span is detected from a gen_ai.operation.name attribute.
986    #[test]
987    fn test_is_ai_span_from_gen_ai_operation_name() {
988        let mut span_data = Annotated::default();
989        span_data
990            .get_or_insert_with(SpanData::default)
991            .other
992            .insert(
993                GEN_AI__OPERATION__NAME.to_owned(),
994                Annotated::new(Value::String("chat".into())),
995            );
996        assert!(is_ai_span(&span_data, None));
997    }
998
999    /// Test that an AI span is detected from a span.op starting with "ai.".
1000    #[test]
1001    fn test_is_ai_span_from_span_op_ai() {
1002        let span_op: OperationType = "ai.chat".into();
1003        assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1004    }
1005
1006    /// Test that an AI span is detected from a span.op starting with "gen_ai.".
1007    #[test]
1008    fn test_is_ai_span_from_span_op_gen_ai() {
1009        let span_op: OperationType = "gen_ai.chat".into();
1010        assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1011    }
1012
1013    /// Test that a non-AI span is detected.
1014    #[test]
1015    fn test_is_ai_span_negative() {
1016        assert!(!is_ai_span(&Annotated::default(), None));
1017    }
1018
1019    /// Test enrich_ai_event_data with invoke_agent in trace context and a chat child span.
1020    #[test]
1021    fn test_enrich_ai_event_data_invoke_agent_trace_with_chat_span() {
1022        let event_json = r#"{
1023            "type": "transaction",
1024            "timestamp": 1234567892.0,
1025            "start_timestamp": 1234567889.0,
1026            "contexts": {
1027                "trace": {
1028                    "op": "gen_ai.invoke_agent",
1029                    "trace_id": "12345678901234567890123456789012",
1030                    "span_id": "1234567890123456",
1031                    "data": {
1032                        "gen_ai.operation.name": "gen_ai.invoke_agent",
1033                        "gen_ai.usage.input_tokens": 500,
1034                        "gen_ai.usage.output_tokens": 200
1035                    }
1036                }
1037            },
1038            "spans": [
1039                {
1040                    "op": "gen_ai.chat.completions",
1041                    "span_id": "1234567890123457",
1042                    "start_timestamp": 1234567889.5,
1043                    "timestamp": 1234567890.5,
1044                    "data": {
1045                        "gen_ai.operation.name": "chat",
1046                        "gen_ai.usage.input_tokens": 100,
1047                        "gen_ai.usage.output_tokens": 50
1048                    }
1049                }
1050            ]
1051        }"#;
1052
1053        let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1054        let event = annotated_event.value_mut().as_mut().unwrap();
1055
1056        enrich_ai_event_data(event, None);
1057
1058        assert_annotated_snapshot!(&annotated_event, @r#"
1059        {
1060          "type": "transaction",
1061          "timestamp": 1234567892.0,
1062          "start_timestamp": 1234567889.0,
1063          "contexts": {
1064            "trace": {
1065              "trace_id": "12345678901234567890123456789012",
1066              "span_id": "1234567890123456",
1067              "op": "gen_ai.invoke_agent",
1068              "data": {
1069                "gen_ai.operation.name": "gen_ai.invoke_agent",
1070                "gen_ai.operation.type": "agent",
1071                "gen_ai.usage.input_tokens": 500,
1072                "gen_ai.usage.output_tokens": 200,
1073                "gen_ai.usage.total_tokens": 700.0
1074              },
1075              "type": "trace"
1076            }
1077          },
1078          "spans": [
1079            {
1080              "timestamp": 1234567890.5,
1081              "start_timestamp": 1234567889.5,
1082              "op": "gen_ai.chat.completions",
1083              "span_id": "1234567890123457",
1084              "data": {
1085                "gen_ai.operation.name": "chat",
1086                "gen_ai.operation.type": "other",
1087                "gen_ai.usage.input_tokens": 100,
1088                "gen_ai.usage.output_tokens": 50,
1089                "gen_ai.usage.total_tokens": 150.0
1090              }
1091            }
1092          ]
1093        }
1094        "#);
1095    }
1096
1097    /// Test enrich_ai_event_data with non-AI trace context, invoke_agent parent span, and chat child span.
1098    #[test]
1099    fn test_enrich_ai_event_data_nested_agent_and_chat_spans() {
1100        let event_json = r#"{
1101            "type": "transaction",
1102            "timestamp": 1234567892.0,
1103            "start_timestamp": 1234567889.0,
1104            "contexts": {
1105                "trace": {
1106                    "op": "http.server",
1107                    "trace_id": "12345678901234567890123456789012",
1108                    "span_id": "1234567890123456"
1109                }
1110            },
1111            "spans": [
1112                {
1113                    "op": "gen_ai.invoke_agent",
1114                    "span_id": "1234567890123457",
1115                    "parent_span_id": "1234567890123456",
1116                    "start_timestamp": 1234567889.5,
1117                    "timestamp": 1234567891.5,
1118                    "data": {
1119                        "gen_ai.operation.name": "invoke_agent",
1120                        "gen_ai.usage.input_tokens": 500,
1121                        "gen_ai.usage.output_tokens": 200
1122                    }
1123                },
1124                {
1125                    "op": "gen_ai.chat.completions",
1126                    "span_id": "1234567890123458",
1127                    "parent_span_id": "1234567890123457",
1128                    "start_timestamp": 1234567890.0,
1129                    "timestamp": 1234567891.0,
1130                    "data": {
1131                        "gen_ai.operation.name": "chat",
1132                        "gen_ai.usage.input_tokens": 100,
1133                        "gen_ai.usage.output_tokens": 50
1134                    }
1135                }
1136            ]
1137        }"#;
1138
1139        let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1140        let event = annotated_event.value_mut().as_mut().unwrap();
1141
1142        enrich_ai_event_data(event, None);
1143
1144        assert_annotated_snapshot!(&annotated_event, @r#"
1145        {
1146          "type": "transaction",
1147          "timestamp": 1234567892.0,
1148          "start_timestamp": 1234567889.0,
1149          "contexts": {
1150            "trace": {
1151              "trace_id": "12345678901234567890123456789012",
1152              "span_id": "1234567890123456",
1153              "op": "http.server",
1154              "type": "trace"
1155            }
1156          },
1157          "spans": [
1158            {
1159              "timestamp": 1234567891.5,
1160              "start_timestamp": 1234567889.5,
1161              "op": "gen_ai.invoke_agent",
1162              "span_id": "1234567890123457",
1163              "parent_span_id": "1234567890123456",
1164              "data": {
1165                "gen_ai.operation.name": "invoke_agent",
1166                "gen_ai.operation.type": "agent",
1167                "gen_ai.usage.input_tokens": 500,
1168                "gen_ai.usage.output_tokens": 200,
1169                "gen_ai.usage.total_tokens": 700.0
1170              }
1171            },
1172            {
1173              "timestamp": 1234567891.0,
1174              "start_timestamp": 1234567890.0,
1175              "op": "gen_ai.chat.completions",
1176              "span_id": "1234567890123458",
1177              "parent_span_id": "1234567890123457",
1178              "data": {
1179                "gen_ai.operation.name": "chat",
1180                "gen_ai.operation.type": "other",
1181                "gen_ai.usage.input_tokens": 100,
1182                "gen_ai.usage.output_tokens": 50,
1183                "gen_ai.usage.total_tokens": 150.0
1184              }
1185            }
1186          ]
1187        }
1188        "#);
1189    }
1190
1191    /// Test enrich_ai_event_data with legacy measurements and span op for operation type.
1192    #[test]
1193    fn test_enrich_ai_event_data_legacy_measurements_and_span_op() {
1194        let event_json = r#"{
1195            "type": "transaction",
1196            "timestamp": 1234567892.0,
1197            "start_timestamp": 1234567889.0,
1198            "contexts": {
1199                "trace": {
1200                    "op": "http.server",
1201                    "trace_id": "12345678901234567890123456789012",
1202                    "span_id": "1234567890123456"
1203                }
1204            },
1205            "spans": [
1206                {
1207                    "op": "gen_ai.invoke_agent",
1208                    "span_id": "1234567890123457",
1209                    "parent_span_id": "1234567890123456",
1210                    "start_timestamp": 1234567889.5,
1211                    "timestamp": 1234567891.5,
1212                    "measurements": {
1213                        "ai_prompt_tokens_used": {"value": 500.0},
1214                        "ai_completion_tokens_used": {"value": 200.0}
1215                    }
1216                },
1217                {
1218                    "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1219                    "span_id": "1234567890123458",
1220                    "parent_span_id": "1234567890123457",
1221                    "start_timestamp": 1234567890.0,
1222                    "timestamp": 1234567891.0,
1223                    "measurements": {
1224                        "ai_prompt_tokens_used": {"value": 100.0},
1225                        "ai_completion_tokens_used": {"value": 50.0}
1226                    }
1227                }
1228            ]
1229        }"#;
1230
1231        let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1232        let event = annotated_event.value_mut().as_mut().unwrap();
1233
1234        enrich_ai_event_data(event, None);
1235
1236        assert_annotated_snapshot!(&annotated_event, @r#"
1237        {
1238          "type": "transaction",
1239          "timestamp": 1234567892.0,
1240          "start_timestamp": 1234567889.0,
1241          "contexts": {
1242            "trace": {
1243              "trace_id": "12345678901234567890123456789012",
1244              "span_id": "1234567890123456",
1245              "op": "http.server",
1246              "type": "trace"
1247            }
1248          },
1249          "spans": [
1250            {
1251              "timestamp": 1234567891.5,
1252              "start_timestamp": 1234567889.5,
1253              "op": "gen_ai.invoke_agent",
1254              "span_id": "1234567890123457",
1255              "parent_span_id": "1234567890123456",
1256              "data": {
1257                "gen_ai.operation.type": "agent",
1258                "gen_ai.usage.input_tokens": 500.0,
1259                "gen_ai.usage.output_tokens": 200.0,
1260                "gen_ai.usage.total_tokens": 700.0
1261              },
1262              "measurements": {
1263                "ai_completion_tokens_used": {
1264                  "value": 200.0
1265                },
1266                "ai_prompt_tokens_used": {
1267                  "value": 500.0
1268                }
1269              }
1270            },
1271            {
1272              "timestamp": 1234567891.0,
1273              "start_timestamp": 1234567890.0,
1274              "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1275              "span_id": "1234567890123458",
1276              "parent_span_id": "1234567890123457",
1277              "data": {
1278                "gen_ai.operation.type": "other",
1279                "gen_ai.usage.input_tokens": 100.0,
1280                "gen_ai.usage.output_tokens": 50.0,
1281                "gen_ai.usage.total_tokens": 150.0
1282              },
1283              "measurements": {
1284                "ai_completion_tokens_used": {
1285                  "value": 50.0
1286                },
1287                "ai_prompt_tokens_used": {
1288                  "value": 100.0
1289                }
1290              }
1291            }
1292          ]
1293        }
1294        "#);
1295    }
1296
1297    fn metadata_with_context_size() -> ModelMetadata {
1298        ModelMetadata {
1299            version: 1,
1300            models: HashMap::from([(
1301                "claude-2.1".parse().unwrap(),
1302                ModelMetadataEntry {
1303                    costs: Some(ModelCostV2 {
1304                        input_per_token: 0.01,
1305                        output_per_token: 0.02,
1306                        output_reasoning_per_token: 0.0,
1307                        input_cached_per_token: 0.0,
1308                        input_cache_write_per_token: 0.0,
1309                    }),
1310                    context_size: Some(100_000),
1311                },
1312            )]),
1313        }
1314    }
1315
1316    #[test]
1317    fn test_context_utilization_with_total_tokens() {
1318        let mut span = Span {
1319            op: "gen_ai.test".to_owned().into(),
1320            data: SpanData::from_value(
1321                json!({
1322                    "gen_ai.response.model": "claude-2.1",
1323                    "gen_ai.usage.input_tokens": 30000.0,
1324                    "gen_ai.usage.output_tokens": 12000.0,
1325                    "gen_ai.usage.total_tokens": 42000.0,
1326                })
1327                .into(),
1328            ),
1329            ..Default::default()
1330        };
1331
1332        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1333
1334        let data = span.data.value().unwrap();
1335        assert_eq!(
1336            data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1337                .and_then(Value::as_f64),
1338            Some(100_000.0)
1339        );
1340        assert_eq!(
1341            data.get_value(GEN_AI__CONTEXT__UTILIZATION)
1342                .and_then(Value::as_f64),
1343            Some(0.42)
1344        );
1345    }
1346
1347    #[test]
1348    fn test_context_utilization_no_context_size() {
1349        let metadata = ModelMetadata {
1350            version: 1,
1351            models: HashMap::from([(
1352                "claude-2.1".parse().unwrap(),
1353                ModelMetadataEntry {
1354                    costs: None,
1355                    context_size: None,
1356                },
1357            )]),
1358        };
1359
1360        let mut span = Span {
1361            op: "gen_ai.test".to_owned().into(),
1362            data: SpanData::from_value(
1363                json!({
1364                    "gen_ai.response.model": "claude-2.1",
1365                    "gen_ai.usage.total_tokens": 1000.0,
1366                })
1367                .into(),
1368            ),
1369            ..Default::default()
1370        };
1371
1372        enrich_ai_span(&mut span, Some(&metadata));
1373
1374        let data = span.data.value().unwrap();
1375        assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1376        assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1377    }
1378
1379    #[test]
1380    fn test_context_utilization_no_total_tokens() {
1381        let mut span = Span {
1382            op: "gen_ai.test".to_owned().into(),
1383            data: SpanData::from_value(
1384                json!({
1385                    "gen_ai.response.model": "claude-2.1",
1386                })
1387                .into(),
1388            ),
1389            ..Default::default()
1390        };
1391
1392        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1393
1394        let data = span.data.value().unwrap();
1395        // window_size should still be set even without tokens.
1396        assert_eq!(
1397            data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1398                .and_then(Value::as_f64),
1399            Some(100_000.0)
1400        );
1401        // But utilization cannot be computed without total_tokens.
1402        assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1403    }
1404
1405    #[test]
1406    fn test_context_utilization_unknown_model() {
1407        let mut span = Span {
1408            op: "gen_ai.test".to_owned().into(),
1409            data: SpanData::from_value(
1410                json!({
1411                    "gen_ai.response.model": "unknown-model",
1412                    "gen_ai.usage.total_tokens": 1000.0,
1413                })
1414                .into(),
1415            ),
1416            ..Default::default()
1417        };
1418
1419        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1420
1421        let data = span.data.value().unwrap();
1422        assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1423        assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1424    }
1425}