hyperprobe-agent 1.2.27.pre.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0fc98f264e7d9cd4fe3c902f686ba8e6b95e7c6f272126d4b6a811400f0eff39
4
+ data.tar.gz: 67b10ac7149644e1a4644693e016b31b48dc8f6892fcc9c1c5e360e5604495a5
5
+ SHA512:
6
+ metadata.gz: 44c3a8718c1df4b1b843f9320b4deb3237ab722af67128cb38670c80ebf1bb74e2a6a5f81ebb7d9b5d4f12af92b1ea43b6abcc13ec01f6a4a3f34500cf42006f
7
+ data.tar.gz: 47e7bb3084e5700db38c353cf351046c7082abf417ab30a41350270c72329194b89cceba596df0cdc9cd53d0f40c3e05cc02ef712290895b9e1d302f39360f69
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Hyperprobe Proprietary License
2
+
3
+ Copyright (c) 2026 Hyperprobe. All rights reserved.
4
+
5
+ This software and its associated documentation are proprietary to Hyperprobe.
6
+ No permission is granted to use, copy, modify, distribute, sublicense, or
7
+ create derivative works except as expressly authorized under a written
8
+ commercial license agreement with Hyperprobe.
9
+
10
+ A valid commercial license from Hyperprobe is required to operate this
11
+ software.
12
+
13
+ For licensing inquiries, visit https://hyperprobe.co/.
data/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # HyperProbe Ruby Agent
2
+
3
+ Production-grade, non-breaking live debugger and telemetry agent for Ruby and JRuby applications.
4
+
5
+ ## Overview
6
+
7
+ The HyperProbe Ruby Agent allows developers to set non-breaking live probes (snapshots, logs, counters, metrics, and durations) in production environments without pausing execution or modifying source code.
8
+
9
+ ## Key Features
10
+
11
+ - **Snapshots**: Capture local variables across stack frames and evaluate watch expressions without pausing threads.
12
+ - **Dynamic Logs**: Evaluate log templates (`${var}`) in context, with optional stdout emission and full value redaction.
13
+ - **Metrics & Counters**: Measure event counts, custom expressions, and block/loop durations.
14
+ - **Safety Shield**: Continuous thread lag monitoring and pause duration budgets with automated circuit breaker shedding (`GREEN`/`YELLOW`/`RED`).
15
+ - **Quota Governance**: Token bucket rate limiting for hits per second and network bandwidth.
16
+ - **Distributed Tracing**: Integrations with OpenTelemetry, Datadog (`ddtrace`), New Relic (`newrelic_rpm`), Elastic APM, and custom trace getters.
17
+ - **Multi-Process & Serverless**: Native support for Puma/Unicorn worker forking and AWS Lambda execution.
18
+ - **JRuby on JVM**: First-class JRuby support with native JVM HTTP/2 gRPC transport and hybrid Java object serialization.
19
+
20
+ ## Runtime Compatibility
21
+
22
+ | Platform | Supported Versions | Prerequisites |
23
+ | :--- | :--- | :--- |
24
+ | **Standard Ruby (CRuby / MRI)** | **Ruby 3.0, 3.1, 3.2, 3.3, 3.4, 4.0+** | None (Zero launch flags needed) |
25
+ | **JRuby (Ruby on JVM)** | **JRuby 9.3+, 9.4+, 10.x+** | **Java 11+** and `JRUBY_OPTS="--debug"` |
26
+
27
+ ## Installation
28
+
29
+ Add to your `Gemfile`:
30
+
31
+ ```ruby
32
+ gem 'hyperprobe-agent'
33
+ ```
34
+
35
+ And run:
36
+
37
+ ```bash
38
+ bundle install
39
+ ```
40
+
41
+ ## Quickstart
42
+
43
+ Create a dedicated `hyperprobe.rb` file in your application root:
44
+
45
+ ```ruby
46
+ # hyperprobe.rb
47
+ require 'hyperprobe'
48
+
49
+ HyperProbe.start(
50
+ service_id: '<service-uuid-from-dashboard>',
51
+ environment: ENV['HYPERPROBE_ENVIRONMENT'] || 'production',
52
+ broker_url: 'https://logger.app.hyperprobe.co',
53
+ commit_sha: ENV['GIT_COMMIT']
54
+ )
55
+ ```
56
+
57
+ Require it as early as possible in your application entrypoint (e.g. `app.rb`, `server.rb`, `config/environment.rb`):
58
+
59
+ ```ruby
60
+ # app.rb
61
+ require_relative 'hyperprobe'
62
+
63
+ # ... start your web server / application ...
64
+ ```
65
+
66
+ ## Environment Variables
67
+
68
+ | Variable | Description | Default |
69
+ | :--- | :--- | :--- |
70
+ | `HYPERPROBE_SERVICE_ID` | Unique UUID for the service | (Required) |
71
+ | `HYPERPROBE_ENVIRONMENT` | Deployment environment (`production`, `staging`, etc.) | (Required) |
72
+ | `HYPERPROBE_BROKER_URL` | gRPC Broker endpoint | (Required) |
73
+ | `GIT_COMMIT` / `HYPERPROBE_COMMIT_SHA` | Current deployment commit SHA | (Required) |
74
+ | `HYPERPROBE_DISABLED` | Set to `YES` to disable the agent | `NO` |
75
+ | `HYPERPROBE_SYNC_INTERVAL_MS` | Probe synchronization interval | `60000` |
76
+ | `HYPERPROBE_FLUSH_INTERVAL_MS` | Telemetry batch flush interval | `1000` |
77
+ | `HYPERPROBE_HITS_PER_SEC` | Maximum probe hits evaluated per second | `10` |
78
+ | `HYPERPROBE_BANDWIDTH_KB_PER_SEC` | Maximum telemetry bandwidth in KB/s | `1024` |
79
+ | `HYPERPROBE_MAX_LAG_MS` | Safety threshold for thread lag | `50` |
80
+ | `HYPERPROBE_PAUSE_BUDGET_MS` | Maximum probe execution pause per second | `15` |
81
+ | `HYPERPROBE_REDACT_KEYS` | Comma-separated list of sensitive key patterns | `password,secret,token,...` |
82
+ | `HYPERPROBE_REDACT_VALUES` | Comma-separated list of sensitive value regexes | `""` |
83
+
84
+ ---
85
+
86
+ ## ☕ JRuby Support (Ruby on the JVM)
87
+
88
+ HyperProbe provides native, first-class support for JRuby applications running on the JVM.
89
+
90
+ ### Prerequisites for JRuby
91
+ 1. **JRuby Version**: JRuby 9.3+, 9.4+, or 10.x+.
92
+ 2. **Java Runtime**: Java 11, 17, 21, or 25+.
93
+ 3. **Debug Flag**: The JVM must be launched with `--debug` or `JRUBY_OPTS="--debug"`.
94
+
95
+ ### Why is `--debug` Required on JRuby?
96
+ JRuby's JIT (Just-In-Time) compiler aggressively optimizes hot methods into raw JVM bytecode, stripping out line-level checkpoints and local variable names by default.
97
+
98
+ Running with `--debug` instructs JRuby's JVM compiler to:
99
+ * Retain line-by-line tracing checkpoints so probes trigger reliably on every execution.
100
+ * Preserve local variable tables in memory for snapshot capture.
101
+
102
+ > **Pre-Flight Protection**: If JRuby is started without `--debug`, HyperProbe detects this at startup, logs an informative error message, and safely aborts initialization without attaching hooks or degrading performance.
103
+
104
+ ### How to Run JRuby with HyperProbe
105
+
106
+ #### 1. In Docker / Kubernetes
107
+ Add the environment variable in your `Dockerfile` or Deployment manifest:
108
+ ```dockerfile
109
+ ENV JRUBY_OPTS="--debug"
110
+ ```
111
+
112
+ #### 2. Command Line / Local
113
+ Launch JRuby with the `--debug` flag:
114
+ ```bash
115
+ jruby --debug app.rb
116
+ # or:
117
+ JRUBY_OPTS="--debug" jruby app.rb
118
+ ```
119
+
120
+ ### JRuby Architecture Highlights
121
+ * **Zero C-Extensions**: On JRuby, HyperProbe automatically uses Java's built-in `java.net.http.HttpClient` (HTTP/2) for gRPC communication to the Logger. No native compilation or C-extensions are required.
122
+ * **Hybrid Object Serialization**: Automatically detects and serializes both native Ruby objects and Java collections (`java.util.Map`, `java.util.List`, `java.util.Set`, and Java POJOs).
123
+ * **Multi-Threaded Safety**: Thread-safe synchronization across parallel JVM worker threads.
124
+
125
+ ---
126
+
127
+ ## Puma / Unicorn Multi-Worker Integration
128
+
129
+ When using multi-worker servers like Puma or Unicorn in cluster mode on CRuby:
130
+
131
+ ```ruby
132
+ # config/puma.rb
133
+ on_worker_boot do
134
+ HyperProbe.after_fork
135
+ end
136
+ ```
137
+
138
+ *(Note: On JRuby, Puma runs in multi-threaded mode on the JVM without process forking, so `after_fork` is not required).*
139
+
140
+ ---
141
+
142
+ ## AWS Lambda Integration
143
+
144
+ ```ruby
145
+ require 'hyperprobe'
146
+
147
+ handler = ->(event:, context:) {
148
+ # Handler logic
149
+ { statusCode: 200, body: "OK" }
150
+ }
151
+
152
+ exports.handler = HyperProbe.wrap_lambda(
153
+ service_id: '<service-uuid>',
154
+ environment: 'production',
155
+ broker_url: 'https://logger.app.hyperprobe.co'
156
+ ) { |event:, context:| handler.call(event: event, context: context) }
157
+ ```
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'uri'
5
+ require 'thread'
6
+ require_relative 'version'
7
+ require_relative 'core/quota'
8
+ require_relative 'core/safety'
9
+ require_relative 'core/serializer'
10
+ require_relative 'core/evaluator'
11
+ require_relative 'core/trace_extractor'
12
+ require_relative 'core/monitoring_engine'
13
+ require_relative 'core/broker'
14
+
15
+ module HyperProbe
16
+ UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.freeze
17
+
18
+ class Agent
19
+ attr_reader :options, :agent_id, :owner_pid, :is_shutdown, :active_probes, :global_config, :quota_manager, :safety_monitor, :broker_client, :engine
20
+
21
+ def initialize(options = {})
22
+ @options = (options || {}).dup
23
+ @owner_pid = Process.pid
24
+ @agent_id = SecureRandom.uuid
25
+ @is_shutdown = false
26
+ @is_agent_disabled = false
27
+ @stop_event = ConditionVariable.new
28
+ @mutex = Monitor.new # Reentrant mutex in Ruby
29
+
30
+ @log = Core::Logger.get_logger('hyperprobe:agent')
31
+ @log_broker = Core::Logger.get_logger('hyperprobe:broker')
32
+ @log_safety = Core::Logger.get_logger('hyperprobe:safety')
33
+ @log_stats = Core::Logger.get_logger('hyperprobe:stats')
34
+
35
+ parse_configuration
36
+
37
+ @quota_manager = Core::QuotaManager.new(@hits_per_sec, @bandwidth_kb_per_sec * 1024)
38
+ @safety_monitor = Core::SafetyMonitor.new(
39
+ method(:handle_health_change),
40
+ max_lag_ms: @max_lag_ms,
41
+ pause_budget_ms: @pause_budget_ms,
42
+ is_ephemeral_lambda: @is_ephemeral_lambda
43
+ )
44
+
45
+ @broker_client = Core::BrokerClient.new(
46
+ broker_url: @broker_url,
47
+ service_id: @service_id,
48
+ environment: @environment,
49
+ commit_sha: @commit_sha,
50
+ agent_id: @agent_id,
51
+ agent_version: VERSION,
52
+ rpc_timeout_sec: @rpc_timeout_sec,
53
+ enable_keep_alive: @enable_keep_alive
54
+ )
55
+
56
+ @telemetry_queue = Queue.new
57
+ @active_probes = {}
58
+ @local_hits = {}
59
+ @cooldown_timer = nil
60
+
61
+ @engine = Core::MonitoringEngine.new(
62
+ @quota_manager,
63
+ @safety_monitor,
64
+ method(:handle_capture),
65
+ @options[:set_trace_id]
66
+ )
67
+ @engine.set_global_config(@global_config)
68
+
69
+ @sync_thread = nil
70
+ @flush_thread = nil
71
+ @stats_thread = nil
72
+
73
+ if @is_ephemeral_lambda
74
+ @log.debug 'Running in Ephemeral AWS Lambda Mode.'
75
+ else
76
+ @safety_monitor.start
77
+ start_background_loops
78
+ sync_with_broker
79
+ end
80
+
81
+ @log.info "Agent started for #{@service_id} in #{@environment} (v#{VERSION})"
82
+ end
83
+
84
+ def agent_disabled?
85
+ @is_agent_disabled
86
+ end
87
+
88
+ def telemetry_queue_length
89
+ @telemetry_queue.size
90
+ end
91
+
92
+ def force_sync(timeout_sec = nil)
93
+ return if @is_shutdown || @is_agent_disabled
94
+
95
+ sync_with_broker(timeout_sec)
96
+ end
97
+
98
+ def force_flush(timeout_sec = nil)
99
+ return if @is_shutdown || @is_agent_disabled
100
+
101
+ flush_telemetry(timeout_sec)
102
+ end
103
+
104
+ def shutdown
105
+ @mutex.synchronize do
106
+ return if @is_shutdown
107
+
108
+ @is_shutdown = true
109
+ end
110
+
111
+ @safety_monitor&.stop
112
+ @engine&.close
113
+ @cooldown_timer&.exit if @cooldown_timer&.alive?
114
+ @broker_client&.shutdown
115
+
116
+ [@sync_thread, @flush_thread, @stats_thread].compact.each do |t|
117
+ t.kill if t.alive? && t != Thread.current
118
+ t.join(1.0) rescue nil
119
+ end
120
+ end
121
+
122
+ def after_fork
123
+ return if defined?(JRUBY_VERSION)
124
+
125
+ @mutex.synchronize do
126
+ return if @is_shutdown
127
+
128
+ @owner_pid = Process.pid
129
+ @agent_id = SecureRandom.uuid
130
+
131
+ # Reset queues and client for new process
132
+ @telemetry_queue = Queue.new
133
+ @broker_client = Core::BrokerClient.new(
134
+ broker_url: @broker_url,
135
+ service_id: @service_id,
136
+ environment: @environment,
137
+ commit_sha: @commit_sha,
138
+ agent_id: @agent_id,
139
+ agent_version: VERSION,
140
+ rpc_timeout_sec: @rpc_timeout_sec,
141
+ enable_keep_alive: @enable_keep_alive
142
+ )
143
+
144
+ @safety_monitor.start
145
+ start_background_loops
146
+ sync_with_broker
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def parse_configuration
153
+ @service_id = get_opt(:service_id, :serviceId) || ENV['HYPERPROBE_SERVICE_ID']
154
+ @environment = get_opt(:environment) || ENV['HYPERPROBE_ENVIRONMENT']
155
+ @broker_url = get_opt(:broker_url, :brokerUrl) || ENV['HYPERPROBE_BROKER_URL']
156
+ @commit_sha = get_opt(:commit_sha, :commitSha) || ENV['GIT_COMMIT'] || ENV['HYPERPROBE_COMMIT_SHA']
157
+
158
+ @sync_interval_sec = ((get_opt(:sync_interval_ms, :syncIntervalMs) || parse_env_int('HYPERPROBE_SYNC_INTERVAL_MS', 60_000)).to_f / 1000.0)
159
+ @flush_interval_sec = ((get_opt(:flush_interval_ms, :flushIntervalMs) || parse_env_int('HYPERPROBE_FLUSH_INTERVAL_MS', 1000)).to_f / 1000.0)
160
+ @max_queue_size = get_opt(:max_queue_size, :maxQueueSize) || parse_env_int('HYPERPROBE_MAX_QUEUE_SIZE', 100)
161
+ @cooldown_sec = get_opt(:cooldown_sec, :cooldownSec) || parse_env_int('HYPERPROBE_COOLDOWN_SEC', 10)
162
+
163
+ @hits_per_sec = get_opt(:hits_per_sec, :hitsPerSec) || parse_env_int('HYPERPROBE_HITS_PER_SEC', 10)
164
+ @bandwidth_kb_per_sec = get_opt(:bandwidth_kb_per_sec, :bandwidthKbPerSec) || parse_env_int('HYPERPROBE_BANDWIDTH_KB_PER_SEC', 1024)
165
+ @max_lag_ms = (get_opt(:max_lag_ms, :maxLagMs) || parse_env_int('HYPERPROBE_MAX_LAG_MS', 50)).to_f
166
+ @pause_budget_ms = (get_opt(:pause_budget_ms, :pauseBudgetMs) || parse_env_int('HYPERPROBE_PAUSE_BUDGET_MS', 15)).to_f
167
+ @rpc_timeout_sec = (get_opt(:rpc_timeout_sec, :rpcTimeoutSec) || parse_env_int('HYPERPROBE_RPC_TIMEOUT_SEC', 10)).to_f
168
+
169
+ is_aws_lambda = !ENV['AWS_LAMBDA_FUNCTION_NAME'].nil?
170
+ is_local_emulator = ENV['IS_OFFLINE'] == 'true' || ENV['AWS_SAM_LOCAL'] == 'true'
171
+ @is_ephemeral_lambda = (get_opt(:is_lambda, :isLambda) || is_aws_lambda) && !is_local_emulator
172
+ enable_keep_alive_opt = get_opt(:enable_keep_alive, :enableKeepAlive)
173
+ @enable_keep_alive = enable_keep_alive_opt.nil? ? !@is_ephemeral_lambda : enable_keep_alive_opt
174
+
175
+ redact_keys_raw = get_opt(:redact_keys, :redactKeys) || (ENV['HYPERPROBE_REDACT_KEYS'] ? ENV['HYPERPROBE_REDACT_KEYS'].split(',') : %w[password secret token authorization cookie key signature])
176
+ redact_values_raw = get_opt(:redact_values, :redactValues) || (ENV['HYPERPROBE_REDACT_VALUES'] ? ENV['HYPERPROBE_REDACT_VALUES'].split(',') : [])
177
+
178
+ @global_config = {
179
+ redact_keys: Array(redact_keys_raw).map(&:to_s).map(&:strip).reject(&:empty?),
180
+ redact_values: Array(redact_values_raw).map(&:to_s).map(&:strip).reject(&:empty?),
181
+ max_object_depth: get_opt(:max_object_depth, :maxObjectDepth) || parse_env_int('HYPERPROBE_MAX_OBJECT_DEPTH', 3),
182
+ max_array_length: get_opt(:max_array_length, :maxArrayLength) || parse_env_int('HYPERPROBE_MAX_ARRAY_LENGTH', 3),
183
+ stack_frame_depth: get_opt(:stack_frame_depth, :stackFrameDepth) || parse_env_int('HYPERPROBE_STACK_FRAME_DEPTH', 3),
184
+ max_object_properties: get_opt(:max_object_properties, :maxObjectProperties) || parse_env_int('HYPERPROBE_MAX_OBJECT_PROPERTIES', 50),
185
+ max_string_length: get_opt(:max_string_length, :maxStringLength) || parse_env_int('HYPERPROBE_MAX_STRING_LENGTH', 1024)
186
+ }
187
+ end
188
+
189
+ def get_opt(*keys)
190
+ keys.each do |k|
191
+ return @options[k] if @options.key?(k)
192
+ return @options[k.to_s] if @options.key?(k.to_s)
193
+ end
194
+ nil
195
+ end
196
+
197
+ def parse_env_int(key, default_val)
198
+ val = ENV[key]
199
+ return default_val if val.nil? || val.strip.empty?
200
+
201
+ val.to_i rescue default_val
202
+ end
203
+
204
+ def start_background_loops
205
+ @sync_thread = Thread.new { sync_loop }
206
+ @sync_thread.name = 'hyperprobe-sync' if @sync_thread.respond_to?(:name=)
207
+
208
+ @flush_thread = Thread.new { flush_loop }
209
+ @flush_thread.name = 'hyperprobe-flush' if @flush_thread.respond_to?(:name=)
210
+
211
+ @stats_thread = Thread.new { stats_loop }
212
+ @stats_thread.name = 'hyperprobe-stats' if @stats_thread.respond_to?(:name=)
213
+ end
214
+
215
+ def sync_loop
216
+ until @is_shutdown
217
+ sleep @sync_interval_sec
218
+ break if @is_shutdown
219
+
220
+ begin
221
+ sync_with_broker
222
+ rescue StandardError => e
223
+ # Silently handle transient sync error
224
+ end
225
+ end
226
+ end
227
+
228
+ def sync_with_broker(timeout_sec = nil)
229
+ return if @is_shutdown
230
+
231
+ @log_broker.debug "sync_with_broker started #{timeout_sec ? "with timeout #{timeout_sec}" : ''}"
232
+ response = @broker_client.get_probes(timeout_sec)
233
+ return unless response
234
+
235
+ @log_broker.debug "sync_with_broker: got #{response.probes.length} probes from broker"
236
+
237
+ # Dynamic global config updates from broker
238
+ if response.global_config
239
+ gc = response.global_config
240
+ @mutex.synchronize do
241
+ @global_config[:redact_keys] = gc.redact_keys.to_a unless gc.redact_keys.empty?
242
+ @global_config[:redact_values] = gc.redact_values.to_a unless gc.redact_values.empty?
243
+ @global_config[:max_object_depth] = gc.max_object_depth if gc.max_object_depth.positive?
244
+ @global_config[:max_array_length] = gc.max_array_length if gc.max_array_length.positive?
245
+ @global_config[:stack_frame_depth] = gc.stack_frame_depth if gc.stack_frame_depth.positive?
246
+ @global_config[:max_object_properties] = gc.max_object_properties if gc.max_object_properties.positive?
247
+ @global_config[:max_string_length] = gc.max_string_length if gc.max_string_length.positive?
248
+ end
249
+ @engine.set_global_config(@global_config)
250
+ end
251
+
252
+ server_probes = response.probes.to_a
253
+ server_probe_ids = server_probes.map(&:id)
254
+
255
+ to_apply = []
256
+ now_ms = (Time.now.to_f * 1000).to_i
257
+
258
+ @mutex.synchronize do
259
+ # Clean stale local probes
260
+ @active_probes.delete_if { |pid, _| !server_probe_ids.include?(pid) }
261
+ @local_hits.delete_if { |pid, _| !server_probe_ids.include?(pid) }
262
+
263
+ server_probes.each do |probe|
264
+ hits = @local_hits[probe.id] || 0
265
+ is_expired = probe.expiry_time.positive? && probe.expiry_time <= now_ms
266
+
267
+ if hits < probe.hit_limit && !is_expired
268
+ to_apply << probe
269
+ @active_probes[probe.id] = probe
270
+ else
271
+ @active_probes.delete(probe.id)
272
+ end
273
+ end
274
+ end
275
+
276
+ @log_broker.debug "Found #{to_apply.length} active probes to apply locally"
277
+ @engine.set_probes(to_apply)
278
+ true
279
+ rescue StandardError => e
280
+ @log_broker.error("Failed to sync with broker: #{e.message}", e)
281
+ false
282
+ end
283
+
284
+ def flush_loop
285
+ until @is_shutdown
286
+ sleep @flush_interval_sec
287
+ break if @is_shutdown
288
+
289
+ begin
290
+ flush_telemetry
291
+ rescue StandardError => e
292
+ # Silently handle flush loop error
293
+ end
294
+ end
295
+ end
296
+
297
+ def flush_telemetry(timeout_sec = nil)
298
+ return if @is_shutdown || @telemetry_queue.empty?
299
+
300
+ batch = []
301
+ until @telemetry_queue.empty?
302
+ begin
303
+ batch << @telemetry_queue.pop(true)
304
+ rescue ThreadError
305
+ break
306
+ end
307
+ end
308
+
309
+ return if batch.empty?
310
+
311
+ @log_broker.info "Flushing #{batch.length} telemetry events to broker..."
312
+
313
+ begin
314
+ events = batch.map { |item| item[:event] }
315
+ finished_probe_ids = @broker_client.report_telemetry(events, timeout_sec)
316
+
317
+ @log_broker.info "Successfully flushed #{batch.length} telemetry events to broker"
318
+
319
+ # Commit bandwidth reservations
320
+ batch.each do |item|
321
+ item[:reservation]&.commit
322
+ end
323
+
324
+ # Handle globally finished probes
325
+ if finished_probe_ids && !finished_probe_ids.empty?
326
+ changed = false
327
+ @mutex.synchronize do
328
+ finished_probe_ids.each do |pid|
329
+ if @active_probes.key?(pid)
330
+ @active_probes.delete(pid)
331
+ changed = true
332
+ end
333
+ end
334
+ end
335
+ @engine.set_probes(@active_probes.values) if changed
336
+ end
337
+ rescue StandardError => e
338
+ @log_broker.error("Failed to flush telemetry: #{e.message}", e)
339
+ # On flush failure, re-queue events up to max capacity
340
+ batch.each do |item|
341
+ item[:reservation]&.release
342
+ if @telemetry_queue.size < @max_queue_size
343
+ @telemetry_queue.push(item)
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ def stats_loop
350
+ until @is_shutdown
351
+ sleep 5.0
352
+ break if @is_shutdown
353
+
354
+ begin
355
+ stats = @engine.get_stats
356
+ if stats[:hits].positive? || stats[:skips].positive?
357
+ @log_stats.info "Probes Hit: #{stats[:hits]}, Probes Skipped: #{stats[:skips]}"
358
+ end
359
+ rescue StandardError
360
+ # Ignore stats error
361
+ end
362
+ end
363
+ end
364
+
365
+ def handle_capture(event)
366
+ return if @is_shutdown || @is_agent_disabled
367
+
368
+ probe_id = event[:probe_id]
369
+ @log.debug "Captured event for probe #{probe_id}"
370
+ probe = nil
371
+ hits = 0
372
+
373
+ @mutex.synchronize do
374
+ probe = @active_probes[probe_id]
375
+ return unless probe
376
+
377
+ hits = (@local_hits[probe_id] || 0) + 1
378
+ @local_hits[probe_id] = hits
379
+ end
380
+
381
+ # Reserve bandwidth
382
+ event_size = @broker_client.estimate_event_size(event)
383
+ reservation = @quota_manager.reserve_bandwidth(event_size)
384
+ unless reservation
385
+ @log.debug "Bandwidth quota exceeded; dropping event for probe #{probe_id}"
386
+ return
387
+ end
388
+
389
+ # Admission to telemetry queue
390
+ item = { event: event, reservation: reservation }
391
+ if @telemetry_queue.size < @max_queue_size
392
+ @telemetry_queue.push(item)
393
+ else
394
+ @log.debug "Telemetry queue full. Dropping event for probe #{probe_id}"
395
+ reservation.release
396
+ end
397
+
398
+ # Enforce hitLimit fuse
399
+ hit_limit = probe.respond_to?(:hit_limit) ? probe.hit_limit : probe[:hit_limit]
400
+ if hits >= hit_limit
401
+ remaining_probes = nil
402
+ @mutex.synchronize do
403
+ @active_probes.delete(probe_id)
404
+ remaining_probes = @active_probes.values
405
+ end
406
+ @engine.set_probes(remaining_probes)
407
+ end
408
+ end
409
+
410
+ def handle_health_change(health, reason = nil)
411
+ return if @is_shutdown
412
+
413
+ case health
414
+ when Core::AgentHealth::RED
415
+ @log_safety.error "Safety Shield Triggered: RED Status. #{reason || ''}"
416
+ @engine.suspend
417
+
418
+ @cooldown_timer&.exit if @cooldown_timer&.alive?
419
+ cooldown_duration = @cooldown_sec
420
+ @cooldown_timer = Thread.new do
421
+ @log_safety.info "Cooldown period started (#{cooldown_duration}s)..."
422
+ sleep cooldown_duration
423
+ if @safety_monitor.get_health == Core::AgentHealth::GREEN
424
+ @log_safety.info "Cooldown period ended. Resuming instrumentation..."
425
+ @engine.resume
426
+ end
427
+ end
428
+ when Core::AgentHealth::YELLOW
429
+ @log_safety.warn "Safety Warning: YELLOW Status (Moderate Overhead). #{reason || ''}"
430
+ when Core::AgentHealth::GREEN
431
+ @log_safety.info "Status back to GREEN. #{reason || ''}"
432
+ if @engine.is_suspended && (!@cooldown_timer || !@cooldown_timer.alive?)
433
+ @engine.resume
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end