Skip to main content

poolsim_core/
otlp.rs

1//! OpenTelemetry metric payload helpers.
2//!
3//! This module converts OTLP-style JSON metric exports into Poolsim workload
4//! inputs. It is shared by the CLI and web crates so OTLP metric-name handling,
5//! numeric datapoint extraction, and error reporting stay consistent.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{error::PoolsimError, types::WorkloadConfig};
11
12/// Default request-rate metric name used by OTLP import helpers.
13pub const DEFAULT_RPS_METRIC: &str = "poolsim.rps";
14/// Default p50 latency metric name used by OTLP import helpers.
15pub const DEFAULT_P50_METRIC: &str = "poolsim.latency.p50_ms";
16/// Default p95 latency metric name used by OTLP import helpers.
17pub const DEFAULT_P95_METRIC: &str = "poolsim.latency.p95_ms";
18/// Default p99 latency metric name used by OTLP import helpers.
19pub const DEFAULT_P99_METRIC: &str = "poolsim.latency.p99_ms";
20
21fn default_rps_metric() -> String {
22    DEFAULT_RPS_METRIC.to_string()
23}
24
25fn default_p50_metric() -> String {
26    DEFAULT_P50_METRIC.to_string()
27}
28
29fn default_p95_metric() -> String {
30    DEFAULT_P95_METRIC.to_string()
31}
32
33fn default_p99_metric() -> String {
34    DEFAULT_P99_METRIC.to_string()
35}
36
37/// Metric-name mapping used to extract Poolsim workload values from OTLP JSON.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct OtlpMetricNames {
40    /// Metric containing requests per second.
41    #[serde(default = "default_rps_metric")]
42    pub rps_metric: String,
43    /// Metric containing p50 latency in milliseconds.
44    #[serde(default = "default_p50_metric")]
45    pub p50_metric: String,
46    /// Metric containing p95 latency in milliseconds.
47    #[serde(default = "default_p95_metric")]
48    pub p95_metric: String,
49    /// Metric containing p99 latency in milliseconds.
50    #[serde(default = "default_p99_metric")]
51    pub p99_metric: String,
52}
53
54impl Default for OtlpMetricNames {
55    fn default() -> Self {
56        Self {
57            rps_metric: default_rps_metric(),
58            p50_metric: default_p50_metric(),
59            p95_metric: default_p95_metric(),
60            p99_metric: default_p99_metric(),
61        }
62    }
63}
64
65/// Builds a workload from OTLP JSON metrics.
66///
67/// # Errors
68///
69/// Returns [`PoolsimError::InvalidInput`] when any configured metric is missing
70/// or does not contain a numeric datapoint.
71pub fn workload_from_otlp_json(
72    root: &Value,
73    names: &OtlpMetricNames,
74) -> Result<WorkloadConfig, PoolsimError> {
75    Ok(WorkloadConfig {
76        requests_per_second: metric_value(root, &names.rps_metric)?,
77        latency_p50_ms: metric_value(root, &names.p50_metric)?,
78        latency_p95_ms: metric_value(root, &names.p95_metric)?,
79        latency_p99_ms: metric_value(root, &names.p99_metric)?,
80        raw_samples_ms: None,
81        step_load_profile: None,
82    })
83}
84
85/// Extracts the first numeric datapoint from a named OTLP metric.
86///
87/// # Errors
88///
89/// Returns [`PoolsimError::InvalidInput`] when the metric is absent or no
90/// numeric datapoint can be found.
91pub fn metric_value(root: &Value, name: &str) -> Result<f64, PoolsimError> {
92    find_metric(root, name)
93        .and_then(first_numeric)
94        .ok_or_else(|| {
95            PoolsimError::invalid_input(
96                "OTLP_METRIC_NOT_FOUND",
97                format!("OTLP metric {name:?} was not found or had no numeric datapoint"),
98                Some(serde_json::json!({ "metric": name })),
99            )
100        })
101}
102
103fn find_metric<'a>(value: &'a Value, name: &str) -> Option<&'a Value> {
104    match value {
105        Value::Object(map) => {
106            if map.get("name").and_then(Value::as_str) == Some(name) {
107                return Some(value);
108            }
109            map.values().find_map(|child| find_metric(child, name))
110        }
111        Value::Array(items) => items.iter().find_map(|child| find_metric(child, name)),
112        _ => None,
113    }
114}
115
116fn first_numeric(value: &Value) -> Option<f64> {
117    match value {
118        Value::Number(number) => number.as_f64(),
119        Value::String(text) => text.parse().ok(),
120        Value::Object(map) => {
121            for key in ["asDouble", "asInt", "doubleValue", "intValue", "value"] {
122                if let Some(number) = map.get(key).and_then(first_numeric) {
123                    return Some(number);
124                }
125            }
126            for key in ["sum", "gauge", "histogram", "dataPoints"] {
127                if let Some(number) = map.get(key).and_then(first_numeric) {
128                    return Some(number);
129                }
130            }
131            map.values().find_map(first_numeric)
132        }
133        Value::Array(items) => items.iter().find_map(first_numeric),
134        _ => None,
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn fixture() -> Value {
143        serde_json::json!({
144            "resourceMetrics": [{
145                "scopeMetrics": [{
146                    "metrics": [
147                        {"name": "poolsim.rps", "sum": {"dataPoints": [{"asDouble": 180.0}]}},
148                        {"name": "poolsim.latency.p50_ms", "gauge": {"dataPoints": [{"asDouble": 8.0}]}},
149                        {"name": "poolsim.latency.p95_ms", "gauge": {"dataPoints": [{"asInt": "30"}]}},
150                        {"name": "poolsim.latency.p99_ms", "gauge": {"dataPoints": [{"value": 70.0}]}}
151                    ]
152                }]
153            }]
154        })
155    }
156
157    #[test]
158    fn extracts_workload_from_otlp_json() {
159        let workload = workload_from_otlp_json(&fixture(), &OtlpMetricNames::default())
160            .expect("OTLP workload should resolve");
161        assert_eq!(workload.requests_per_second, 180.0);
162        assert_eq!(workload.latency_p95_ms, 30.0);
163        assert_eq!(workload.latency_p99_ms, 70.0);
164    }
165
166    #[test]
167    fn reports_missing_metric_with_stable_error_code() {
168        let err = metric_value(&serde_json::json!({ "metrics": [] }), "missing")
169            .expect_err("missing metric should fail");
170        assert_eq!(err.code(), "OTLP_METRIC_NOT_FOUND");
171    }
172}