Skip to content

Commit ecea914

Browse files
committed
feat: add signal metrics and actor lifetime statistics
Implements comprehensive telemetry for signals and actor lifecycles, completing most of Phase 2 (Production Readiness) of the telemetry enhancement roadmap. Signal Metrics (#5): - joerl_signals_sent_total{type}: Counter tracking sent signals (exit, down, stop, kill) - joerl_signals_received_total{type}: Counter tracking received signals - joerl_signals_ignored_total{type}: Counter for trapped/ignored signals - joerl_exit_signals_by_reason_total{reason}: Breakdown of exit signal reasons (normal, shutdown, killed, panic, custom) Actor Lifetime Statistics (#6): - joerl_actor_lifetime_seconds{type}: Histogram of actor lifetime durations for performance analysis - joerl_short_lived_actors_total{type}: Counter for actors living < 1s to identify restart loops Implementation Details: - Added SignalMetrics struct to telemetry module with signal tracking - Instrumented send_signal() to track all signal types and exit reasons - Instrumented actor message loop to track signal reception and trapping - Added spawn_time field to ActorEntry (conditional on telemetry feature) - Calculate lifetime duration in cleanup_actor() on actor termination - Automatically detect short-lived actors with < 1s lifetime threshold - Zero-cost when telemetry feature is disabled Documentation: - Updated TELEMETRY.md with signal and lifetime metrics tables - Added comprehensive PromQL query examples for: * Signal sending/receiving rates * Exit signal analysis by reason * Actor lifetime percentiles (p50, p95, p99) * Short-lived actor detection (restart loops) * Signal ignore ratio (trap_exit effectiveness) - Added alerts for restart loops and abnormal exit signals - Updated TODO.md marking tasks #5 and #6 as completed Testing: - All 65 unit tests pass - All 17 integration tests pass - All 58 doc tests pass - Clippy clean with -D warnings This completes Phase 1 entirely and most of Phase 2 (5/12 tasks, 41.7%) excluding Distributed System Metrics which requires the distributed module to be fully implemented. The telemetry system now provides comprehensive observability for production debugging and monitoring. Progress: 5/12 tasks completed (41.7%)
1 parent 7f413dd commit ecea914

4 files changed

Lines changed: 226 additions & 26 deletions

File tree

TELEMETRY.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ joerl automatically tracks the following metrics when telemetry is enabled:
5656
| `joerl_actors_stopped_total` | Counter | Total actors stopped | `type`, `reason` |
5757
| `joerl_actors_active` | Gauge | Current number of active actors | `type` |
5858
| `joerl_actors_panicked_total` | Counter | Total actors that panicked | `type` |
59+
| `joerl_actor_lifetime_seconds` | Histogram | Actor lifetime duration | `type` |
60+
| `joerl_short_lived_actors_total` | Counter | Actors that lived < 1 second | `type` |
5961

6062
**Type labels**: Actor struct name (e.g., `"Worker"`, `"Supervisor"`, `"boxed"` for dynamically spawned actors)
6163

@@ -123,6 +125,19 @@ joerl automatically tracks the following metrics when telemetry is enabled:
123125

124126
**State/Event labels**: Debug representation of state/event enums (e.g., `"Locked"`, `"Open"`)
125127

128+
### Signal Operations
129+
130+
| Metric | Type | Description | Labels |
131+
|--------|------|-------------|--------|
132+
| `joerl_signals_sent_total` | Counter | Total signals sent | `type` |
133+
| `joerl_signals_received_total` | Counter | Total signals received | `type` |
134+
| `joerl_signals_ignored_total` | Counter | Signals ignored (trapped) | `type` |
135+
| `joerl_exit_signals_by_reason_total` | Counter | Exit signals by reason | `reason` |
136+
137+
**Signal type labels**: `exit`, `down`, `stop`, `kill`
138+
139+
**Reason labels**: `normal`, `shutdown`, `killed`, `panic`, `custom`
140+
126141
## Integration Examples
127142

128143
### Prometheus
@@ -267,6 +282,40 @@ joerl_gen_statem_current_state by (type, state)
267282
268283
# GenStatem invalid transitions (potential bugs)
269284
rate(joerl_gen_statem_invalid_transitions_total[5m]) by (type, state, event)
285+
286+
# Actor average lifetime by type
287+
rate(joerl_actor_lifetime_seconds_sum[5m]) /
288+
rate(joerl_actor_lifetime_seconds_count[5m]) by (type)
289+
290+
# Actor lifetime percentiles (p50, p95, p99)
291+
histogram_quantile(0.50, rate(joerl_actor_lifetime_seconds_bucket[5m])) by (type)
292+
histogram_quantile(0.95, rate(joerl_actor_lifetime_seconds_bucket[5m])) by (type)
293+
histogram_quantile(0.99, rate(joerl_actor_lifetime_seconds_bucket[5m])) by (type)
294+
295+
# Short-lived actors (potential restart loops)
296+
rate(joerl_short_lived_actors_total[5m]) by (type)
297+
298+
# Actors with highest short-lived rate (restart issues)
299+
topk(5, rate(joerl_short_lived_actors_total[5m]) by (type))
300+
301+
# Signal sending rate by type
302+
rate(joerl_signals_sent_total[1m]) by (type)
303+
304+
# Signal receiving rate by type
305+
rate(joerl_signals_received_total[1m]) by (type)
306+
307+
# Ignored signal rate (trapped exits)
308+
rate(joerl_signals_ignored_total[1m]) by (type)
309+
310+
# Exit signal breakdown by reason
311+
rate(joerl_exit_signals_by_reason_total[5m]) by (reason)
312+
313+
# Most common exit reasons
314+
topk(5, rate(joerl_exit_signals_by_reason_total[5m]) by (reason))
315+
316+
# Signal ignore ratio (trapped vs total)
317+
rate(joerl_signals_ignored_total[5m]) /
318+
(rate(joerl_signals_received_total[5m]) + 0.001)
270319
```
271320

272321
### Datadog
@@ -307,6 +356,8 @@ Monitor critical metrics:
307356
- **Mailbox backpressure**: `joerl_mailbox_full_total` increasing
308357
- **High panic rate**: `rate(joerl_actors_panicked_total[5m]) > 0`
309358
- **Message send failures**: `rate(joerl_messages_sent_failed_total[1m]) > 100`
359+
- **Restart loops**: `rate(joerl_short_lived_actors_total[5m]) > 5`
360+
- **Abnormal exit signals**: `rate(joerl_exit_signals_by_reason_total{reason!="normal"}[5m]) > 10`
310361

311362
### 3. Use Dashboards
312363

TODO.md

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -112,28 +112,57 @@ This document tracks planned enhancements to joerl's telemetry system.
112112

113113
---
114114

115-
### 5. Signal-Specific Metrics
116-
**Status**: Not started
115+
### 5. Signal-Specific Metrics ✅ COMPLETED
116+
**Status**: Completed
117117
**Priority**: Short-term
118118
**Value**: Debug supervision trees, understand failure propagation patterns
119119

120-
**Metrics to add**:
121-
- `joerl_signals_sent_total{signal_type}` (exit, down, stop, kill)
122-
- `joerl_signals_received_total{signal_type}`
123-
- `joerl_signals_ignored_total{signal_type}` (when trap_exit=true)
124-
- `joerl_exit_signals_by_reason{reason}` (breakdown of exit reasons)
120+
**Implementation**:
121+
- [x] Add `SignalMetrics` struct to telemetry module
122+
- [x] Track signal sending in `send_signal()` with type labels
123+
- [x] Track signal reception in actor message loop
124+
- [x] Detect and track trapped/ignored signals
125+
- [x] Track exit signal reasons separately
126+
- [x] Update TELEMETRY.md with signal metrics and queries
127+
128+
**Result**: Complete signal observability for debugging supervision trees and failure propagation.
129+
130+
**Metrics added**:
131+
- `joerl_signals_sent_total{type}` - exit, down, stop, kill
132+
- `joerl_signals_received_total{type}`
133+
- `joerl_signals_ignored_total{type}` - tracked when trap_exit=true
134+
- `joerl_exit_signals_by_reason_total{reason}` - normal, shutdown, killed, panic, custom
135+
136+
**Files modified**:
137+
- `joerl/src/telemetry.rs` - Added SignalMetrics struct
138+
- `joerl/src/system.rs` - Instrumented send_signal() and signal reception
139+
- `TELEMETRY.md` - Added metrics table and PromQL queries
125140

126141
---
127142

128-
### 6. Actor Lifetime Statistics
129-
**Status**: Not started
143+
### 6. Actor Lifetime Statistics ✅ COMPLETED
144+
**Status**: Completed
130145
**Priority**: Short-term
131146
**Value**: Identify actors that die too quickly (restart loops) or leak (never die)
132147

133-
**Metrics to add**:
134-
- `joerl_actor_lifetime_seconds{actor_type}` (histogram)
135-
- `joerl_actor_lifetime_total_seconds{actor_type}` (cumulative)
136-
- `joerl_short_lived_actors_total{actor_type}` (lived < 1s)
148+
**Implementation**:
149+
- [x] Add `spawn_time` field to ActorEntry (conditional on telemetry feature)
150+
- [x] Track spawn time on actor creation
151+
- [x] Calculate lifetime duration in cleanup_actor()
152+
- [x] Add `actor_lifetime()` method to ActorMetrics
153+
- [x] Automatically detect short-lived actors (< 1s)
154+
- [x] Update TELEMETRY.md with lifetime metrics and queries
155+
156+
**Result**: Complete actor lifetime tracking for identifying restart loops and performance issues.
157+
158+
**Metrics added**:
159+
- `joerl_actor_lifetime_seconds{type}` - histogram of actor lifetimes
160+
- `joerl_short_lived_actors_total{type}` - counter for actors < 1s (restart loops)
161+
162+
**Files modified**:
163+
- `joerl/src/system.rs` - Added spawn_time tracking and lifetime calculation
164+
- `joerl/src/telemetry.rs` - Added actor_lifetime() method
165+
- `TELEMETRY.md` - Added metrics table and PromQL queries
137166

138167
---
139168

@@ -220,9 +249,9 @@ This document tracks planned enhancements to joerl's telemetry system.
220249

221250
## Progress Summary
222251

223-
- **Completed**: 3/12 (25%)
252+
- **Completed**: 5/12 (41.7%)
224253
- **In Progress**: 0/12
225-
- **Not Started**: 9/12
254+
- **Not Started**: 7/12
226255

227256
---
228257

@@ -233,10 +262,10 @@ This document tracks planned enhancements to joerl's telemetry system.
233262
2.**Per-Actor Mailbox Tracking** (#4) - COMPLETED
234263
3.**GenServer/GenStatem Metrics** (#3) - COMPLETED
235264

236-
### Phase 2: Production Readiness (Short-term)
237-
4. **Distributed System Metrics** (#2)
238-
5. **Signal-Specific Metrics** (#5)
239-
6. **Actor Lifetime Statistics** (#6)
265+
### Phase 2: Production Readiness (Short-term) ✅ COMPLETED (except #2)
266+
4. **Distributed System Metrics** (#2) - Requires distributed module implementation
267+
5. **Signal-Specific Metrics** (#5) - COMPLETED
268+
6. **Actor Lifetime Statistics** (#6) - COMPLETED
240269

241270
### Phase 3: Performance & Optimization (Medium-term)
242271
7. **Sampling Configuration** (#8)

joerl/src/system.rs

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::actor::{Actor, ActorContext};
1111
use crate::error::{ActorError, Result};
1212
use crate::mailbox::{DEFAULT_MAILBOX_CAPACITY, Mailbox, MailboxSender};
1313
use crate::message::{Envelope, ExitReason, Message, MonitorRef, Signal};
14-
use crate::telemetry::{ActorMetrics, LinkMetrics, MessageMetrics};
14+
use crate::telemetry::{ActorMetrics, LinkMetrics, MessageMetrics, SignalMetrics};
1515
use dashmap::DashMap;
1616
use futures::FutureExt;
1717
use std::collections::HashSet;
@@ -233,6 +233,8 @@ struct ActorEntry {
233233
links: HashSet<Pid>,
234234
monitors: HashSet<(Pid, MonitorRef)>, // (monitoring_pid, ref)
235235
actor_type: String, // Type name for telemetry
236+
#[cfg(feature = "telemetry")]
237+
spawn_time: std::time::Instant, // For lifetime tracking
236238
}
237239

238240
/// The actor system runtime.
@@ -400,6 +402,8 @@ impl ActorSystem {
400402
links: HashSet::new(),
401403
monitors: HashSet::new(),
402404
actor_type: actor_type_owned.clone(),
405+
#[cfg(feature = "telemetry")]
406+
spawn_time: std::time::Instant::now(),
403407
};
404408
self.actors.insert(pid, entry);
405409

@@ -423,6 +427,26 @@ impl ActorSystem {
423427
MessageMetrics::message_processed();
424428
}
425429
Some(Envelope::Signal(signal)) => {
430+
// Telemetry: track signal reception
431+
let signal_type = match &signal {
432+
Signal::Exit { .. } => "exit",
433+
Signal::Down { .. } => "down",
434+
Signal::Stop => "stop",
435+
Signal::Kill => "kill",
436+
};
437+
SignalMetrics::signal_received(signal_type);
438+
439+
// Check if signal will be ignored (trapped)
440+
let is_trapped = if let Signal::Exit { reason, .. } = &signal {
441+
ctx.is_trapping_exits() && reason.is_trappable()
442+
} else {
443+
false
444+
};
445+
446+
if is_trapped {
447+
SignalMetrics::signal_ignored(signal_type);
448+
}
449+
426450
actor.handle_signal(signal, &mut ctx).await;
427451
}
428452
None => {
@@ -526,6 +550,28 @@ impl ActorSystem {
526550

527551
/// Sends a signal to an actor.
528552
pub(crate) async fn send_signal(&self, to: Pid, signal: Signal) -> Result<()> {
553+
// Telemetry: track signal sending
554+
let signal_type = match &signal {
555+
Signal::Exit { .. } => "exit",
556+
Signal::Down { .. } => "down",
557+
Signal::Stop => "stop",
558+
Signal::Kill => "kill",
559+
};
560+
561+
// Track exit reasons specifically
562+
if let Signal::Exit { reason, .. } = &signal {
563+
let reason_str = match reason {
564+
ExitReason::Normal => "normal",
565+
ExitReason::Shutdown => "shutdown",
566+
ExitReason::Killed => "killed",
567+
ExitReason::Panic(_) => "panic",
568+
ExitReason::Custom(_) => "custom",
569+
};
570+
SignalMetrics::exit_signal_by_reason(reason_str);
571+
}
572+
573+
SignalMetrics::signal_sent(signal_type);
574+
529575
if let Some(entry) = self.actors.get(&to) {
530576
entry
531577
.sender
@@ -688,12 +734,21 @@ impl ActorSystem {
688734

689735
/// Cleans up an actor after termination.
690736
async fn cleanup_actor(&self, pid: Pid, reason: &ExitReason) {
691-
// Get actor type and remove from registry
692-
let (actor_type, links, monitors) = if let Some((_, entry)) = self.actors.remove(&pid) {
693-
(entry.actor_type, entry.links, entry.monitors)
694-
} else {
695-
return;
696-
};
737+
// Get actor type, spawn time, and remove from registry
738+
let (actor_type, links, monitors, spawn_time) =
739+
if let Some((_, entry)) = self.actors.remove(&pid) {
740+
(
741+
entry.actor_type,
742+
entry.links,
743+
entry.monitors,
744+
#[cfg(feature = "telemetry")]
745+
entry.spawn_time,
746+
#[cfg(not(feature = "telemetry"))]
747+
std::time::Instant::now(),
748+
)
749+
} else {
750+
return;
751+
};
697752

698753
// Telemetry: record actor stop with type
699754
let reason_str = match reason {
@@ -705,6 +760,13 @@ impl ActorSystem {
705760
};
706761
ActorMetrics::actor_stopped_typed(&actor_type, reason_str);
707762

763+
// Telemetry: record actor lifetime
764+
#[cfg(feature = "telemetry")]
765+
{
766+
let lifetime = spawn_time.elapsed().as_secs_f64();
767+
ActorMetrics::actor_lifetime(&actor_type, lifetime);
768+
}
769+
708770
// Send EXIT signals to linked actors
709771
for linked_pid in links {
710772
let _ = self

joerl/src/telemetry.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
//! - `joerl_actors_stopped_total`: Total number of actors stopped (by reason)
2020
//! - `joerl_actors_active`: Current number of active actors
2121
//! - `joerl_actors_panicked_total`: Total number of actors that panicked
22+
//! - `joerl_actor_lifetime_seconds`: Actor lifetime duration histogram
23+
//! - `joerl_short_lived_actors_total`: Actors that lived < 1 second
2224
//!
2325
//! ### Messages
2426
//! - `joerl_messages_sent_total`: Total messages sent
@@ -50,6 +52,12 @@
5052
//! - `joerl_gen_statem_state_duration_seconds`: Time spent in each state
5153
//! - `joerl_gen_statem_current_state`: Current state of state machines
5254
//!
55+
//! ### Signals
56+
//! - `joerl_signals_sent_total`: Total signals sent
57+
//! - `joerl_signals_received_total`: Total signals received
58+
//! - `joerl_signals_ignored_total`: Signals ignored (trapped exits)
59+
//! - `joerl_exit_signals_by_reason_total`: Exit signals by reason
60+
//!
5361
//! ## Example
5462
//!
5563
//! ```rust,no_run
@@ -139,6 +147,23 @@ impl ActorMetrics {
139147
}
140148
}
141149

