hyperprobe-agent 1.2.27.pre.3-java

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,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'uri'
5
+ require 'thread'
6
+ require 'monitor'
7
+ require_relative 'version'
8
+ require_relative 'core/quota'
9
+ require_relative 'core/safety'
10
+ require_relative 'core/serializer'
11
+ require_relative 'core/evaluator'
12
+ require_relative 'core/trace_extractor'
13
+ require_relative 'core/monitoring_engine'
14
+ require_relative 'core/broker'
15
+
16
+ module HyperProbe
17
+ 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
18
+
19
+ class Agent
20
+ attr_reader :options, :agent_id, :owner_pid, :is_shutdown, :active_probes, :global_config, :quota_manager, :safety_monitor, :broker_client, :engine
21
+
22
+ def initialize(options = {})
23
+ @options = (options || {}).dup
24
+ @owner_pid = Process.pid
25
+ @agent_id = SecureRandom.uuid
26
+ @is_shutdown = false
27
+ @is_agent_disabled = false
28
+ @mutex = Monitor.new # Reentrant mutex in Ruby
29
+ @stop_event = @mutex.new_cond
30
+ @sync_mutex = Mutex.new
31
+ @flush_mutex = Mutex.new
32
+ @shutdown_mutex = Mutex.new
33
+ @probe_generation = 0
34
+ @pending_probe_update = nil
35
+ @finished_probes = {}
36
+ @telemetry_queue = Queue.new
37
+ @active_probes = {}
38
+ @local_hits = {}
39
+
40
+ @log = Core::Logger.get_logger('hyperprobe:agent')
41
+ @log_broker = Core::Logger.get_logger('hyperprobe:broker')
42
+ @log_safety = Core::Logger.get_logger('hyperprobe:safety')
43
+ @log_stats = Core::Logger.get_logger('hyperprobe:stats')
44
+
45
+ parse_configuration
46
+
47
+ if @global_config[:disable_safe_evaluation]
48
+ @log.warn 'Safe evaluation is DISABLED. Probe expressions can execute arbitrary Ruby code, mutate application state, or block execution.'
49
+ end
50
+
51
+ @quota_manager = Core::QuotaManager.new(@hits_per_sec, @bandwidth_kb_per_sec * 1024)
52
+ @safety_monitor = Core::SafetyMonitor.new(
53
+ method(:handle_health_change),
54
+ max_lag_ms: @max_lag_ms,
55
+ pause_budget_ms: @pause_budget_ms,
56
+ is_ephemeral_lambda: @is_ephemeral_lambda
57
+ )
58
+
59
+ @broker_client = Core::BrokerClient.new(
60
+ broker_url: @broker_url,
61
+ service_id: @service_id,
62
+ environment: @environment,
63
+ commit_sha: @commit_sha,
64
+ agent_id: @agent_id,
65
+ agent_version: VERSION,
66
+ rpc_timeout_sec: @rpc_timeout_sec,
67
+ enable_keep_alive: @enable_keep_alive
68
+ )
69
+
70
+ @cooldown_timer = nil
71
+ @cooldown_until = nil
72
+
73
+ @engine = Core::MonitoringEngine.new(
74
+ @quota_manager,
75
+ @safety_monitor,
76
+ method(:handle_capture),
77
+ @options[:set_trace_id]
78
+ )
79
+ @engine.set_global_config(@global_config)
80
+
81
+ @sync_thread = nil
82
+ @flush_thread = nil
83
+ @stats_thread = nil
84
+ @apply_thread = Thread.new { apply_loop }
85
+ @apply_thread.name = 'hyperprobe-apply'
86
+
87
+ if @is_ephemeral_lambda
88
+ @log.debug 'Running in Ephemeral AWS Lambda Mode.'
89
+ else
90
+ @safety_monitor.start
91
+ start_background_loops
92
+ end
93
+
94
+ @log.info "Agent started for #{@service_id} in #{@environment} (v#{VERSION})"
95
+ rescue SignalException, SystemExit
96
+ shutdown
97
+ raise
98
+ rescue Exception # Agent-owned startup failures must not escape into the host.
99
+ shutdown
100
+ end
101
+
102
+ def agent_disabled?
103
+ @is_agent_disabled
104
+ end
105
+
106
+ def telemetry_queue_length
107
+ @telemetry_queue&.size || 0
108
+ end
109
+
110
+ def force_sync(timeout_sec = nil)
111
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
112
+
113
+ sync_with_broker(timeout_sec)
114
+ end
115
+
116
+ def force_flush(timeout_sec = nil)
117
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
118
+
119
+ flush_telemetry(timeout_sec)
120
+ end
121
+
122
+ def shutdown
123
+ return after_fork if @owner_pid && @owner_pid != Process.pid
124
+ @shutdown_mutex ||= Mutex.new
125
+ return unless @shutdown_mutex.try_lock
126
+
127
+ shutdown_locked = true
128
+ return if @is_shutdown
129
+
130
+ @is_shutdown = true
131
+ @is_agent_disabled = true
132
+ threads = [@sync_thread, @flush_thread, @stats_thread, @apply_thread, @cooldown_timer].compact
133
+ threads.each { |thread| thread.kill unless thread == Thread.current }
134
+
135
+ # Cleanup independently: a broken or stuck resource cannot prevent the rest.
136
+ cleanup = [[@engine, :close], [@safety_monitor, :stop], [@broker_client, :shutdown]].each_with_object([]) do |(resource, method), workers|
137
+ next unless resource
138
+
139
+ workers << Thread.new do
140
+ resource.public_send(method)
141
+ rescue Exception
142
+ # Only agent-owned cleanup runs in this thread.
143
+ end
144
+ end
145
+ cleanup << Thread.new do
146
+ @mutex.synchronize do
147
+ until @telemetry_queue.empty?
148
+ settle_reservation(@telemetry_queue.pop(true)[:reservation], :release)
149
+ end
150
+ end
151
+ rescue Exception
152
+ # Reservations belong only to the stopped agent.
153
+ end
154
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
155
+ (threads + cleanup).each do |thread|
156
+ next if thread == Thread.current
157
+
158
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ thread.join(remaining) if remaining.positive?
160
+ rescue SignalException, SystemExit
161
+ raise
162
+ rescue Exception
163
+ # Continue closing the other resources, including from a signal trap.
164
+ end
165
+ cleanup.each do |thread|
166
+ thread.kill if thread.alive?
167
+ thread.join(0.01)
168
+ rescue SignalException, SystemExit
169
+ raise
170
+ rescue Exception
171
+ # Never wait indefinitely for a broken cleanup thread.
172
+ end
173
+ rescue SignalException, SystemExit
174
+ raise
175
+ rescue Exception
176
+ @is_shutdown = true
177
+ @is_agent_disabled = true
178
+ ensure
179
+ @shutdown_mutex.unlock if shutdown_locked
180
+ end
181
+
182
+ def after_fork
183
+ return if @owner_pid == Process.pid
184
+
185
+ # Never call, close, or replace an inherited native gRPC client here.
186
+ @is_shutdown = true
187
+ @is_agent_disabled = true
188
+ @engine&.after_fork
189
+ false
190
+ rescue SignalException, SystemExit
191
+ raise
192
+ rescue Exception
193
+ false
194
+ end
195
+
196
+ private
197
+
198
+ def parse_configuration
199
+ @service_id = get_opt(:service_id, :serviceId) || ENV['HYPERPROBE_SERVICE_ID']
200
+ @environment = get_opt(:environment) || ENV['HYPERPROBE_ENVIRONMENT']
201
+ @broker_url = get_opt(:broker_url, :brokerUrl) || ENV['HYPERPROBE_BROKER_URL']
202
+ @commit_sha = get_opt(:commit_sha, :commitSha) || ENV['GIT_COMMIT'] || ENV['HYPERPROBE_COMMIT_SHA']
203
+
204
+ raw_sync = get_opt(:sync_interval_ms, :syncIntervalMs) || parse_env_int('HYPERPROBE_SYNC_INTERVAL_MS', 60_000)
205
+ @sync_interval_sec = [raw_sync.to_f / 1000.0, 0.1].max
206
+
207
+ raw_flush = get_opt(:flush_interval_ms, :flushIntervalMs) || parse_env_int('HYPERPROBE_FLUSH_INTERVAL_MS', 1000)
208
+ @flush_interval_sec = [raw_flush.to_f / 1000.0, 0.05].max
209
+ @max_queue_size = get_opt(:max_queue_size, :maxQueueSize) || parse_env_int('HYPERPROBE_MAX_QUEUE_SIZE', 100)
210
+ @cooldown_sec = get_opt(:cooldown_sec, :cooldownSec) || parse_env_int('HYPERPROBE_COOLDOWN_SEC', 10)
211
+
212
+ @hits_per_sec = get_opt(:hits_per_sec, :hitsPerSec) || parse_env_int('HYPERPROBE_HITS_PER_SEC', 10)
213
+ @bandwidth_kb_per_sec = get_opt(:bandwidth_kb_per_sec, :bandwidthKbPerSec) || parse_env_int('HYPERPROBE_BANDWIDTH_KB_PER_SEC', 1024)
214
+ @max_lag_ms = (get_opt(:max_lag_ms, :maxLagMs) || parse_env_int('HYPERPROBE_MAX_LAG_MS', 50)).to_f
215
+ @pause_budget_ms = (get_opt(:pause_budget_ms, :pauseBudgetMs) || parse_env_int('HYPERPROBE_PAUSE_BUDGET_MS', 15)).to_f
216
+ @rpc_timeout_sec = (get_opt(:rpc_timeout_sec, :rpcTimeoutSec) || parse_env_int('HYPERPROBE_RPC_TIMEOUT_SEC', 10)).to_f
217
+
218
+ is_aws_lambda = !ENV['AWS_LAMBDA_FUNCTION_NAME'].nil?
219
+ is_local_emulator = ENV['IS_OFFLINE'] == 'true' || ENV['AWS_SAM_LOCAL'] == 'true'
220
+ lambda_opt = get_opt(:is_lambda, :isLambda)
221
+ @is_ephemeral_lambda = (lambda_opt.nil? ? is_aws_lambda : lambda_opt) && !is_local_emulator
222
+ enable_keep_alive_opt = get_opt(:enable_keep_alive, :enableKeepAlive)
223
+ @enable_keep_alive = enable_keep_alive_opt.nil? ? !@is_ephemeral_lambda : enable_keep_alive_opt
224
+
225
+ 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])
226
+ redact_values_raw = get_opt(:redact_values, :redactValues) || (ENV['HYPERPROBE_REDACT_VALUES'] ? ENV['HYPERPROBE_REDACT_VALUES'].split(',') : [])
227
+
228
+ @global_config = {
229
+ redact_keys: Array(redact_keys_raw).map(&:to_s).map(&:strip).reject(&:empty?),
230
+ redact_values: Array(redact_values_raw).map(&:to_s).map(&:strip).reject(&:empty?),
231
+ max_object_depth: get_opt(:max_object_depth, :maxObjectDepth) || parse_env_int('HYPERPROBE_MAX_OBJECT_DEPTH', 3),
232
+ max_array_length: get_opt(:max_array_length, :maxArrayLength) || parse_env_int('HYPERPROBE_MAX_ARRAY_LENGTH', 3),
233
+ stack_frame_depth: get_opt(:stack_frame_depth, :stackFrameDepth) || parse_env_int('HYPERPROBE_STACK_FRAME_DEPTH', 3),
234
+ max_object_properties: get_opt(:max_object_properties, :maxObjectProperties) || parse_env_int('HYPERPROBE_MAX_OBJECT_PROPERTIES', 50),
235
+ max_string_length: get_opt(:max_string_length, :maxStringLength) || parse_env_int('HYPERPROBE_MAX_STRING_LENGTH', 1024),
236
+ capture_closures: get_opt(:capture_closures, :captureClosures) == true,
237
+ disable_safe_evaluation: !Core::Evaluator.safe_evaluation_enabled?(get_opt(:disable_safe_evaluation, :disableSafeEvaluation))
238
+ }
239
+ end
240
+
241
+ def get_opt(*keys)
242
+ keys.each do |k|
243
+ return @options[k] if @options.key?(k)
244
+ return @options[k.to_s] if @options.key?(k.to_s)
245
+ end
246
+ nil
247
+ end
248
+
249
+ def parse_env_int(key, default_val)
250
+ val = ENV[key]
251
+ return default_val if val.nil? || val.strip.empty?
252
+
253
+ val.to_i rescue default_val
254
+ end
255
+
256
+ def start_background_loops
257
+ @sync_thread = Thread.new { sync_loop }
258
+ @sync_thread.name = 'hyperprobe-sync' if @sync_thread.respond_to?(:name=)
259
+
260
+ @flush_thread = Thread.new { flush_loop }
261
+ @flush_thread.name = 'hyperprobe-flush' if @flush_thread.respond_to?(:name=)
262
+
263
+ @stats_thread = Thread.new { stats_loop }
264
+ @stats_thread.name = 'hyperprobe-stats' if @stats_thread.respond_to?(:name=)
265
+ end
266
+
267
+ def sync_loop
268
+ until @is_shutdown
269
+ begin
270
+ sync_with_broker
271
+ sleep @sync_interval_sec
272
+ rescue Exception
273
+ # Silently handle transient sync error
274
+ break
275
+ end
276
+ end
277
+ end
278
+
279
+ def sync_with_broker(timeout_sec = nil)
280
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
281
+ return false unless @sync_mutex.try_lock
282
+
283
+ sync_locked = true
284
+
285
+ @log_broker.debug "sync_with_broker started #{timeout_sec ? "with timeout #{timeout_sec}" : ''}"
286
+ response = @broker_client.get_probes(timeout_sec)
287
+ return unless response
288
+
289
+ @log_broker.debug "sync_with_broker: got #{response.probes.length} probes from broker"
290
+
291
+ server_probes = response.probes.to_a
292
+ server_probe_ids = server_probes.map(&:id)
293
+ now_ms = (Time.now.to_f * 1000).to_i
294
+ config = nil
295
+ snapshot = @mutex.synchronize do
296
+ return false if @is_shutdown
297
+
298
+ if response.global_config
299
+ gc = response.global_config
300
+ @global_config[:redact_keys] = gc.redact_keys.to_a unless gc.redact_keys.empty?
301
+ @global_config[:redact_values] = gc.redact_values.to_a unless gc.redact_values.empty?
302
+ %i[max_object_depth max_array_length stack_frame_depth max_object_properties max_string_length capture_closures].each do |field|
303
+ @global_config[field] = gc.public_send(field) if gc.public_send("has_#{field}?")
304
+ end
305
+ config = @global_config.dup
306
+ end
307
+ # Clean stale local probes
308
+ @active_probes.delete_if { |pid, _| !server_probe_ids.include?(pid) }
309
+ @local_hits.delete_if { |pid, _| !server_probe_ids.include?(pid) }
310
+ @finished_probes.delete_if { |pid, _| !server_probe_ids.include?(pid) }
311
+
312
+ server_probes.each do |probe|
313
+ hits = @local_hits[probe.id] || 0
314
+ is_expired = probe.expiry_time.positive? && probe.expiry_time <= now_ms
315
+
316
+ if hits < probe.hit_limit && !is_expired && !@finished_probes[probe.id]
317
+ @active_probes[probe.id] = probe
318
+ else
319
+ @active_probes.delete(probe.id)
320
+ end
321
+ end
322
+ probe_snapshot_locked
323
+ end
324
+
325
+ @engine.set_global_config(config) if config
326
+ @engine.set_probes(snapshot[0], generation: snapshot[1])
327
+ true
328
+ rescue SignalException, SystemExit
329
+ raise
330
+ rescue Exception
331
+ false
332
+ ensure
333
+ @sync_mutex.unlock if sync_locked
334
+ end
335
+
336
+ def flush_loop
337
+ until @is_shutdown
338
+ begin
339
+ sleep @flush_interval_sec
340
+ break if @is_shutdown
341
+
342
+ flush_telemetry
343
+ rescue Exception
344
+ # Silently handle flush loop error
345
+ end
346
+ end
347
+ end
348
+
349
+ def flush_telemetry(timeout_sec = nil)
350
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
351
+ return false unless @flush_mutex.try_lock
352
+
353
+ flush_locked = true
354
+
355
+ batch = []
356
+ @mutex.synchronize do
357
+ batch << @telemetry_queue.pop(true) until @telemetry_queue.empty?
358
+ end
359
+
360
+ return if batch.empty?
361
+
362
+ begin
363
+ events = batch.map { |item| item[:event] }
364
+ finished_probe_ids = @broker_client.report_telemetry(events, timeout_sec)
365
+ delivered = true
366
+
367
+ # Commit bandwidth reservations
368
+ batch.each do |item|
369
+ settle_reservation(item[:reservation], :commit)
370
+ end
371
+
372
+ # Handle globally finished probes
373
+ if finished_probe_ids && !finished_probe_ids.empty?
374
+ snapshot = @mutex.synchronize do
375
+ return if @is_shutdown
376
+
377
+ finished_probe_ids.each do |pid|
378
+ @finished_probes[pid] = true
379
+ @active_probes.delete(pid)
380
+ end
381
+ probe_snapshot_locked
382
+ end
383
+ @engine.set_probes(snapshot[0], generation: snapshot[1])
384
+ end
385
+ rescue SignalException, SystemExit
386
+ raise
387
+ rescue Exception
388
+ return false if delivered
389
+
390
+ # On flush failure, re-queue events up to max capacity
391
+ batch.each do |item|
392
+ retained = @mutex.synchronize do
393
+ if !@is_shutdown && @telemetry_queue.size < @max_queue_size
394
+ @telemetry_queue.push(item)
395
+ true
396
+ end
397
+ end
398
+ settle_reservation(item[:reservation], :release) unless retained
399
+ end
400
+ end
401
+ rescue SignalException, SystemExit
402
+ raise
403
+ rescue Exception
404
+ false
405
+ ensure
406
+ if @is_shutdown && batch && !delivered
407
+ batch.each { |item| settle_reservation(item[:reservation], :release) }
408
+ end
409
+ @flush_mutex.unlock if flush_locked
410
+ end
411
+
412
+ def stats_loop
413
+ until @is_shutdown
414
+ begin
415
+ sleep 5.0
416
+ break if @is_shutdown
417
+
418
+ stats = @engine.get_stats
419
+ if stats[:hits].positive? || stats[:skips].positive?
420
+ @log_stats.info "Probes Hit: #{stats[:hits]}, Probes Skipped: #{stats[:skips]}"
421
+ end
422
+ rescue Exception
423
+ # Ignore stats error
424
+ end
425
+ end
426
+ end
427
+
428
+ def handle_capture(event)
429
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
430
+ return unless @mutex.try_enter
431
+
432
+ locked = true
433
+ return if @is_shutdown || @is_agent_disabled
434
+
435
+ probe_id = event[:probe_id]
436
+ probe = @active_probes[probe_id]
437
+ return unless probe
438
+
439
+ raw_limit = probe.respond_to?(:hit_limit) ? probe.hit_limit : probe[:hit_limit]
440
+ hit_limit = raw_limit.to_i
441
+ hits = @local_hits[probe_id] || 0
442
+ return if hit_limit.positive? && hits >= hit_limit
443
+
444
+ # Attempted captures consume the safety fuse even when delivery is dropped.
445
+ @local_hits[probe_id] = hits + 1
446
+ if hit_limit.positive? && hits + 1 >= hit_limit
447
+ @active_probes.delete(probe_id)
448
+ @pending_probe_update = probe_snapshot_locked
449
+ @stop_event.broadcast
450
+ end
451
+
452
+ return if @telemetry_queue.size >= @max_queue_size
453
+
454
+ event_size = @broker_client.estimate_event_size(event)
455
+ reservation = @quota_manager.reserve_bandwidth(event_size)
456
+ return unless reservation
457
+
458
+ @telemetry_queue.push(event: event, reservation: reservation)
459
+ admitted = true
460
+ rescue SignalException, SystemExit
461
+ raise
462
+ rescue Exception
463
+ # This callback is agent code running on an application thread.
464
+ nil
465
+ ensure
466
+ @mutex.exit if locked
467
+ settle_reservation(reservation, :release) if reservation && !admitted
468
+ end
469
+
470
+ def settle_reservation(reservation, action)
471
+ reservation&.public_send(action)
472
+ rescue Exception
473
+ nil
474
+ end
475
+
476
+ def probe_snapshot_locked
477
+ @probe_generation += 1
478
+ [@active_probes.values, @probe_generation]
479
+ end
480
+
481
+ def apply_loop
482
+ loop do
483
+ snapshot = @mutex.synchronize do
484
+ @stop_event.wait until @is_shutdown || @pending_probe_update
485
+ break if @is_shutdown
486
+
487
+ update = @pending_probe_update
488
+ @pending_probe_update = nil
489
+ update
490
+ end
491
+ break unless snapshot
492
+
493
+ @engine.set_probes(snapshot[0], generation: snapshot[1])
494
+ end
495
+ rescue Exception
496
+ shutdown
497
+ end
498
+
499
+ def handle_health_change(health, reason = nil)
500
+ return if @owner_pid != Process.pid || @is_shutdown
501
+
502
+ case health
503
+ when Core::AgentHealth::RED
504
+ @log_safety.error "Safety Shield Triggered: RED Status. #{reason || ''}"
505
+ @engine.suspend
506
+
507
+ @cooldown_timer&.exit if @cooldown_timer&.alive?
508
+ cooldown_duration = @cooldown_sec
509
+ @cooldown_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + cooldown_duration
510
+ @cooldown_timer = Thread.new do
511
+ @log_safety.info "Cooldown period started (#{cooldown_duration}s)..."
512
+ sleep cooldown_duration
513
+ @safety_monitor.with_health(Core::AgentHealth::GREEN) do
514
+ if !@is_shutdown && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @cooldown_until
515
+ @log_safety.info "Cooldown period ended. Resuming instrumentation..."
516
+ @engine.resume
517
+ end
518
+ end
519
+ rescue Exception
520
+ # Agent-owned cooldown work must never abort the application.
521
+ end
522
+ @cooldown_timer.kill if @is_shutdown
523
+ when Core::AgentHealth::YELLOW
524
+ @log_safety.warn "Safety Warning: YELLOW Status (Moderate Overhead). #{reason || ''}"
525
+ when Core::AgentHealth::GREEN
526
+ @log_safety.info "Status back to GREEN. #{reason || ''}"
527
+ # A timer may be alive briefly after its final health check. Use the
528
+ # deadline, not thread liveness, so a concurrent GREEN is never lost.
529
+ if @engine.is_suspended && (!@cooldown_until || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @cooldown_until)
530
+ @engine.resume
531
+ end
532
+ end
533
+ rescue Exception
534
+ @is_agent_disabled = true
535
+ end
536
+ end
537
+ end