Skip to main content

aion_server/observability/
metrics.rs

1//! Prometheus metrics registry and recording helpers.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use axum::http::header::CONTENT_TYPE;
7use axum::http::{HeaderValue, StatusCode};
8use axum::response::{IntoResponse, Response};
9use prometheus::{
10    Encoder, HistogramOpts, HistogramVec, IntCounterVec, IntGaugeVec, Opts, Registry, TextEncoder,
11};
12use thiserror::Error;
13
14const TEXT_FORMAT: &str = "text/plain; version=0.0.4; charset=utf-8";
15
16/// Prometheus registry construction or exposition error.
17#[derive(Debug, Error)]
18pub enum MetricsError {
19    /// A metric failed to register in the prometheus registry.
20    #[error("failed to register prometheus metric: {0}")]
21    Register(#[from] prometheus::Error),
22    /// The prometheus text encoder failed to encode gathered metric families.
23    #[error("failed to encode prometheus metrics: {0}")]
24    Encode(String),
25}
26
27/// Cloneable server metrics handle backed by a prometheus registry.
28#[derive(Clone, Debug)]
29pub struct Metrics {
30    inner: Arc<MetricsInner>,
31}
32
33#[derive(Debug)]
34struct MetricsInner {
35    registry: Registry,
36    workflows_started: IntCounterVec,
37    workflows_completed: IntCounterVec,
38    workflows_reopened: IntCounterVec,
39    activities_dispatched: IntCounterVec,
40    activities_completed: IntCounterVec,
41    activity_duration: HistogramVec,
42    store_operation_duration: HistogramVec,
43    connected_workers: IntGaugeVec,
44    inflight_activities: IntGaugeVec,
45    signals_delivered: IntCounterVec,
46    schedules_fired: IntCounterVec,
47    deploy_operations: IntCounterVec,
48    deploy_denied: IntCounterVec,
49    loaded_workflow_versions: IntGaugeVec,
50}
51
52impl MetricsInner {
53    fn register_collectors(&self) -> Result<(), prometheus::Error> {
54        self.registry
55            .register(Box::new(self.workflows_started.clone()))?;
56        self.registry
57            .register(Box::new(self.workflows_completed.clone()))?;
58        self.registry
59            .register(Box::new(self.workflows_reopened.clone()))?;
60        self.registry
61            .register(Box::new(self.activities_dispatched.clone()))?;
62        self.registry
63            .register(Box::new(self.activities_completed.clone()))?;
64        self.registry
65            .register(Box::new(self.activity_duration.clone()))?;
66        self.registry
67            .register(Box::new(self.store_operation_duration.clone()))?;
68        self.registry
69            .register(Box::new(self.connected_workers.clone()))?;
70        self.registry
71            .register(Box::new(self.inflight_activities.clone()))?;
72        self.registry
73            .register(Box::new(self.signals_delivered.clone()))?;
74        self.registry
75            .register(Box::new(self.schedules_fired.clone()))?;
76        self.registry
77            .register(Box::new(self.deploy_operations.clone()))?;
78        self.registry
79            .register(Box::new(self.deploy_denied.clone()))?;
80        self.registry
81            .register(Box::new(self.loaded_workflow_versions.clone()))?;
82        Ok(())
83    }
84}
85
86impl Metrics {
87    /// Construct the server metrics registry and register all exported metrics.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`MetricsError::Register`] if prometheus rejects a metric descriptor.
92    pub fn new() -> Result<Self, MetricsError> {
93        let inner = build_metrics_inner()?;
94        inner.register_collectors()?;
95        initialize_default_label_sets(&inner);
96        Ok(Self {
97            inner: Arc::new(inner),
98        })
99    }
100
101    /// Encode all currently gathered metrics in prometheus text exposition format.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`MetricsError::Encode`] if prometheus cannot encode gathered metrics.
106    pub fn encode(&self) -> Result<Vec<u8>, MetricsError> {
107        let encoder = TextEncoder::new();
108        let families = self.inner.registry.gather();
109        let mut buffer = Vec::new();
110        encoder
111            .encode(&families, &mut buffer)
112            .map_err(|error| MetricsError::Encode(error.to_string()))?;
113        Ok(buffer)
114    }
115
116    /// Increment the workflow-start counter.
117    pub fn workflow_started(&self, namespace: &str, workflow_type: &str) {
118        self.inner
119            .workflows_started
120            .with_label_values(&[namespace, workflow_type])
121            .inc();
122    }
123
124    /// Increment the workflow-terminal counter.
125    pub fn workflow_completed(&self, namespace: &str, status: &str) {
126        self.inner
127            .workflows_completed
128            .with_label_values(&[namespace, status])
129            .inc();
130    }
131
132    /// Increment the workflow-reopen counter when a failed run is reopened.
133    pub fn workflow_reopened(&self, namespace: &str) {
134        self.inner
135            .workflows_reopened
136            .with_label_values(&[namespace])
137            .inc();
138    }
139
140    /// Increment the activity-dispatch counter and in-flight gauge.
141    pub fn activity_dispatched(&self, namespace: &str, activity_type: &str) {
142        self.inner
143            .activities_dispatched
144            .with_label_values(&[namespace, activity_type])
145            .inc();
146        self.inner
147            .inflight_activities
148            .with_label_values(&[namespace])
149            .inc();
150    }
151
152    /// Increment the activity-completion counter, observe duration, and decrement in-flight gauge.
153    pub fn activity_completed(
154        &self,
155        namespace: &str,
156        activity_type: &str,
157        outcome: &str,
158        duration: Duration,
159    ) {
160        self.inner
161            .activities_completed
162            .with_label_values(&[namespace, outcome])
163            .inc();
164        self.inner
165            .activity_duration
166            .with_label_values(&[namespace, activity_type])
167            .observe(duration.as_secs_f64());
168        self.inner
169            .inflight_activities
170            .with_label_values(&[namespace])
171            .dec();
172    }
173
174    /// Decrement in-flight activity gauge when dispatch fails before a result can arrive.
175    pub fn activity_abandoned(&self, namespace: &str) {
176        self.inner
177            .inflight_activities
178            .with_label_values(&[namespace])
179            .dec();
180    }
181
182    /// Observe a store operation duration.
183    pub fn store_operation(&self, operation: &str, duration: Duration) {
184        self.inner
185            .store_operation_duration
186            .with_label_values(&[operation])
187            .observe(duration.as_secs_f64());
188    }
189
190    /// Increment connected worker gauge for a namespace.
191    pub fn worker_connected(&self, namespace: &str) {
192        self.inner
193            .connected_workers
194            .with_label_values(&[namespace])
195            .inc();
196    }
197
198    /// Decrement connected worker gauge for a namespace.
199    pub fn worker_disconnected(&self, namespace: &str) {
200        self.inner
201            .connected_workers
202            .with_label_values(&[namespace])
203            .dec();
204    }
205
206    /// Increment signal delivery counter.
207    pub fn signal_delivered(&self, namespace: &str, residency: &str) {
208        self.inner
209            .signals_delivered
210            .with_label_values(&[namespace, residency])
211            .inc();
212    }
213
214    /// Increment schedule-fired counter.
215    pub fn schedule_fired(&self, namespace: &str) {
216        self.inner
217            .schedules_fired
218            .with_label_values(&[namespace])
219            .inc();
220    }
221
222    /// Increment the deploy-operation counter for one mutation outcome.
223    pub fn deploy_operation(&self, operation: &str, outcome: &str) {
224        self.inner
225            .deploy_operations
226            .with_label_values(&[operation, outcome])
227            .inc();
228    }
229
230    /// Increment the deploy-denied counter for a transport.
231    pub fn deploy_denied(&self, transport: &str) {
232        self.inner
233            .deploy_denied
234            .with_label_values(&[transport])
235            .inc();
236    }
237
238    /// Set the loaded-version gauge for one workflow type from the
239    /// post-operation listing.
240    pub fn set_loaded_workflow_versions(&self, workflow_type: &str, count: i64) {
241        self.inner
242            .loaded_workflow_versions
243            .with_label_values(&[workflow_type])
244            .set(count);
245    }
246
247    /// Current value of the `aion_inflight_activities` gauge for a namespace.
248    #[cfg(test)]
249    pub(crate) fn inflight_activities_value(&self, namespace: &str) -> i64 {
250        self.inner
251            .inflight_activities
252            .with_label_values(&[namespace])
253            .get()
254    }
255
256    /// Current value of the `aion_activities_dispatched_total` counter for a
257    /// namespace and activity type.
258    #[cfg(test)]
259    pub(crate) fn activities_dispatched_value(&self, namespace: &str, activity_type: &str) -> u64 {
260        self.inner
261            .activities_dispatched
262            .with_label_values(&[namespace, activity_type])
263            .get()
264    }
265
266    /// Current value of the `aion_activities_completed_total` counter for a
267    /// namespace and outcome label.
268    #[cfg(test)]
269    pub(crate) fn activities_completed_value(&self, namespace: &str, outcome: &str) -> u64 {
270        self.inner
271            .activities_completed
272            .with_label_values(&[namespace, outcome])
273            .get()
274    }
275}
276
277fn build_workflow_metrics() -> Result<(IntCounterVec, IntCounterVec, IntCounterVec), MetricsError> {
278    let workflows_started = IntCounterVec::new(
279        Opts::new(
280            "aion_workflows_started_total",
281            "Total workflow executions started by namespace and workflow type.",
282        ),
283        &["namespace", "workflow_type"],
284    )?;
285    let workflows_completed = IntCounterVec::new(
286        Opts::new(
287            "aion_workflows_completed_total",
288            "Total workflow executions that reached a terminal status by namespace and status.",
289        ),
290        &["namespace", "status"],
291    )?;
292    let workflows_reopened = IntCounterVec::new(
293        Opts::new(
294            "aion_workflows_reopened_total",
295            "Total failed workflow runs reopened, by namespace.",
296        ),
297        &["namespace"],
298    )?;
299    Ok((workflows_started, workflows_completed, workflows_reopened))
300}
301
302fn build_metrics_inner() -> Result<MetricsInner, MetricsError> {
303    let registry = Registry::new();
304    let (workflows_started, workflows_completed, workflows_reopened) = build_workflow_metrics()?;
305    let activities_dispatched = IntCounterVec::new(
306        Opts::new(
307            "aion_activities_dispatched_total",
308            "Total activities dispatched to workers by namespace and activity type.",
309        ),
310        &["namespace", "activity_type"],
311    )?;
312    let activities_completed = IntCounterVec::new(
313        Opts::new(
314            "aion_activities_completed_total",
315            "Total activity results received by namespace and outcome.",
316        ),
317        &["namespace", "outcome"],
318    )?;
319    let activity_duration = HistogramVec::new(
320        HistogramOpts::new(
321            "aion_activity_duration_seconds",
322            "Wall-clock activity execution latency from dispatch to result by namespace and activity type.",
323        )
324        .buckets(vec![
325            0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0,
326        ]),
327        &["namespace", "activity_type"],
328    )?;
329    let store_operation_duration = HistogramVec::new(
330        HistogramOpts::new(
331            "aion_store_operation_duration_seconds",
332            "Store operation latency by operation.",
333        )
334        .buckets(vec![
335            0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
336        ]),
337        &["operation"],
338    )?;
339    let connected_workers = IntGaugeVec::new(
340        Opts::new(
341            "aion_connected_workers",
342            "Current connected worker streams by namespace.",
343        ),
344        &["namespace"],
345    )?;
346    let inflight_activities = IntGaugeVec::new(
347        Opts::new(
348            "aion_inflight_activities",
349            "Current dispatched activities awaiting worker completion by namespace.",
350        ),
351        &["namespace"],
352    )?;
353    let signals_delivered = IntCounterVec::new(
354        Opts::new(
355            "aion_signals_delivered_total",
356            "Total signals delivered by namespace and residency classification.",
357        ),
358        &["namespace", "residency"],
359    )?;
360    let schedules_fired = IntCounterVec::new(
361        Opts::new(
362            "aion_schedules_fired_total",
363            "Total schedule timer evaluations that started a workflow by namespace.",
364        ),
365        &["namespace"],
366    )?;
367    let (deploy_operations, deploy_denied, loaded_workflow_versions) = build_deploy_metrics()?;
368
369    Ok(MetricsInner {
370        registry,
371        workflows_started,
372        workflows_completed,
373        workflows_reopened,
374        activities_dispatched,
375        activities_completed,
376        activity_duration,
377        store_operation_duration,
378        connected_workers,
379        inflight_activities,
380        signals_delivered,
381        schedules_fired,
382        deploy_operations,
383        deploy_denied,
384        loaded_workflow_versions,
385    })
386}
387
388/// Deploy API collectors: mutation counter, denial counter, and the
389/// loaded-version gauge fed from the post-operation listing.
390fn build_deploy_metrics() -> Result<(IntCounterVec, IntCounterVec, IntGaugeVec), MetricsError> {
391    let deploy_operations = IntCounterVec::new(
392        Opts::new(
393            "aion_deploy_operations_total",
394            "Total deploy API mutations by operation and outcome class.",
395        ),
396        &["operation", "outcome"],
397    )?;
398    let deploy_denied = IntCounterVec::new(
399        Opts::new(
400            "aion_deploy_denied_total",
401            "Total deploy API authorization denials by transport.",
402        ),
403        &["transport"],
404    )?;
405    let loaded_workflow_versions = IntGaugeVec::new(
406        Opts::new(
407            "aion_loaded_workflow_versions",
408            "Currently loaded package versions per workflow type.",
409        ),
410        &["workflow_type"],
411    )?;
412    Ok((deploy_operations, deploy_denied, loaded_workflow_versions))
413}
414
415/// Pre-initialize known label sets so all metric families appear in the
416/// prometheus text output before any workflow or activity traffic occurs.
417fn initialize_default_label_sets(inner: &MetricsInner) {
418    for operation in ["append", "read_history", "list_active", "list_workflow_ids"] {
419        inner
420            .store_operation_duration
421            .with_label_values(&[operation]);
422    }
423    inner
424        .activity_duration
425        .with_label_values(&["default", "default"]);
426}
427
428/// Axum handler for `/metrics`.
429pub async fn metrics_handler(
430    axum::extract::State(metrics): axum::extract::State<Metrics>,
431) -> Response {
432    match metrics.encode() {
433        Ok(body) => {
434            let mut response = body.into_response();
435            response
436                .headers_mut()
437                .insert(CONTENT_TYPE, HeaderValue::from_static(TEXT_FORMAT));
438            response
439        }
440        Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
441    }
442}