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.
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'socket'
5
+ require_relative '../protos'
6
+
7
+ if RUBY_PLATFORM !~ /java/
8
+ begin
9
+ require 'grpc'
10
+ rescue LoadError
11
+ # Handled gracefully if grpc gem is absent
12
+ end
13
+ else
14
+ require 'java'
15
+ end
16
+
17
+ module HyperProbe
18
+ module Core
19
+ class BrokerClient
20
+ attr_reader :agent_id, :service_id, :environment, :commit_sha, :agent_version, :stub
21
+
22
+ def initialize(
23
+ broker_url:,
24
+ service_id:,
25
+ environment:,
26
+ commit_sha:,
27
+ agent_id: nil,
28
+ agent_version: '1.0.0',
29
+ rpc_timeout_sec: 10.0,
30
+ enable_keep_alive: true
31
+ )
32
+ @service_id = service_id.to_s
33
+ @environment = environment.to_s
34
+ @commit_sha = commit_sha.to_s
35
+ @agent_id = agent_id || SecureRandom.uuid
36
+ @agent_version = agent_version.to_s
37
+ @rpc_timeout_sec = rpc_timeout_sec.to_f
38
+ @hostname = ENV['HOSTNAME'] || Socket.gethostname rescue 'unknown'
39
+ @is_jruby = RUBY_PLATFORM =~ /java/
40
+ @log = Logger.get_logger('hyperprobe:broker')
41
+
42
+ if @is_jruby
43
+ init_jruby_client(broker_url)
44
+ else
45
+ init_cruby_client(broker_url, enable_keep_alive)
46
+ end
47
+ end
48
+
49
+ def get_probes(timeout_sec = nil)
50
+ req = Hyperprobe::Agent::V1::GetProbesRequest.new(
51
+ agent_id: @agent_id,
52
+ service_id: @service_id,
53
+ environment: @environment,
54
+ commit_sha: @commit_sha,
55
+ language: @is_jruby ? 'jruby' : 'ruby',
56
+ agent_version: @agent_version,
57
+ hostname: @hostname
58
+ )
59
+
60
+ if @is_jruby
61
+ call_jruby_rpc('/hyperprobe.agent.v1.AgentBroker/GetProbes', req, Hyperprobe::Agent::V1::GetProbesResponse, timeout_sec)
62
+ else
63
+ deadline = calculate_deadline(timeout_sec || @rpc_timeout_sec)
64
+ metadata = { 'x-hp-service-id' => @service_id }
65
+ @stub.get_probes(req, metadata: metadata, deadline: deadline)
66
+ end
67
+ end
68
+
69
+ def report_telemetry(events, timeout_sec = nil)
70
+ return [] if events.nil? || events.empty?
71
+
72
+ proto_events = events.map { |evt| build_telemetry_event_proto(evt) }
73
+ batch = Hyperprobe::Agent::V1::TelemetryBatch.new(
74
+ agent_id: @agent_id,
75
+ events: proto_events
76
+ )
77
+
78
+ if @is_jruby
79
+ response = call_jruby_rpc('/hyperprobe.agent.v1.AgentBroker/ReportTelemetry', batch, Hyperprobe::Agent::V1::TelemetryResponse, timeout_sec)
80
+ response.finished_probe_ids.to_a
81
+ else
82
+ deadline = calculate_deadline(timeout_sec || @rpc_timeout_sec)
83
+ metadata = { 'x-hp-service-id' => @service_id }
84
+ response = @stub.report_telemetry(batch, metadata: metadata, deadline: deadline)
85
+ response.finished_probe_ids.to_a
86
+ end
87
+ end
88
+
89
+ def estimate_event_size(event)
90
+ proto = build_telemetry_event_proto(event)
91
+ proto.to_proto.bytesize
92
+ rescue StandardError
93
+ JSON.generate(event).bytesize rescue 512
94
+ end
95
+
96
+ def shutdown
97
+ # No-op / cleanup
98
+ end
99
+
100
+ private
101
+
102
+ def init_cruby_client(broker_url, enable_keep_alive)
103
+ target, creds, channel_args = parse_broker_url(broker_url, enable_keep_alive)
104
+ @stub = Hyperprobe::Agent::V1::AgentBroker::Stub.new(
105
+ target,
106
+ creds,
107
+ channel_args: channel_args
108
+ )
109
+ end
110
+
111
+ def init_jruby_client(broker_url)
112
+ raw_url = broker_url.to_s.strip
113
+ if raw_url.start_with?('http://', 'https://')
114
+ @base_url = raw_url
115
+ else
116
+ @base_url = "http://#{raw_url}"
117
+ end
118
+
119
+ timeout_ms = (@rpc_timeout_sec * 1000).to_i
120
+ @http_client = java.net.http.HttpClient.newBuilder
121
+ .version(java.net.http.HttpClient::Version::HTTP_2)
122
+ .connectTimeout(java.time.Duration.ofMillis(timeout_ms))
123
+ .build
124
+ end
125
+
126
+ def call_jruby_rpc(method_path, request_proto, response_class, timeout_sec)
127
+ raw_bytes = request_proto.to_proto
128
+ framed = "\x00" + [raw_bytes.bytesize].pack('N') + raw_bytes
129
+
130
+ uri = java.net.URI.create("#{@base_url}#{method_path}")
131
+ body_publisher = java.net.http.HttpRequest::BodyPublishers.ofByteArray(framed.to_java_bytes)
132
+
133
+ req_builder = java.net.http.HttpRequest.newBuilder(uri)
134
+ .version(java.net.http.HttpClient::Version::HTTP_2)
135
+ .header('Content-Type', 'application/grpc')
136
+ .header('TE', 'trailers')
137
+ .header('x-hp-service-id', @service_id)
138
+ .POST(body_publisher)
139
+
140
+ effective_timeout = timeout_sec || @rpc_timeout_sec
141
+ if effective_timeout && effective_timeout.positive?
142
+ req_builder.timeout(java.time.Duration.ofMillis((effective_timeout * 1000).to_i))
143
+ end
144
+
145
+ http_response = @http_client.send(req_builder.build, java.net.http.HttpResponse::BodyHandlers.ofByteArray)
146
+ response_bytes = String.from_java_bytes(http_response.body)
147
+
148
+ # Strip 5-byte gRPC prefix header (1 byte compression flag + 4 bytes length)
149
+ proto_payload = response_bytes.length >= 5 ? response_bytes[5..-1] : ''
150
+ response_class.decode(proto_payload)
151
+ end
152
+
153
+ def build_telemetry_event_proto(evt)
154
+ return evt if evt.is_a?(Hyperprobe::Agent::V1::TelemetryEvent)
155
+
156
+ stack_frames = Array(evt[:stack_frames]).map do |frame|
157
+ Hyperprobe::Agent::V1::StackFrame.new(
158
+ function_name: frame[:function_name].to_s,
159
+ file_name: frame[:file_name].to_s,
160
+ line_number: frame[:line_number].to_i,
161
+ column_number: frame[:column_number].to_i,
162
+ class_name: frame[:class_name] ? frame[:class_name].to_s : nil
163
+ )
164
+ end
165
+
166
+ proto_params = {
167
+ probe_id: evt[:probe_id].to_s,
168
+ timestamp_ms: (evt[:timestamp_ms] || (Time.now.to_f * 1000)).to_i,
169
+ stack_frames: stack_frames,
170
+ captured_vars_json: evt[:captured_vars_json].to_s,
171
+ watch_results_json: evt[:watch_results_json].to_s,
172
+ evaluated_log: evt[:evaluated_log].to_s,
173
+ metric_value: evt[:metric_value].to_f,
174
+ capture_error: evt[:capture_error].to_s
175
+ }
176
+
177
+ proto_params[:trace_id] = evt[:trace_id].to_s if evt[:trace_id] && !evt[:trace_id].to_s.empty?
178
+
179
+ Hyperprobe::Agent::V1::TelemetryEvent.new(proto_params)
180
+ end
181
+
182
+ def calculate_deadline(timeout_sec)
183
+ return nil unless timeout_sec && timeout_sec.positive?
184
+
185
+ Time.now + timeout_sec
186
+ end
187
+
188
+ def parse_broker_url(url, enable_keep_alive)
189
+ raw_url = url.to_s.strip
190
+ channel_args = {}
191
+
192
+ if enable_keep_alive
193
+ channel_args['grpc.keepalive_time_ms'] = 60_000
194
+ channel_args['grpc.keepalive_timeout_ms'] = 20_000
195
+ channel_args['grpc.keepalive_permit_without_calls'] = 1
196
+ end
197
+
198
+ if raw_url.start_with?('https://')
199
+ target = raw_url.sub('https://', '')
200
+ creds = GRPC::Core::ChannelCredentials.new
201
+ [target, creds, channel_args]
202
+ elsif raw_url.start_with?('http://')
203
+ target = raw_url.sub('http://', '')
204
+ creds = :this_channel_is_insecure
205
+ [target, creds, channel_args]
206
+ else
207
+ creds = :this_channel_is_insecure
208
+ [raw_url, creds, channel_args]
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'safe_ast_validator'
4
+
5
+ module HyperProbe
6
+ module Core
7
+ class Evaluator
8
+ PLACEHOLDER_REGEX = /\$?\{([^}]+)\}|\#\{([^}]+)\}/.freeze
9
+ JS_FLOAT_PREFIX_RE = /^\s*([+-]?(?:(?:[0-9]+\.?[0-9]*)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?)/.freeze
10
+
11
+ class << self
12
+ def eval_condition(condition, binding_ctx)
13
+ return true if condition.nil? || condition.strip.empty?
14
+
15
+ clean_cond = condition.strip
16
+ SafeASTValidator.validate!(clean_cond)
17
+ result = binding_ctx.eval(clean_cond)
18
+ !!result
19
+ end
20
+
21
+ def evaluate_watches(watch_expressions, binding_ctx)
22
+ results = {}
23
+ return results if watch_expressions.nil? || watch_expressions.empty?
24
+
25
+ watch_expressions.each do |expr|
26
+ clean_expr = expr.strip
27
+ next if clean_expr.empty?
28
+
29
+ begin
30
+ SafeASTValidator.validate!(clean_expr)
31
+ results[expr] = binding_ctx.eval(clean_expr)
32
+ rescue StandardError, ScriptError, SecurityError => e
33
+ results[expr] = "Error: #{e.class.name}: #{e.message}"
34
+ end
35
+ end
36
+ results
37
+ end
38
+
39
+ def evaluate_log_template(template, binding_ctx)
40
+ return '' if template.nil? || template.empty?
41
+
42
+ template.gsub(PLACEHOLDER_REGEX) do |_match|
43
+ expr = (Regexp.last_match(1) || Regexp.last_match(2)).strip
44
+ begin
45
+ SafeASTValidator.validate!(expr)
46
+ val = binding_ctx.eval(expr)
47
+ val.is_a?(String) ? val : val.inspect
48
+ rescue StandardError, ScriptError, SecurityError => e
49
+ "<Error: #{e.class.name}: #{e.message}>"
50
+ end
51
+ end
52
+ end
53
+
54
+ def evaluate_metric(metric_expression, binding_ctx)
55
+ return nil if metric_expression.nil? || metric_expression.strip.empty?
56
+
57
+ clean_expr = metric_expression.strip
58
+ SafeASTValidator.validate!(clean_expr)
59
+ raw_val = binding_ctx.eval(clean_expr)
60
+ coerce_metric_value(raw_val)
61
+ end
62
+
63
+ def evaluate_correlation(correlation_expression, binding_ctx)
64
+ return 'static-singleton' if correlation_expression.nil? || correlation_expression.strip.empty?
65
+
66
+ clean_expr = correlation_expression.strip
67
+ SafeASTValidator.validate!(clean_expr)
68
+ raw_val = binding_ctx.eval(clean_expr)
69
+ normalize_correlation_value(raw_val)
70
+ end
71
+
72
+ def coerce_metric_value(value)
73
+ return nil if value == true || value == false
74
+ return nil if value.nil?
75
+
76
+ if value.is_a?(Numeric)
77
+ float_val = value.to_f
78
+ return float_val if float_val.finite?
79
+
80
+ return nil
81
+ end
82
+
83
+ if value.is_a?(String)
84
+ match = JS_FLOAT_PREFIX_RE.match(value)
85
+ if match
86
+ float_val = match[1].to_f
87
+ return float_val if float_val.finite?
88
+ end
89
+ end
90
+
91
+ nil
92
+ end
93
+
94
+ def normalize_correlation_value(value)
95
+ return 'static-singleton' if value.nil?
96
+
97
+ if value == true || value == false
98
+ type_name = value ? 'true' : 'false'
99
+ raise TypeError, "Correlation expression must evaluate to a string or number, got boolean (#{type_name})"
100
+ end
101
+
102
+ if value.is_a?(String)
103
+ if value.start_with?('Error: ')
104
+ raise StandardError, value
105
+ end
106
+
107
+ return value
108
+ end
109
+
110
+ if value.is_a?(Integer)
111
+ return value.to_s
112
+ end
113
+
114
+ if value.is_a?(Float)
115
+ return 'NaN' if value.nan?
116
+ return 'Infinity' if value.infinite? && value.positive?
117
+ return '-Infinity' if value.infinite? && value.negative?
118
+ return value.to_i.to_s if (value % 1.0).zero?
119
+
120
+ return value.to_s
121
+ end
122
+
123
+ raise TypeError, "Correlation expression must evaluate to a string or number, got #{value.class.name}"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module HyperProbe
6
+ module Core
7
+ class Logger
8
+ COLOR_CODES = [32, 33, 34, 35, 36, 31, 92, 93, 94, 95, 96, 91].freeze # Vibrant ANSI colors
9
+ DISABLED_COLOR_VALUES = %w[0 false no off].freeze
10
+ SELECTOR_SEPARATOR = /[\s,]+/.freeze
11
+
12
+ @logger_cache = {}
13
+ @cache_mutex = Mutex.new
14
+
15
+ class << self
16
+ def get_logger(namespace = 'hyperprobe:agent')
17
+ @cache_mutex.synchronize do
18
+ @logger_cache[namespace.to_s] ||= new(namespace.to_s)
19
+ end
20
+ end
21
+
22
+ def is_namespace_enabled?(namespace, debug_value = nil)
23
+ val = debug_value.nil? ? ENV['DEBUG'] : debug_value
24
+ return false if val.nil? || val.strip.empty?
25
+
26
+ enabled_patterns, skipped_patterns = parse_selectors(val)
27
+
28
+ # Check exclusions first
29
+ return false if skipped_patterns.any? { |p| p.match?(namespace) }
30
+
31
+ # Check inclusions
32
+ enabled_patterns.any? { |p| p.match?(namespace) }
33
+ end
34
+
35
+ def parse_selectors(value)
36
+ enabled = []
37
+ skipped = []
38
+
39
+ (value || '').split(SELECTOR_SEPARATOR).each do |selector|
40
+ clean = selector.strip
41
+ next if clean.empty?
42
+
43
+ is_skip = clean.start_with?('-')
44
+ raw_pattern = is_skip ? clean[1..-1] : clean
45
+ next if raw_pattern.nil? || raw_pattern.empty?
46
+
47
+ regex = compile_selector(raw_pattern)
48
+ if is_skip
49
+ skipped << regex
50
+ else
51
+ enabled << regex
52
+ end
53
+ end
54
+
55
+ [enabled, skipped]
56
+ end
57
+
58
+ def compile_selector(pattern)
59
+ regex_str = '^' + Regexp.escape(pattern).gsub('\\*', '.*?') + '$'
60
+ Regexp.new(regex_str, Regexp::IGNORECASE)
61
+ end
62
+
63
+ def reset_cache!
64
+ @cache_mutex.synchronize do
65
+ @logger_cache.clear
66
+ end
67
+ end
68
+ end
69
+
70
+ attr_reader :namespace, :enabled, :color
71
+
72
+ def initialize(namespace = 'hyperprobe:agent')
73
+ @namespace = namespace.to_s
74
+ @enabled = self.class.is_namespace_enabled?(@namespace)
75
+ @color = select_namespace_color(@namespace)
76
+ @last_log_time_ms = monotonic_ms
77
+ @mutex = Mutex.new
78
+ end
79
+
80
+ def debug_enabled?
81
+ @enabled
82
+ end
83
+
84
+ alias is_debug_enabled debug_enabled?
85
+
86
+ def debug(message)
87
+ return unless @enabled
88
+
89
+ write_log(message, $stdout)
90
+ end
91
+
92
+ def info(message)
93
+ if @enabled
94
+ write_log(message, $stdout)
95
+ end
96
+ end
97
+
98
+ def warn(message)
99
+ timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
100
+ formatted = "#{timestamp} \e[33m[HyperProbe WARN]\e[0m [#{@namespace}] #{message}"
101
+ $stdout.puts(formatted)
102
+ $stdout.flush rescue nil
103
+ end
104
+
105
+ def error(message, exception = nil)
106
+ timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
107
+ formatted = "#{timestamp} \e[31m[HyperProbe ERROR]\e[0m [#{@namespace}] #{message}"
108
+ $stderr.puts(formatted)
109
+ if exception
110
+ $stderr.puts(" #{exception.class}: #{exception.message}")
111
+ $stderr.puts(exception.backtrace.first(5).map { |l| " at #{l}" }.join("\n")) if exception.backtrace
112
+ end
113
+ $stderr.flush rescue nil
114
+ end
115
+
116
+ def force_info(message)
117
+ timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
118
+ formatted = "#{timestamp} \e[34mHyperProbe\e[0m -- [#{@namespace}] #{message}"
119
+ $stdout.puts(formatted)
120
+ $stdout.flush rescue nil
121
+ end
122
+
123
+ alias forceInfo force_info
124
+
125
+ def force_error(message, exception = nil)
126
+ error(message, exception)
127
+ end
128
+
129
+ alias forceError force_error
130
+
131
+ private
132
+
133
+ def write_log(message, stream)
134
+ now = monotonic_ms
135
+ delta_str = ''
136
+
137
+ @mutex.synchronize do
138
+ delta = now - @last_log_time_ms
139
+ delta_str = " \e[#{@color}m+#{delta}ms\e[0m" if delta > 0
140
+ @last_log_time_ms = now
141
+ end
142
+
143
+ prefix = if colors_enabled?(stream)
144
+ "\e[1m\e[#{@color}m#{@namespace}\e[0m"
145
+ else
146
+ @namespace
147
+ end
148
+
149
+ stream.puts(" #{prefix} \e[90m#{message}\e[0m#{delta_str}")
150
+ stream.flush rescue nil
151
+ end
152
+
153
+ def select_namespace_color(ns)
154
+ hash_val = 0
155
+ ns.each_char.with_index do |char, idx|
156
+ hash_val += (idx + 1) * char.ord
157
+ end
158
+ COLOR_CODES[hash_val % COLOR_CODES.length]
159
+ end
160
+
161
+ def colors_enabled?(stream)
162
+ configured = ENV['DEBUG_COLORS']
163
+ if configured
164
+ return !DISABLED_COLOR_VALUES.include?(configured.strip.downcase)
165
+ end
166
+
167
+ stream.respond_to?(:tty?) && stream.tty?
168
+ rescue StandardError
169
+ false
170
+ end
171
+
172
+ def monotonic_ms
173
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
174
+ end
175
+ end
176
+ end
177
+ end