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