1use 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#[derive(Debug, Error)]
18pub enum MetricsError {
19 #[error("failed to register prometheus metric: {0}")]
21 Register(#[from] prometheus::Error),
22 #[error("failed to encode prometheus metrics: {0}")]
24 Encode(String),
25}
26
27#[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 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 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 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 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 pub fn workflow_reopened(&self, namespace: &str) {
134 self.inner
135 .workflows_reopened
136 .with_label_values(&[namespace])
137 .inc();
138 }
139
140 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 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 pub fn activity_abandoned(&self, namespace: &str) {
176 self.inner
177 .inflight_activities
178 .with_label_values(&[namespace])
179 .dec();
180 }
181
182 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 pub fn worker_connected(&self, namespace: &str) {
192 self.inner
193 .connected_workers
194 .with_label_values(&[namespace])
195 .inc();
196 }
197
198 pub fn worker_disconnected(&self, namespace: &str) {
200 self.inner
201 .connected_workers
202 .with_label_values(&[namespace])
203 .dec();
204 }
205
206 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 pub fn schedule_fired(&self, namespace: &str) {
216 self.inner
217 .schedules_fired
218 .with_label_values(&[namespace])
219 .inc();
220 }
221
222 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 pub fn deploy_denied(&self, transport: &str) {
232 self.inner
233 .deploy_denied
234 .with_label_values(&[transport])
235 .inc();
236 }
237
238 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 #[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 #[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 #[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
388fn 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
415fn 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
428pub 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}