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.
@@ -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