Skip to main content

duroxide_pg_opt/
db_metrics.rs

1//! Database metrics instrumentation module.
2//!
3//! This module provides zero-cost instrumentation for database operations.
4//! When the `db-metrics` feature is disabled, all metrics calls compile to nothing.
5//!
6//! # Usage
7//!
8//! ```rust,ignore
9//! use crate::db_metrics::{record_db_call, DbOperation};
10//!
11//! // Record a stored procedure call
12//! record_db_call(DbOperation::StoredProcedure, Some("fetch_orchestration_item"));
13//!
14//! // Record a SELECT query
15//! record_db_call(DbOperation::Select, None);
16//!
17//! // Record fetch effectiveness
18//! record_fetch_attempt(FetchType::Orchestration);
19//! record_fetch_success(FetchType::Orchestration, 1); // 1 item fetched
20//! ```
21//!
22//! # Metrics Exported
23//!
24//! When enabled, the following metrics are recorded:
25//!
26//! - `duroxide.db.calls` (counter): Total database calls by operation type
27//!   - Labels: `operation` (sp_call, select, insert, update, delete, ddl)
28//! - `duroxide.db.sp_calls` (counter): Stored procedure calls by name
29//!   - Labels: `sp_name`
30//! - `duroxide.db.call_duration_ms` (histogram): Duration of database calls
31//!   - Labels: `operation`, `sp_name` (optional)
32//! - `duroxide.fetch.attempts` (counter): Number of fetch attempts
33//!   - Labels: `fetch_type` (orchestration, work_item)
34//! - `duroxide.fetch.items` (counter): Number of items successfully fetched
35//!   - Labels: `fetch_type` (orchestration, work_item)
36//! - `duroxide.fetch.loaded` (counter): Number of fetches that returned items
37//!   - Labels: `fetch_type` (orchestration, work_item)
38//! - `duroxide.fetch.empty` (counter): Number of fetches that returned no items
39//!   - Labels: `fetch_type` (orchestration, work_item)
40//! - `duroxide.fetch.loaded_duration_ms` (histogram): Duration of fetches that returned items
41//!   - Labels: `fetch_type` (orchestration, work_item)
42//! - `duroxide.fetch.empty_duration_ms` (histogram): Duration of fetches that returned no items
43//!   - Labels: `fetch_type` (orchestration, work_item)
44
45/// Types of database operations for metrics classification.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DbOperation {
48    /// Stored procedure call (SELECT schema.sp_name(...))
49    StoredProcedure,
50    /// SELECT query (non-SP)
51    Select,
52    /// INSERT statement
53    Insert,
54    /// UPDATE statement
55    Update,
56    /// DELETE statement
57    Delete,
58    /// DDL operations (CREATE, DROP, ALTER)
59    Ddl,
60}
61
62impl DbOperation {
63    /// Returns the string label for this operation type.
64    #[inline]
65    pub const fn as_str(&self) -> &'static str {
66        match self {
67            DbOperation::StoredProcedure => "sp_call",
68            DbOperation::Select => "select",
69            DbOperation::Insert => "insert",
70            DbOperation::Update => "update",
71            DbOperation::Delete => "delete",
72            DbOperation::Ddl => "ddl",
73        }
74    }
75}
76
77/// Types of fetch operations for long-poll effectiveness metrics.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum FetchType {
80    /// Orchestration item fetch (orchestrator dispatcher)
81    Orchestration,
82    /// Work item fetch (activity worker dispatcher)
83    WorkItem,
84}
85
86impl FetchType {
87    /// Returns the string label for this fetch type.
88    #[inline]
89    pub const fn as_str(&self) -> &'static str {
90        match self {
91            FetchType::Orchestration => "orchestration",
92            FetchType::WorkItem => "work_item",
93        }
94    }
95}
96
97/// Record a database call. Zero-cost when `db-metrics` feature is disabled.
98///
99/// # Arguments
100///
101/// * `operation` - The type of database operation
102/// * `sp_name` - Optional stored procedure name (only for StoredProcedure operations)
103#[cfg(feature = "db-metrics")]
104#[inline]
105pub fn record_db_call(operation: DbOperation, sp_name: Option<&str>) {
106    use metrics::counter;
107
108    // Record the operation counter
109    counter!("duroxide.db.calls", "operation" => operation.as_str()).increment(1);
110
111    // If it's a stored procedure, also record by SP name
112    if operation == DbOperation::StoredProcedure {
113        if let Some(name) = sp_name {
114            counter!("duroxide.db.sp_calls", "sp_name" => name.to_string()).increment(1);
115        }
116    }
117}
118
119/// Record a database call. Zero-cost no-op when `db-metrics` feature is disabled.
120#[cfg(not(feature = "db-metrics"))]
121#[inline(always)]
122pub fn record_db_call(_operation: DbOperation, _sp_name: Option<&str>) {
123    // Compiles to nothing when db-metrics feature is disabled
124}
125
126/// Record a fetch attempt. Zero-cost when `db-metrics` feature is disabled.
127///
128/// This should be called every time we attempt to fetch work (orchestration or work item),
129/// regardless of whether the fetch returns any items.
130///
131/// # Arguments
132///
133/// * `fetch_type` - The type of fetch operation (Orchestration or WorkItem)
134#[cfg(feature = "db-metrics")]
135#[inline]
136pub fn record_fetch_attempt(fetch_type: FetchType) {
137    use metrics::counter;
138    counter!("duroxide.fetch.attempts", "fetch_type" => fetch_type.as_str()).increment(1);
139}
140
141/// Record a fetch attempt. Zero-cost no-op when `db-metrics` feature is disabled.
142#[cfg(not(feature = "db-metrics"))]
143#[inline(always)]
144pub fn record_fetch_attempt(_fetch_type: FetchType) {
145    // Compiles to nothing when db-metrics feature is disabled
146}
147
148/// Record successful fetch(es). Zero-cost when `db-metrics` feature is disabled.
149///
150/// This should be called when a fetch returns actual items. The `count` parameter
151/// allows tracking batch fetches where multiple items are returned.
152///
153/// # Arguments
154///
155/// * `fetch_type` - The type of fetch operation (Orchestration or WorkItem)
156/// * `count` - Number of items successfully fetched (typically 1, but can be > 1 for batches)
157#[cfg(feature = "db-metrics")]
158#[inline]
159pub fn record_fetch_success(fetch_type: FetchType, count: u64) {
160    use metrics::counter;
161    counter!("duroxide.fetch.items", "fetch_type" => fetch_type.as_str()).increment(count);
162}
163
164/// Record successful fetch(es). Zero-cost no-op when `db-metrics` feature is disabled.
165#[cfg(not(feature = "db-metrics"))]
166#[inline(always)]
167pub fn record_fetch_success(_fetch_type: FetchType, _count: u64) {
168    // Compiles to nothing when db-metrics feature is disabled
169}
170
171/// Record a fetch result with timing. Zero-cost when `db-metrics` feature is disabled.
172///
173/// This is the preferred way to record fetch metrics as it separates "loaded" fetches
174/// (which return items) from "empty" fetches (which return nothing). This distinction
175/// is important because:
176/// - Empty fetches typically execute much faster (no rows to lock/serialize)
177/// - Loaded fetches have row locking, serialization, and data transfer overhead
178/// - Averaging them together skews performance analysis
179///
180/// # Arguments
181///
182/// * `fetch_type` - The type of fetch operation (Orchestration or WorkItem)
183/// * `items_fetched` - Number of items returned (0 for empty fetch)
184/// * `duration_ms` - Duration of the fetch operation in milliseconds
185#[cfg(feature = "db-metrics")]
186#[inline]
187pub fn record_fetch_result(fetch_type: FetchType, items_fetched: u64, duration_ms: f64) {
188    use metrics::{counter, histogram};
189
190    // Always record the attempt
191    counter!("duroxide.fetch.attempts", "fetch_type" => fetch_type.as_str()).increment(1);
192
193    if items_fetched > 0 {
194        // Loaded fetch - got items
195        counter!("duroxide.fetch.items", "fetch_type" => fetch_type.as_str())
196            .increment(items_fetched);
197        counter!("duroxide.fetch.loaded", "fetch_type" => fetch_type.as_str()).increment(1);
198        histogram!("duroxide.fetch.loaded_duration_ms", "fetch_type" => fetch_type.as_str())
199            .record(duration_ms);
200    } else {
201        // Empty fetch - no items
202        counter!("duroxide.fetch.empty", "fetch_type" => fetch_type.as_str()).increment(1);
203        histogram!("duroxide.fetch.empty_duration_ms", "fetch_type" => fetch_type.as_str())
204            .record(duration_ms);
205    }
206}
207
208/// Record a fetch result with timing. Zero-cost no-op when `db-metrics` feature is disabled.
209#[cfg(not(feature = "db-metrics"))]
210#[inline(always)]
211pub fn record_fetch_result(_fetch_type: FetchType, _items_fetched: u64, _duration_ms: f64) {
212    // Compiles to nothing when db-metrics feature is disabled
213}
214
215/// Record a database call with duration. Zero-cost when `db-metrics` feature is disabled.
216///
217/// This function records both the call counter and the duration histogram in one call.
218/// Use this when you have the duration already computed (e.g., from a manual timer).
219///
220/// # Arguments
221///
222/// * `operation` - The type of database operation
223/// * `sp_name` - Optional stored procedure name (only for StoredProcedure operations)
224/// * `duration_ms` - Duration of the database call in milliseconds
225#[cfg(feature = "db-metrics")]
226#[inline]
227pub fn record_db_call_with_duration(
228    operation: DbOperation,
229    sp_name: Option<&str>,
230    duration_ms: f64,
231) {
232    use metrics::{counter, histogram};
233
234    // Record the operation counter
235    counter!("duroxide.db.calls", "operation" => operation.as_str()).increment(1);
236
237    // If it's a stored procedure, also record by SP name
238    if operation == DbOperation::StoredProcedure {
239        if let Some(name) = sp_name {
240            counter!("duroxide.db.sp_calls", "sp_name" => name.to_string()).increment(1);
241            histogram!(
242                "duroxide.db.call_duration_ms",
243                "operation" => operation.as_str(),
244                "sp_name" => name.to_string()
245            )
246            .record(duration_ms);
247        } else {
248            histogram!(
249                "duroxide.db.call_duration_ms",
250                "operation" => operation.as_str()
251            )
252            .record(duration_ms);
253        }
254    } else {
255        histogram!(
256            "duroxide.db.call_duration_ms",
257            "operation" => operation.as_str()
258        )
259        .record(duration_ms);
260    }
261}
262
263/// Record a database call with duration. Zero-cost no-op when `db-metrics` feature is disabled.
264#[cfg(not(feature = "db-metrics"))]
265#[inline(always)]
266pub fn record_db_call_with_duration(
267    _operation: DbOperation,
268    _sp_name: Option<&str>,
269    _duration_ms: f64,
270) {
271    // Compiles to nothing when db-metrics feature is disabled
272}
273
274/// Guard for timing database operations. Zero-cost when `db-metrics` feature is disabled.
275///
276/// Usage:
277/// ```rust,ignore
278/// let _guard = DbCallTimer::new(DbOperation::StoredProcedure, Some("fetch_work_item"));
279/// // ... execute query ...
280/// // Timer automatically records duration when dropped
281/// ```
282#[cfg(feature = "db-metrics")]
283pub struct DbCallTimer {
284    operation: DbOperation,
285    sp_name: Option<&'static str>,
286    start: std::time::Instant,
287}
288
289#[cfg(feature = "db-metrics")]
290impl DbCallTimer {
291    /// Create a new timer for a database operation.
292    #[inline]
293    pub fn new(operation: DbOperation, sp_name: Option<&'static str>) -> Self {
294        Self {
295            operation,
296            sp_name,
297            start: std::time::Instant::now(),
298        }
299    }
300}
301
302#[cfg(feature = "db-metrics")]
303impl Drop for DbCallTimer {
304    fn drop(&mut self) {
305        let duration_ms = self.start.elapsed().as_secs_f64() * 1000.0;
306        // Use record_db_call_with_duration to record both counter and histogram
307        record_db_call_with_duration(self.operation, self.sp_name, duration_ms);
308    }
309}
310
311/// Zero-cost timer stub when `db-metrics` feature is disabled.
312#[cfg(not(feature = "db-metrics"))]
313pub struct DbCallTimer;
314
315#[cfg(not(feature = "db-metrics"))]
316impl DbCallTimer {
317    /// Create a no-op timer (compiles to nothing).
318    #[inline(always)]
319    pub fn new(_operation: DbOperation, _sp_name: Option<&'static str>) -> Self {
320        Self
321    }
322}
323
324/// Macro for convenient instrumentation of a database call block.
325/// Zero-cost when `db-metrics` feature is disabled.
326///
327/// # Example
328///
329/// ```rust,ignore
330/// let result = instrument_db_call!(StoredProcedure, "fetch_work_item", {
331///     sqlx::query("SELECT schema.fetch_work_item($1)")
332///         .bind(worker_id)
333///         .fetch_optional(&*self.pool)
334///         .await
335/// });
336/// ```
337#[macro_export]
338macro_rules! instrument_db_call {
339    ($op:ident, $sp_name:expr, $body:expr) => {{
340        #[cfg(feature = "db-metrics")]
341        {
342            let _timer = $crate::db_metrics::DbCallTimer::new(
343                $crate::db_metrics::DbOperation::$op,
344                Some($sp_name),
345            );
346            $crate::db_metrics::record_db_call(
347                $crate::db_metrics::DbOperation::$op,
348                Some($sp_name),
349            );
350            $body
351        }
352        #[cfg(not(feature = "db-metrics"))]
353        {
354            $body
355        }
356    }};
357    ($op:ident, $body:expr) => {{
358        #[cfg(feature = "db-metrics")]
359        {
360            let _timer =
361                $crate::db_metrics::DbCallTimer::new($crate::db_metrics::DbOperation::$op, None);
362            $crate::db_metrics::record_db_call($crate::db_metrics::DbOperation::$op, None);
363            $body
364        }
365        #[cfg(not(feature = "db-metrics"))]
366        {
367            $body
368        }
369    }};
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_db_operation_as_str() {
378        assert_eq!(DbOperation::StoredProcedure.as_str(), "sp_call");
379        assert_eq!(DbOperation::Select.as_str(), "select");
380        assert_eq!(DbOperation::Insert.as_str(), "insert");
381        assert_eq!(DbOperation::Update.as_str(), "update");
382        assert_eq!(DbOperation::Delete.as_str(), "delete");
383        assert_eq!(DbOperation::Ddl.as_str(), "ddl");
384    }
385
386    #[test]
387    fn test_record_db_call_compiles() {
388        // This test just ensures the functions compile and can be called
389        record_db_call(DbOperation::StoredProcedure, Some("test_sp"));
390        record_db_call(DbOperation::Select, None);
391    }
392
393    #[test]
394    fn test_timer_compiles() {
395        let _timer = DbCallTimer::new(DbOperation::Select, None);
396        let _timer2 = DbCallTimer::new(DbOperation::StoredProcedure, Some("test_sp"));
397    }
398}