aivory_monitor 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +343 -0
- data/lib/aivory_monitor/backend_connection.rb +363 -0
- data/lib/aivory_monitor/config.rb +71 -0
- data/lib/aivory_monitor/exception_capture.rb +466 -0
- data/lib/aivory_monitor/models.rb +113 -0
- data/lib/aivory_monitor/version.rb +5 -0
- data/lib/aivory_monitor.rb +213 -0
- metadata +96 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module AIVoryMonitor
|
|
11
|
+
# WebSocket connection to the AIVory backend.
|
|
12
|
+
class BackendConnection
|
|
13
|
+
attr_reader :connected, :authenticated
|
|
14
|
+
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
@socket = nil
|
|
18
|
+
@ssl_socket = nil
|
|
19
|
+
@connected = false
|
|
20
|
+
@authenticated = false
|
|
21
|
+
@reconnect_attempts = 0
|
|
22
|
+
@agent_id = generate_agent_id
|
|
23
|
+
@message_queue = []
|
|
24
|
+
@event_handlers = {}
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@heartbeat_thread = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Connects to the backend.
|
|
30
|
+
def connect
|
|
31
|
+
return if @connected
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
uri = URI.parse(@config.backend_url)
|
|
35
|
+
host = uri.host || "api.aivory.net"
|
|
36
|
+
port = uri.port || (uri.scheme == "wss" ? 443 : 80)
|
|
37
|
+
path = uri.path.empty? ? "/ws/monitor/agent" : uri.path
|
|
38
|
+
|
|
39
|
+
# Create TCP socket
|
|
40
|
+
@socket = TCPSocket.new(host, port)
|
|
41
|
+
|
|
42
|
+
# Wrap with SSL if wss
|
|
43
|
+
if uri.scheme == "wss"
|
|
44
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
45
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
46
|
+
@ssl_socket = OpenSSL::SSL::SSLSocket.new(@socket, ssl_context)
|
|
47
|
+
@ssl_socket.hostname = host
|
|
48
|
+
@ssl_socket.connect
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Perform WebSocket handshake
|
|
52
|
+
ws_key = Base64.strict_encode64(SecureRandom.random_bytes(16))
|
|
53
|
+
handshake = [
|
|
54
|
+
"GET #{path} HTTP/1.1",
|
|
55
|
+
"Host: #{host}",
|
|
56
|
+
"Upgrade: websocket",
|
|
57
|
+
"Connection: Upgrade",
|
|
58
|
+
"Sec-WebSocket-Key: #{ws_key}",
|
|
59
|
+
"Sec-WebSocket-Version: 13",
|
|
60
|
+
"Authorization: Bearer #{@config.api_key}"
|
|
61
|
+
].join("\r\n") + "\r\n\r\n"
|
|
62
|
+
|
|
63
|
+
active_socket.write(handshake)
|
|
64
|
+
|
|
65
|
+
response = active_socket.gets("\r\n\r\n")
|
|
66
|
+
unless response&.include?("101")
|
|
67
|
+
puts "[AIVory Monitor] WebSocket handshake failed" if @config.debug
|
|
68
|
+
close_sockets
|
|
69
|
+
schedule_reconnect
|
|
70
|
+
return false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@connected = true
|
|
74
|
+
@reconnect_attempts = 0
|
|
75
|
+
|
|
76
|
+
puts "[AIVory Monitor] WebSocket connected" if @config.debug
|
|
77
|
+
|
|
78
|
+
authenticate
|
|
79
|
+
|
|
80
|
+
# Start heartbeat thread
|
|
81
|
+
start_heartbeat
|
|
82
|
+
|
|
83
|
+
# Start message receiver thread
|
|
84
|
+
start_receiver
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
puts "[AIVory Monitor] Connection error: #{e.message}" if @config.debug
|
|
89
|
+
close_sockets
|
|
90
|
+
schedule_reconnect
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Disconnects from the backend.
|
|
96
|
+
def disconnect
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
stop_heartbeat
|
|
99
|
+
close_sockets
|
|
100
|
+
@connected = false
|
|
101
|
+
@authenticated = false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts "[AIVory Monitor] Disconnected" if @config.debug
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Sends an exception to the backend.
|
|
108
|
+
def send_exception(data)
|
|
109
|
+
payload = data.to_h
|
|
110
|
+
payload[:agent_id] = @agent_id
|
|
111
|
+
payload[:environment] = @config.environment
|
|
112
|
+
payload[:hostname] = Socket.gethostname
|
|
113
|
+
|
|
114
|
+
send_message("exception", payload)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Sends a snapshot to the backend.
|
|
118
|
+
def send_snapshot(data)
|
|
119
|
+
payload = data.to_h
|
|
120
|
+
payload[:agent_id] = @agent_id
|
|
121
|
+
|
|
122
|
+
send_message("snapshot", payload)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Registers an event handler.
|
|
126
|
+
def on(event, &block)
|
|
127
|
+
@event_handlers[event.to_s] = block
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Checks if connected and authenticated.
|
|
131
|
+
def connected?
|
|
132
|
+
@connected && @authenticated
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def active_socket
|
|
138
|
+
@ssl_socket || @socket
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def close_sockets
|
|
142
|
+
@ssl_socket&.close rescue nil
|
|
143
|
+
@socket&.close rescue nil
|
|
144
|
+
@ssl_socket = nil
|
|
145
|
+
@socket = nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def send_message(type, payload)
|
|
149
|
+
message = {
|
|
150
|
+
type: type,
|
|
151
|
+
payload: payload,
|
|
152
|
+
timestamp: (Time.now.to_f * 1000).to_i
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
json = JSON.generate(message)
|
|
156
|
+
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
if @connected && @authenticated
|
|
159
|
+
send_websocket_frame(json)
|
|
160
|
+
else
|
|
161
|
+
@message_queue << json
|
|
162
|
+
@message_queue.shift if @message_queue.size > 100
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def authenticate
|
|
168
|
+
payload = {
|
|
169
|
+
api_key: @config.api_key,
|
|
170
|
+
agent_id: @agent_id,
|
|
171
|
+
hostname: Socket.gethostname,
|
|
172
|
+
environment: @config.environment,
|
|
173
|
+
runtime: "ruby",
|
|
174
|
+
runtime_version: RUBY_VERSION,
|
|
175
|
+
agent_version: VERSION
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
payload[:application_name] = @config.application_name if @config.application_name
|
|
179
|
+
|
|
180
|
+
message = {
|
|
181
|
+
type: "register",
|
|
182
|
+
payload: payload,
|
|
183
|
+
timestamp: (Time.now.to_f * 1000).to_i
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
send_websocket_frame(JSON.generate(message))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def start_heartbeat
|
|
190
|
+
@heartbeat_thread = Thread.new do
|
|
191
|
+
loop do
|
|
192
|
+
sleep(@config.heartbeat_interval_ms / 1000.0)
|
|
193
|
+
break unless @connected
|
|
194
|
+
|
|
195
|
+
send_message("heartbeat", {
|
|
196
|
+
timestamp: (Time.now.to_f * 1000).to_i,
|
|
197
|
+
agent_id: @agent_id,
|
|
198
|
+
metrics: {
|
|
199
|
+
memory_mb: get_memory_usage
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def stop_heartbeat
|
|
207
|
+
@heartbeat_thread&.kill
|
|
208
|
+
@heartbeat_thread = nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def start_receiver
|
|
212
|
+
Thread.new do
|
|
213
|
+
loop do
|
|
214
|
+
break unless @connected
|
|
215
|
+
|
|
216
|
+
begin
|
|
217
|
+
frame = read_websocket_frame
|
|
218
|
+
handle_message(frame) if frame
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
puts "[AIVory Monitor] Receive error: #{e.message}" if @config.debug
|
|
221
|
+
break
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
handle_disconnect
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def handle_message(data)
|
|
230
|
+
message = JSON.parse(data)
|
|
231
|
+
type = message["type"]
|
|
232
|
+
|
|
233
|
+
puts "[AIVory Monitor] Received: #{type}" if @config.debug
|
|
234
|
+
|
|
235
|
+
case type
|
|
236
|
+
when "registered"
|
|
237
|
+
handle_registered(message["payload"])
|
|
238
|
+
when "error"
|
|
239
|
+
handle_error(message["payload"])
|
|
240
|
+
when "set_breakpoint"
|
|
241
|
+
emit("set_breakpoint", message["payload"])
|
|
242
|
+
when "remove_breakpoint"
|
|
243
|
+
emit("remove_breakpoint", message["payload"])
|
|
244
|
+
end
|
|
245
|
+
rescue JSON::ParserError => e
|
|
246
|
+
puts "[AIVory Monitor] JSON parse error: #{e.message}" if @config.debug
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def handle_registered(payload)
|
|
250
|
+
@authenticated = true
|
|
251
|
+
@agent_id = payload["agent_id"] if payload["agent_id"]
|
|
252
|
+
|
|
253
|
+
# Flush queued messages
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
@message_queue.each { |msg| send_websocket_frame(msg) }
|
|
256
|
+
@message_queue.clear
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
puts "[AIVory Monitor] Agent registered" if @config.debug
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def handle_error(payload)
|
|
263
|
+
code = payload["code"] || "unknown"
|
|
264
|
+
message = payload["message"] || "Unknown error"
|
|
265
|
+
|
|
266
|
+
warn "[AIVory Monitor] Backend error: #{code} - #{message}"
|
|
267
|
+
|
|
268
|
+
if %w[auth_error invalid_api_key].include?(code)
|
|
269
|
+
warn "[AIVory Monitor] Authentication failed, disabling reconnect"
|
|
270
|
+
@config.max_reconnect_attempts = 0
|
|
271
|
+
disconnect
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def handle_disconnect
|
|
276
|
+
@connected = false
|
|
277
|
+
@authenticated = false
|
|
278
|
+
schedule_reconnect
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def emit(event, data)
|
|
282
|
+
handler = @event_handlers[event]
|
|
283
|
+
handler&.call(data)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def schedule_reconnect
|
|
287
|
+
return if @reconnect_attempts >= @config.max_reconnect_attempts
|
|
288
|
+
|
|
289
|
+
@reconnect_attempts += 1
|
|
290
|
+
delay = [1 * (2**(@reconnect_attempts - 1)), 60].min
|
|
291
|
+
|
|
292
|
+
puts "[AIVory Monitor] Reconnecting in #{delay}s (attempt #{@reconnect_attempts})" if @config.debug
|
|
293
|
+
|
|
294
|
+
Thread.new do
|
|
295
|
+
sleep(delay)
|
|
296
|
+
connect
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def generate_agent_id
|
|
301
|
+
"#{Socket.gethostname}-#{SecureRandom.hex(4)}-#{Process.pid}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def get_memory_usage
|
|
305
|
+
if File.exist?("/proc/self/statm")
|
|
306
|
+
(File.read("/proc/self/statm").split[1].to_i * 4096) / (1024.0 * 1024.0)
|
|
307
|
+
else
|
|
308
|
+
0
|
|
309
|
+
end
|
|
310
|
+
rescue StandardError
|
|
311
|
+
0
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def send_websocket_frame(payload)
|
|
315
|
+
length = payload.bytesize
|
|
316
|
+
frame = [0x81].pack("C") # Text frame, FIN bit set
|
|
317
|
+
|
|
318
|
+
if length <= 125
|
|
319
|
+
frame += [(length | 0x80)].pack("C")
|
|
320
|
+
elsif length <= 65_535
|
|
321
|
+
frame += [126 | 0x80, length].pack("Cn")
|
|
322
|
+
else
|
|
323
|
+
frame += [127 | 0x80, length].pack("CQ>")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Generate and apply mask
|
|
327
|
+
mask = SecureRandom.random_bytes(4)
|
|
328
|
+
frame += mask
|
|
329
|
+
|
|
330
|
+
masked_payload = payload.bytes.map.with_index { |b, i| b ^ mask.bytes[i % 4] }.pack("C*")
|
|
331
|
+
frame += masked_payload
|
|
332
|
+
|
|
333
|
+
active_socket.write(frame)
|
|
334
|
+
rescue StandardError => e
|
|
335
|
+
puts "[AIVory Monitor] Send error: #{e.message}" if @config.debug
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def read_websocket_frame
|
|
339
|
+
header = active_socket.read(2)
|
|
340
|
+
return nil unless header && header.length == 2
|
|
341
|
+
|
|
342
|
+
byte1, byte2 = header.unpack("CC")
|
|
343
|
+
masked = (byte2 & 0x80) != 0
|
|
344
|
+
length = byte2 & 0x7f
|
|
345
|
+
|
|
346
|
+
if length == 126
|
|
347
|
+
length = active_socket.read(2).unpack1("n")
|
|
348
|
+
elsif length == 127
|
|
349
|
+
length = active_socket.read(8).unpack1("Q>")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
if masked
|
|
353
|
+
mask = active_socket.read(4)
|
|
354
|
+
payload = active_socket.read(length)
|
|
355
|
+
payload.bytes.map.with_index { |b, i| b ^ mask.bytes[i % 4] }.pack("C*")
|
|
356
|
+
else
|
|
357
|
+
active_socket.read(length)
|
|
358
|
+
end
|
|
359
|
+
rescue StandardError
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIVoryMonitor
|
|
4
|
+
# Configuration for the AIVory Monitor agent.
|
|
5
|
+
class Config
|
|
6
|
+
attr_accessor :api_key, :backend_url, :environment, :application_name,
|
|
7
|
+
:sampling_rate, :max_variable_depth, :debug, :enable_breakpoints,
|
|
8
|
+
:heartbeat_interval_ms, :max_reconnect_attempts
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
api_key: nil,
|
|
12
|
+
backend_url: nil,
|
|
13
|
+
environment: nil,
|
|
14
|
+
application_name: nil,
|
|
15
|
+
sampling_rate: nil,
|
|
16
|
+
max_variable_depth: nil,
|
|
17
|
+
debug: nil,
|
|
18
|
+
enable_breakpoints: nil,
|
|
19
|
+
heartbeat_interval_ms: nil,
|
|
20
|
+
max_reconnect_attempts: nil
|
|
21
|
+
)
|
|
22
|
+
@api_key = api_key || ENV.fetch("AIVORY_API_KEY", "")
|
|
23
|
+
@backend_url = backend_url || ENV.fetch("AIVORY_BACKEND_URL", "wss://api.aivory.net/ws/monitor/agent")
|
|
24
|
+
@environment = environment || ENV.fetch("AIVORY_ENVIRONMENT", "production")
|
|
25
|
+
@application_name = application_name || ENV["AIVORY_APP_NAME"]
|
|
26
|
+
@sampling_rate = (sampling_rate || ENV.fetch("AIVORY_SAMPLING_RATE", "1.0")).to_f
|
|
27
|
+
@max_variable_depth = (max_variable_depth || ENV.fetch("AIVORY_MAX_DEPTH", "10")).to_i
|
|
28
|
+
@debug = parse_boolean(debug, ENV.fetch("AIVORY_DEBUG", "false"))
|
|
29
|
+
@enable_breakpoints = parse_boolean(enable_breakpoints, ENV.fetch("AIVORY_ENABLE_BREAKPOINTS", "true"))
|
|
30
|
+
@heartbeat_interval_ms = (heartbeat_interval_ms || 30_000).to_i
|
|
31
|
+
@max_reconnect_attempts = (max_reconnect_attempts || 10).to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Validates the configuration.
|
|
35
|
+
#
|
|
36
|
+
# @raise [ArgumentError] If configuration is invalid
|
|
37
|
+
def validate!
|
|
38
|
+
raise ArgumentError, "AIVORY_API_KEY environment variable is required" if @api_key.nil? || @api_key.empty?
|
|
39
|
+
|
|
40
|
+
if @sampling_rate.negative? || @sampling_rate > 1
|
|
41
|
+
raise ArgumentError, "Sampling rate must be between 0.0 and 1.0"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if @max_variable_depth.negative? || @max_variable_depth > 10
|
|
45
|
+
raise ArgumentError, "Max variable depth must be between 0 and 10"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Gets runtime information for the agent.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash]
|
|
54
|
+
def runtime_info
|
|
55
|
+
{
|
|
56
|
+
runtime: "ruby",
|
|
57
|
+
runtime_version: RUBY_VERSION,
|
|
58
|
+
platform: RUBY_PLATFORM,
|
|
59
|
+
hostname: Socket.gethostname
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def parse_boolean(explicit_value, env_value)
|
|
66
|
+
return explicit_value unless explicit_value.nil?
|
|
67
|
+
|
|
68
|
+
env_value.to_s.downcase == "true"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|