150+
/// Records actor lifetime statistics.
151+
#[inline]
152+
pub fn actor_lifetime(actor_type: &str, lifetime_secs: f64) {
153+
#[cfg(feature = "telemetry")]
154+
{
155+
// Record lifetime histogram
156+
histogram!("joerl_actor_lifetime_seconds", "type" => actor_type.to_string())
157+
.record(lifetime_secs);
158+
159+
// Track short-lived actors (< 1 second)
160+
if lifetime_secs < 1.0 {
161+
counter!("joerl_short_lived_actors_total", "type" => actor_type.to_string())
162+
.increment(1);
163+
}
164+
}
165+
}
166+
142167
/// Records an actor spawn (backward compatible, no type label).
143168
#[inline]
144169
pub fn actor_spawned() {
@@ -372,6 +397,39 @@ impl Drop for GenServerCallSpan {
372397
}
373398
}
374399

400+
/// Signal metrics.
401+
pub struct SignalMetrics;
402+
403+
impl SignalMetrics {
404+
/// Records a signal sent.
405+
#[inline]
406+
pub fn signal_sent(signal_type: &str) {
407+
#[cfg(feature = "telemetry")]
408+
counter!("joerl_signals_sent_total", "type" => signal_type.to_string()).increment(1);
409+
}
410+
411+
/// Records a signal received by an actor.
412+
#[inline]
413+
pub fn signal_received(signal_type: &str) {
414+
#[cfg(feature = "telemetry")]
415+
counter!("joerl_signals_received_total", "type" => signal_type.to_string()).increment(1);
416+
}
417+
418+
/// Records a signal that was ignored (trapped).
419+
#[inline]
420+
pub fn signal_ignored(signal_type: &str) {
421+
#[cfg(feature = "telemetry")]
422+
counter!("joerl_signals_ignored_total", "type" => signal_type.to_string()).increment(1);
423+
}
424+
425+
/// Records an exit signal with its reason.
426+
#[inline]
427+
pub fn exit_signal_by_reason(reason: &str) {
428+
#[cfg(feature = "telemetry")]
429+
counter!("joerl_exit_signals_by_reason_total", "reason" => reason.to_string()).increment(1);
430+
}
431+
}
432+
375433
/// GenStatem metrics.
376434
pub struct GenStatemMetrics;
377435

0 commit comments

Comments
 (0)