relay_event_normalization/normalize/span/
ai.rs1use crate::{ModelCostV2, ModelCosts};
4use relay_event_schema::protocol::{Event, Span, SpanData};
5use relay_protocol::{Annotated, Getter, Value};
6
7fn calculate_ai_model_cost(model_cost: Option<ModelCostV2>, data: &SpanData) -> Option<f64> {
10 let cost_per_token = model_cost?;
11 let input_tokens_used = data
12 .gen_ai_usage_input_tokens
13 .value()
14 .and_then(Value::as_f64);
15
16 let output_tokens_used = data
17 .gen_ai_usage_output_tokens
18 .value()
19 .and_then(Value::as_f64);
20 let output_reasoning_tokens_used = data
21 .gen_ai_usage_output_tokens_reasoning
22 .value()
23 .and_then(Value::as_f64);
24 let input_cached_tokens_used = data
25 .gen_ai_usage_input_tokens_cached
26 .value()
27 .and_then(Value::as_f64);
28
29 if input_tokens_used.is_none() && output_tokens_used.is_none() {
30 return None;
31 }
32
33 let mut result = 0.0;
34
35 result += cost_per_token.input_per_token * input_tokens_used.unwrap_or(0.0);
36 result += cost_per_token.output_per_token * output_tokens_used.unwrap_or(0.0);
37 result +=
38 cost_per_token.output_reasoning_per_token * output_reasoning_tokens_used.unwrap_or(0.0);
39 result += cost_per_token.input_cached_per_token * input_cached_tokens_used.unwrap_or(0.0);
40
41 Some(result)
42}
43
44pub fn map_ai_measurements_to_data(span: &mut Span) {
46 if !is_ai_span(span) {
47 return;
48 };
49
50 let measurements = span.measurements.value();
51 let data = span.data.get_or_insert_with(SpanData::default);
52
53 let set_field_from_measurement = |target_field: &mut Annotated<Value>,
54 measurement_key: &str| {
55 if let Some(measurements) = measurements {
56 if target_field.value().is_none() {
57 if let Some(value) = measurements.get_value(measurement_key) {
58 target_field.set_value(Value::F64(value.to_f64()).into());
59 }
60 }
61 }
62 };
63
64 set_field_from_measurement(&mut data.gen_ai_usage_total_tokens, "ai_total_tokens_used");
65 set_field_from_measurement(&mut data.gen_ai_usage_input_tokens, "ai_prompt_tokens_used");
66 set_field_from_measurement(
67 &mut data.gen_ai_usage_output_tokens,
68 "ai_completion_tokens_used",
69 );
70
71 if data.gen_ai_usage_total_tokens.value().is_none() {
73 let input_tokens = data
74 .gen_ai_usage_input_tokens
75 .value()
76 .and_then(Value::as_f64);
77 let output_tokens = data
78 .gen_ai_usage_output_tokens
79 .value()
80 .and_then(Value::as_f64);
81
82 if input_tokens.is_none() && output_tokens.is_none() {
83 return;
85 }
86
87 data.gen_ai_usage_total_tokens.set_value(
88 Value::F64(input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0)).into(),
89 );
90 }
91}
92
93pub fn extract_ai_data(span: &mut Span, ai_model_costs: &ModelCosts) {
95 if !is_ai_span(span) {
96 return;
97 }
98
99 let duration = span
100 .get_value("span.duration")
101 .and_then(|v| v.as_f64())
102 .unwrap_or(0.0);
103
104 let Some(data) = span.data.value_mut() else {
105 return;
106 };
107
108 if data.gen_ai_response_tokens_per_second.value().is_none() && duration > 0.0 {
110 if let Some(output_tokens) = data
111 .gen_ai_usage_output_tokens
112 .value()
113 .and_then(Value::as_f64)
114 {
115 data.gen_ai_response_tokens_per_second
116 .set_value(Value::F64(output_tokens / (duration / 1000.0)).into());
117 }
118 }
119
120 if let Some(model_id) = data
122 .gen_ai_request_model
123 .value()
124 .and_then(|val| val.as_str())
125 .or_else(|| data.ai_model_id.value().and_then(|val| val.as_str()))
128 {
129 if let Some(total_cost) =
130 calculate_ai_model_cost(ai_model_costs.cost_per_token(model_id), data)
131 {
132 data.gen_ai_usage_total_cost
133 .set_value(Value::F64(total_cost).into());
134 }
135 }
136}
137
138pub fn enrich_ai_span_data(event: &mut Event, model_costs: Option<&ModelCosts>) {
140 let spans = event.spans.value_mut().iter_mut().flatten();
141 let spans = spans.filter_map(|span| span.value_mut().as_mut());
142
143 for span in spans {
144 map_ai_measurements_to_data(span);
145 if let Some(model_costs) = model_costs {
146 extract_ai_data(span, model_costs);
147 }
148 }
149}
150
151pub fn is_ai_span(span: &Span) -> bool {
154 span.op
155 .value()
156 .is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."))
157}