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}