anzen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anzen
4
+ # Base interface/contract for all safety monitors
5
+ #
6
+ # All monitors (built-in and custom) must implement this interface.
7
+ # This module defines the required methods that every monitor must provide.
8
+ #
9
+ # @api public
10
+ # @example Implement a custom monitor
11
+ # class MyMonitor
12
+ # include Anzen::Monitor
13
+ #
14
+ # def name
15
+ # 'my_monitor'
16
+ # end
17
+ #
18
+ # def check!
19
+ # # Your monitoring logic here
20
+ # raise Anzen::ViolationError.new("violation") if problem_detected?
21
+ # end
22
+ # end
23
+ module Monitor
24
+ # Unique identifier for this monitor
25
+ #
26
+ # @return [String] monitor name (format: [a-z0-9_]+)
27
+ def name
28
+ raise NotImplementedError, "#{self.class} must implement #name"
29
+ end
30
+
31
+ # Enable this monitor for checking
32
+ #
33
+ # @return [Boolean] true
34
+ def enable
35
+ raise NotImplementedError, "#{self.class} must implement #enable"
36
+ end
37
+
38
+ # Disable this monitor from checking
39
+ #
40
+ # @return [Boolean] false
41
+ def disable
42
+ raise NotImplementedError, "#{self.class} must implement #disable"
43
+ end
44
+
45
+ # Check if this monitor is currently enabled
46
+ #
47
+ # @return [Boolean] true if enabled, false if disabled
48
+ def enabled?
49
+ raise NotImplementedError, "#{self.class} must implement #enabled?"
50
+ end
51
+
52
+ # Execute this monitor's check
53
+ #
54
+ # Reads current state, compares to thresholds, and raises appropriate error if violation detected.
55
+ # Must raise a ViolationError subclass on violation, or CheckFailedError if check itself fails.
56
+ # Must not raise if check passes.
57
+ # Returns nil if check passes (monitor enabled or disabled).
58
+ #
59
+ # @return [nil] if check passes
60
+ # @raise [Anzen::ViolationError] subclass on detection
61
+ # @raise [Anzen::CheckFailedError] on infrastructure failure
62
+ def check!
63
+ raise NotImplementedError, "#{self.class} must implement #check!"
64
+ end
65
+
66
+ # Return current status of this monitor
67
+ #
68
+ # @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
69
+ # - name (String): monitor name
70
+ # - enabled (Boolean): whether monitor is enabled
71
+ # - thresholds (Hash): monitor-specific threshold values
72
+ # - last_check (Time): timestamp of last check, or nil if never checked
73
+ # - violations (Integer): count of violations detected
74
+ def status
75
+ raise NotImplementedError, "#{self.class} must implement #status"
76
+ end
77
+
78
+ # Return human-readable one-liner for CLI output
79
+ #
80
+ # @return [String] single line describing monitor status
81
+ def to_cli
82
+ raise NotImplementedError, "#{self.class} must implement #to_cli"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anzen
4
+ module Monitors
5
+ # Monitor that detects call stack depth exceeding configured threshold
6
+ #
7
+ # Tracks call stack depth and raises RecursionLimitExceeded when
8
+ # the depth exceeds the configured limit. Supports any recursion pattern
9
+ # (direct, indirect, n-way cycles).
10
+ #
11
+ # Use case: Hard limit on stack depth to prevent OOM/stack overflow
12
+ #
13
+ # @api public
14
+ # @example Basic usage
15
+ # monitor = Anzen::Monitors::CallStackDepthMonitor.new(depth_limit: 1000)
16
+ # monitor.enable
17
+ # monitor.check! # Raises RecursionLimitExceeded if depth > 1000
18
+ class CallStackDepthMonitor
19
+ include Anzen::Monitor
20
+
21
+ # @return [Integer] configured depth limit
22
+ attr_reader :depth_limit
23
+
24
+ # @return [Integer] count of violations detected
25
+ attr_reader :violation_count
26
+
27
+ # @return [Time, nil] timestamp of last check
28
+ attr_reader :last_check
29
+
30
+ # Initialize CallStackDepthMonitor
31
+ #
32
+ # @param depth_limit [Integer] maximum allowed call stack depth (must be positive)
33
+ # @raise [ConfigurationError] if depth_limit is not a positive integer
34
+ def initialize(depth_limit: 1000)
35
+ validate_depth_limit(depth_limit)
36
+ @depth_limit = depth_limit
37
+ @enabled = false
38
+ @violation_count = 0
39
+ @last_check = nil
40
+ end
41
+
42
+ # Monitor name
43
+ #
44
+ # @return [String] "call_stack_depth"
45
+ def name
46
+ 'call_stack_depth'
47
+ end
48
+
49
+ # Enable this monitor
50
+ #
51
+ # @return [Boolean] true
52
+ def enable
53
+ @enabled = true
54
+ end
55
+
56
+ # Disable this monitor
57
+ #
58
+ # @return [Boolean] false
59
+ def disable
60
+ @enabled = false
61
+ end
62
+
63
+ # Check if monitor is enabled
64
+ #
65
+ # @return [Boolean]
66
+ def enabled?
67
+ @enabled
68
+ end
69
+
70
+ # Check current call stack depth
71
+ #
72
+ # Reads the call stack, counts method frames, and raises RecursionLimitExceeded
73
+ # if depth exceeds threshold. Blocks count as method frames.
74
+ # Does nothing if monitor is disabled.
75
+ #
76
+ # @return [nil]
77
+ # @raise [Anzen::RecursionLimitExceeded] if depth exceeds threshold
78
+ # @raise [Anzen::CheckFailedError] if check infrastructure fails
79
+ def check!
80
+ return nil unless @enabled
81
+
82
+ begin
83
+ current_depth = calculate_depth
84
+ @last_check = Time.now
85
+
86
+ if current_depth > @depth_limit
87
+ @violation_count += 1
88
+ raise Anzen::RecursionLimitExceeded.new(current_depth, @depth_limit)
89
+ end
90
+
91
+ nil
92
+ rescue Anzen::RecursionLimitExceeded
93
+ raise
94
+ rescue StandardError => e
95
+ raise Anzen::CheckFailedError.new(name, 'Failed to calculate call stack depth', e)
96
+ end
97
+ end
98
+
99
+ # Return current status
100
+ #
101
+ # @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
102
+ def status
103
+ {
104
+ name: name,
105
+ enabled: @enabled,
106
+ thresholds: {
107
+ depth_limit: @depth_limit
108
+ },
109
+ last_check: @last_check,
110
+ violations: @violation_count
111
+ }
112
+ end
113
+
114
+ # Return human-readable one-liner for CLI
115
+ #
116
+ # @return [String]
117
+ def to_cli
118
+ status_text = @enabled ? 'enabled' : 'disabled'
119
+ "Call stack depth monitor (#{status_text}): limit=#{@depth_limit}, violations=#{@violation_count}"
120
+ end
121
+
122
+ private
123
+
124
+ # Calculate current call stack depth
125
+ #
126
+ # Counts method frames in the call stack.
127
+ # Uses Kernel.caller to get the call stack.
128
+ #
129
+ # @return [Integer] current depth
130
+ def calculate_depth
131
+ # Kernel.caller returns array of strings like "path/file.rb:123:in `method_name'"
132
+ # Each frame represents a method call (including blocks)
133
+ caller.length
134
+ end
135
+
136
+ # Validate depth_limit configuration
137
+ #
138
+ # @param depth_limit [Integer, Numeric]
139
+ # @raise [Anzen::ConfigurationError] if invalid
140
+ def validate_depth_limit(depth_limit)
141
+ return if depth_limit.is_a?(Numeric) && depth_limit > 0 && depth_limit == depth_limit.to_i
142
+
143
+ raise Anzen::ConfigurationError, "depth_limit must be a positive integer, got #{depth_limit.inspect}"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anzen
4
+ module Monitors
5
+ # Monitor that detects memory consumption exceeding configurable threshold
6
+ #
7
+ # Monitors process RSS (Resident Set Size) memory usage with configurable sampling
8
+ # to minimize overhead. Supports absolute MB limits or percentage of available memory.
9
+ # Raises MemoryLimitExceeded immediately when threshold is exceeded.
10
+ #
11
+ # Use case: Prevent memory leaks and runaway memory consumption in long-running processes
12
+ #
13
+ # @api public
14
+ # @example Basic usage
15
+ # monitor = Anzen::Monitors::MemoryMonitor.new(limit_mb: 512)
16
+ # monitor.enable
17
+ # monitor.check! # Raises MemoryLimitExceeded if > 512MB used
18
+ class MemoryMonitor
19
+ include Anzen::Monitor
20
+
21
+ # Default sampling interval in milliseconds
22
+ DEFAULT_SAMPLING_INTERVAL_MS = 100
23
+
24
+ # Default memory limit in MB
25
+ DEFAULT_LIMIT_MB = 512
26
+
27
+ # @return [String] monitor name
28
+ attr_reader :name
29
+
30
+ # @return [Integer] memory limit in MB
31
+ attr_reader :limit_mb
32
+
33
+ # @return [Integer] sampling interval in milliseconds
34
+ attr_reader :sampling_interval_ms
35
+
36
+ # @return [Time, nil] timestamp of last check
37
+ attr_reader :last_check_time
38
+
39
+ # @return [Float] current RSS memory in MB at last check
40
+ attr_reader :current_rss_mb
41
+
42
+ # @return [Integer] count of violations detected
43
+ attr_reader :violation_count
44
+
45
+ # Initialize MemoryMonitor
46
+ #
47
+ # @param limit_mb [Integer] memory limit in MB (default: 512)
48
+ # @param sampling_interval_ms [Integer] milliseconds between checks (default: 100)
49
+ def initialize(limit_mb: DEFAULT_LIMIT_MB, sampling_interval_ms: DEFAULT_SAMPLING_INTERVAL_MS)
50
+ @name = 'memory'
51
+ @enabled = false
52
+ @limit_mb = limit_mb
53
+ @sampling_interval_ms = sampling_interval_ms
54
+ @last_check_time = nil
55
+ @current_rss_mb = 0.0
56
+ @violation_count = 0
57
+
58
+ validate_configuration!
59
+ end
60
+
61
+ # Enable this monitor
62
+ #
63
+ # @return [Boolean] true
64
+ def enable
65
+ @enabled = true
66
+ end
67
+
68
+ # Disable this monitor
69
+ #
70
+ # @return [Boolean] false
71
+ def disable
72
+ @enabled = false
73
+ end
74
+
75
+ # Check if monitor is enabled
76
+ #
77
+ # @return [Boolean]
78
+ def enabled?
79
+ @enabled
80
+ end
81
+
82
+ # Check for memory limit violation
83
+ #
84
+ # Reads current process RSS memory and compares to configured limit.
85
+ # Only performs check if sampling interval has elapsed since last check.
86
+ # Raises MemoryLimitExceeded if memory usage exceeds threshold.
87
+ # Does nothing if monitor is disabled.
88
+ #
89
+ # @return [nil]
90
+ # @raise [Anzen::MemoryLimitExceeded] if memory usage exceeds threshold
91
+ # @raise [Anzen::CheckFailedError] if memory reading fails
92
+ def check!
93
+ return nil unless @enabled
94
+
95
+ begin
96
+ if should_check?
97
+ @current_rss_mb = read_process_memory_mb
98
+ @last_check_time = Time.now
99
+
100
+ if @current_rss_mb > @limit_mb
101
+ @violation_count += 1
102
+ raise Anzen::MemoryLimitExceeded.new(@current_rss_mb, @limit_mb)
103
+ end
104
+ end
105
+
106
+ nil
107
+ rescue Anzen::MemoryLimitExceeded
108
+ raise
109
+ rescue StandardError => e
110
+ raise Anzen::CheckFailedError.new(name, 'Failed to read process memory', e)
111
+ end
112
+ end
113
+
114
+ # Return current status
115
+ #
116
+ # @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
117
+ def status
118
+ {
119
+ name: name,
120
+ enabled: @enabled,
121
+ thresholds: {
122
+ limit_mb: @limit_mb,
123
+ sampling_interval_ms: @sampling_interval_ms
124
+ },
125
+ last_check: @last_check_time,
126
+ violations: @violation_count
127
+ }
128
+ end
129
+
130
+ # Return human-readable one-liner for CLI
131
+ #
132
+ # @return [String]
133
+ def to_cli
134
+ status_text = @enabled ? 'enabled' : 'disabled'
135
+ "Memory monitor (#{status_text}): limit=#{@limit_mb}MB, current=#{@current_rss_mb.round(1)}MB, violations=#{@violation_count}"
136
+ end
137
+
138
+ private
139
+
140
+ # Validate configuration parameters
141
+ #
142
+ # @raise [Anzen::ConfigurationError] if configuration is invalid
143
+ def validate_configuration!
144
+ unless @limit_mb.is_a?(Integer) && @limit_mb > 0
145
+ raise Anzen::ConfigurationError, "limit_mb must be a positive integer, got: #{@limit_mb.inspect}"
146
+ end
147
+
148
+ return if @sampling_interval_ms.is_a?(Integer) && @sampling_interval_ms >= 0
149
+
150
+ raise Anzen::ConfigurationError,
151
+ "sampling_interval_ms must be a non-negative integer, got: #{@sampling_interval_ms.inspect}"
152
+ end
153
+
154
+ # Check if enough time has elapsed since last check
155
+ #
156
+ # @return [Boolean] true if should perform check
157
+ def should_check?
158
+ return true if @last_check_time.nil?
159
+
160
+ elapsed_ms = (Time.now - @last_check_time) * 1000
161
+ elapsed_ms >= @sampling_interval_ms
162
+ end
163
+
164
+ # Read current process memory usage in MB
165
+ #
166
+ # Attempts to read RSS from /proc/[pid]/status (Linux) or falls back to
167
+ # parsing `ps` command output for cross-platform compatibility.
168
+ #
169
+ # @return [Float] memory usage in MB
170
+ # @raise [StandardError] if memory reading fails
171
+ def read_process_memory_mb
172
+ pid = Process.pid
173
+
174
+ # Try Linux /proc filesystem first (most efficient)
175
+ return read_memory_from_proc(pid) if File.exist?("/proc/#{pid}/status")
176
+
177
+ # Fallback to ps command (cross-platform)
178
+ read_memory_from_ps(pid)
179
+ end
180
+
181
+ # Read memory from Linux /proc/[pid]/status
182
+ #
183
+ # @param pid [Integer] process ID
184
+ # @return [Float] memory in MB
185
+ def read_memory_from_proc(pid)
186
+ status_file = "/proc/#{pid}/status"
187
+ content = File.read(status_file)
188
+
189
+ # Find VmRSS line: "VmRSS: 12345 kB"
190
+ vmrss_match = content.match(/^VmRSS:\s+(\d+)\s+kB/)
191
+ raise "Could not find VmRSS in #{status_file}" unless vmrss_match
192
+
193
+ kb = vmrss_match[1].to_i
194
+ kb / 1024.0 # Convert to MB
195
+ end
196
+
197
+ # Read memory using ps command (fallback for non-Linux systems)
198
+ #
199
+ # @param pid [Integer] process ID
200
+ # @return [Float] memory in MB
201
+ def read_memory_from_ps(pid)
202
+ # Use ps to get RSS in KB, then convert to MB
203
+ # Format: "PID RSS" where RSS is in KB
204
+ output, status = run_ps_command(pid)
205
+ raise "ps command failed: #{status.exitstatus}" unless status.success?
206
+
207
+ lines = output.strip.split("\n")
208
+ raise "ps output incomplete for PID #{pid}" if lines.length < 2
209
+
210
+ # Second line contains the data
211
+ fields = lines[1].strip.split
212
+ raise "ps output format unexpected: #{lines[1]}" if fields.length < 2
213
+
214
+ rss_kb = fields[1].to_i
215
+ rss_kb / 1024.0 # Convert to MB
216
+ end
217
+
218
+ # Execute ps command (extracted for testability)
219
+ #
220
+ # @param pid [Integer] process ID
221
+ # @return [Array<String, Process::Status>] output and status
222
+ def run_ps_command(pid)
223
+ output = `ps -o pid,rss -p #{pid} 2>/dev/null`
224
+ [output, $?]
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anzen
4
+ module Monitors
5
+ # Monitor that detects any recursive method calls (pattern-based)
6
+ #
7
+ # Detects direct recursion (same method repeating) and indirect recursion
8
+ # (method chains forming cycles). Raises RecursionLimitExceeded immediately
9
+ # on first recursion detection, with no depth threshold.
10
+ #
11
+ # Use case: Strict no-recursion enforcement (e.g., signal handlers, async contexts)
12
+ #
13
+ # @api public
14
+ # @example Basic usage
15
+ # monitor = Anzen::Monitors::RecursionMonitor.new
16
+ # monitor.enable
17
+ # monitor.check! # Raises RecursionLimitExceeded if any recursion detected
18
+ class RecursionMonitor
19
+ include Anzen::Monitor
20
+
21
+ # Thread-local key for storing call frame tracking
22
+ FRAME_KEY = :anzen_recursion_frames
23
+
24
+ # @return [Integer] count of violations detected
25
+ attr_reader :violation_count
26
+
27
+ # Initialize RecursionMonitor
28
+ #
29
+ # @param depth_limit [Integer] maximum allowed recursion depth (default: 1000)
30
+ def initialize(depth_limit: 1000)
31
+ @enabled = false
32
+ @violation_count = 0
33
+ @depth_limit = depth_limit
34
+ @last_check = nil
35
+ end
36
+
37
+ # Monitor name
38
+ #
39
+ # @return [String] "recursion"
40
+ def name
41
+ 'recursion'
42
+ end
43
+
44
+ # Enable this monitor
45
+ #
46
+ # @return [Boolean] true
47
+ def enable
48
+ @enabled = true
49
+ end
50
+
51
+ # Disable this monitor
52
+ #
53
+ # @return [Boolean] false
54
+ def disable
55
+ @enabled = false
56
+ end
57
+
58
+ # Check if monitor is enabled
59
+ #
60
+ # @return [Boolean]
61
+ def enabled?
62
+ @enabled
63
+ end
64
+
65
+ # Check for recursion pattern
66
+ #
67
+ # Analyzes the current call stack to detect if any method appears
68
+ # multiple times (direct recursion) or if there's a cycle in the call chain
69
+ # (indirect recursion). Raises RecursionLimitExceeded if recursion is
70
+ # detected and the call stack depth exceeds the configured limit.
71
+ #
72
+ # @return [nil]
73
+ # @raise [Anzen::RecursionLimitExceeded] if recursion detected and depth exceeds limit
74
+ # @raise [Anzen::CheckFailedError] if check infrastructure fails
75
+ def check!
76
+ return nil unless @enabled
77
+
78
+ begin
79
+ current_depth = caller.length
80
+ @last_check = Time.now
81
+
82
+ if recursion_detected? && current_depth > @depth_limit
83
+ @violation_count += 1
84
+ raise Anzen::RecursionLimitExceeded.new(current_depth, @depth_limit)
85
+ end
86
+
87
+ nil
88
+ rescue Anzen::RecursionLimitExceeded
89
+ raise
90
+ rescue StandardError => e
91
+ raise Anzen::CheckFailedError.new(name, 'Failed to detect recursion pattern', e)
92
+ end
93
+ end
94
+
95
+ # Return current status
96
+ #
97
+ # @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
98
+ def status
99
+ {
100
+ name: name,
101
+ enabled: @enabled,
102
+ thresholds: {
103
+ depth_limit: @depth_limit
104
+ },
105
+ last_check: @last_check,
106
+ violations: @violation_count
107
+ }
108
+ end
109
+
110
+ # Return human-readable one-liner for CLI
111
+ #
112
+ # @return [String]
113
+ def to_cli
114
+ status_text = @enabled ? 'enabled' : 'disabled'
115
+ "Recursion monitor (#{status_text}): violations=#{@violation_count}"
116
+ end
117
+
118
+ private
119
+
120
+ # Detect if current call stack has recursion pattern
121
+ #
122
+ # Uses call context (file:line:method) for application frames to avoid
123
+ # false positives from framework internals while still detecting cycles.
124
+ #
125
+ # @return [Boolean] true if recursion detected
126
+ def recursion_detected?
127
+ contexts = extract_call_contexts
128
+ seen = Set.new
129
+ contexts.each do |ctx|
130
+ return true if seen.include?(ctx)
131
+
132
+ seen.add(ctx)
133
+ end
134
+ false
135
+ end
136
+
137
+ # Extract call contexts (file:line:method) from the filtered call stack
138
+ #
139
+ # Example: "path/file.rb:123:in `method'" => "path/file.rb:123:method"
140
+ #
141
+ # @return [Array<String>]
142
+ def extract_call_contexts
143
+ caller
144
+ .reject { |frame| should_skip_frame?(frame) }
145
+ .map { |frame| extract_call_context(frame) }
146
+ .compact
147
+ end
148
+
149
+ # Determine if a caller frame should be skipped
150
+ #
151
+ # Skips frames from:
152
+ # - Standard library (stdlib paths)
153
+ # - RSpec and testing framework core
154
+ # - Ruby's internal code
155
+ # - Gems/vendor directories (except application code)
156
+ #
157
+ # Does NOT skip application code in spec/test/integration directories.
158
+ #
159
+ # @param frame [String] caller frame string
160
+ # @return [Boolean] true if frame should be skipped
161
+ def should_skip_frame?(frame)
162
+ # Skip only specific framework/library patterns, not application test code
163
+ skip_patterns = [
164
+ %r{/gems/.*\.rb:}, # Bundler gems
165
+ %r{\.bundle/}, # Bundler paths
166
+ %r{/lib/ruby/\d+\.\d+}, # Standard library
167
+ %r{rspec.*gem.*/lib/}, # RSpec gem code (not test files)
168
+ /<internal:/, # Ruby internals
169
+ /method_missing/ # Dynamic method dispatch
170
+ ]
171
+
172
+ skip_patterns.any? { |pattern| frame.match?(pattern) }
173
+ end
174
+
175
+ # Extract file:line:method context from a caller frame
176
+ def extract_call_context(frame)
177
+ match = frame.match(/^([^:]+:\d+):in `([^']+)'/)
178
+ return nil unless match
179
+
180
+ file_line = match[1]
181
+ method = match[2]
182
+ "#{file_line}:#{method}"
183
+ end
184
+
185
+ # Get current call stack depth for error reporting
186
+ #
187
+ # @return [Integer]
188
+ def current_depth
189
+ caller.length
190
+ end
191
+ end
192
+ end
193
+ end