aivory_monitor 0.1.0 → 0.1.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 +4 -4
- data/lib/aivory_monitor/backend_connection.rb +16 -1
- data/lib/aivory_monitor/config.rb +1 -1
- data/lib/aivory_monitor/trace_manager.rb +253 -0
- data/lib/aivory_monitor/version.rb +1 -1
- data/lib/aivory_monitor.rb +9 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d72cabd8da9c3b5268f150cc1f02581629ca50f190310a44bd36fd3db6bc4392
|
|
4
|
+
data.tar.gz: 2c9037abded48539838a9b260d446f4c4e56eb4131c50d8c100d38bc0e3c3c9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b90dc42a9156bcff498de44fdc6900c8fab8aed53c38c7cbad4e8636d516d21dbb30e713dde18e6f92e51db1697073ef73561919a270ec4ea5918fc3542322ff
|
|
7
|
+
data.tar.gz: 327a2dbf54eed21b86075672530639cf0e38df3c6c30cf4872674aad540a690dab454f506443d1932aeae3939aa6e59b4011682bc4fdee52c50820a2952beaff
|
|
@@ -34,7 +34,7 @@ module AIVoryMonitor
|
|
|
34
34
|
uri = URI.parse(@config.backend_url)
|
|
35
35
|
host = uri.host || "api.aivory.net"
|
|
36
36
|
port = uri.port || (uri.scheme == "wss" ? 443 : 80)
|
|
37
|
-
path = uri.path.empty? ? "/
|
|
37
|
+
path = uri.path.empty? ? "/monitor/agent" : uri.path
|
|
38
38
|
|
|
39
39
|
# Create TCP socket
|
|
40
40
|
@socket = TCPSocket.new(host, port)
|
|
@@ -122,6 +122,19 @@ module AIVoryMonitor
|
|
|
122
122
|
send_message("snapshot", payload)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
# Sends a breakpoint hit to the backend.
|
|
126
|
+
def send_breakpoint_hit(breakpoint_id, payload)
|
|
127
|
+
payload[:breakpoint_id] = breakpoint_id
|
|
128
|
+
payload[:agent_id] = @agent_id
|
|
129
|
+
|
|
130
|
+
send_message("breakpoint_hit", payload)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Registers a callback for breakpoint commands from the backend.
|
|
134
|
+
def set_breakpoint_callback(&block)
|
|
135
|
+
@breakpoint_callback = block
|
|
136
|
+
end
|
|
137
|
+
|
|
125
138
|
# Registers an event handler.
|
|
126
139
|
def on(event, &block)
|
|
127
140
|
@event_handlers[event.to_s] = block
|
|
@@ -238,8 +251,10 @@ module AIVoryMonitor
|
|
|
238
251
|
when "error"
|
|
239
252
|
handle_error(message["payload"])
|
|
240
253
|
when "set_breakpoint"
|
|
254
|
+
@breakpoint_callback&.call("set", message["payload"])
|
|
241
255
|
emit("set_breakpoint", message["payload"])
|
|
242
256
|
when "remove_breakpoint"
|
|
257
|
+
@breakpoint_callback&.call("remove", message["payload"])
|
|
243
258
|
emit("remove_breakpoint", message["payload"])
|
|
244
259
|
end
|
|
245
260
|
rescue JSON::ParserError => e
|
|
@@ -20,7 +20,7 @@ module AIVoryMonitor
|
|
|
20
20
|
max_reconnect_attempts: nil
|
|
21
21
|
)
|
|
22
22
|
@api_key = api_key || ENV.fetch("AIVORY_API_KEY", "")
|
|
23
|
-
@backend_url = backend_url || ENV.fetch("AIVORY_BACKEND_URL", "wss://api.aivory.net/
|
|
23
|
+
@backend_url = backend_url || ENV.fetch("AIVORY_BACKEND_URL", "wss://api.aivory.net/monitor/agent")
|
|
24
24
|
@environment = environment || ENV.fetch("AIVORY_ENVIRONMENT", "production")
|
|
25
25
|
@application_name = application_name || ENV["AIVORY_APP_NAME"]
|
|
26
26
|
@sampling_rate = (sampling_rate || ENV.fetch("AIVORY_SAMPLING_RATE", "1.0")).to_f
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIVoryMonitor
|
|
4
|
+
# Represents a single breakpoint.
|
|
5
|
+
class BreakpointInfo
|
|
6
|
+
attr_accessor :backend_id, :file_path, :line_number, :condition, :max_hits, :hit_count, :normalized_path
|
|
7
|
+
|
|
8
|
+
def initialize(backend_id, file_path, line_number, condition = nil, max_hits = 1)
|
|
9
|
+
@backend_id = backend_id
|
|
10
|
+
@file_path = file_path
|
|
11
|
+
@line_number = line_number
|
|
12
|
+
@condition = condition
|
|
13
|
+
@max_hits = [[max_hits, 1].max, 50].min
|
|
14
|
+
@hit_count = 0
|
|
15
|
+
@normalized_path = File.expand_path(file_path).downcase
|
|
16
|
+
rescue StandardError
|
|
17
|
+
@normalized_path = file_path.downcase
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Manages tracing for breakpoint support using TracePoint.
|
|
22
|
+
class TraceManager
|
|
23
|
+
MAX_CAPTURES_PER_SECOND = 50
|
|
24
|
+
|
|
25
|
+
def initialize(config, connection)
|
|
26
|
+
@config = config
|
|
27
|
+
@connection = connection
|
|
28
|
+
@enabled = false
|
|
29
|
+
@breakpoints = {}
|
|
30
|
+
@breakpoints_by_file = {}
|
|
31
|
+
@trace_point = nil
|
|
32
|
+
@capture_count = 0
|
|
33
|
+
@capture_window_start = Time.now
|
|
34
|
+
|
|
35
|
+
connection.set_breakpoint_callback { |cmd, payload| handle_command(cmd, payload) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enable
|
|
39
|
+
return if @enabled
|
|
40
|
+
|
|
41
|
+
@trace_point = TracePoint.new(:line) do |tp|
|
|
42
|
+
trace_callback(tp)
|
|
43
|
+
end
|
|
44
|
+
@trace_point.enable
|
|
45
|
+
|
|
46
|
+
@enabled = true
|
|
47
|
+
puts "[AIVory Monitor] Trace manager enabled" if @config.debug
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def disable
|
|
51
|
+
return unless @enabled
|
|
52
|
+
|
|
53
|
+
@trace_point&.disable
|
|
54
|
+
@trace_point = nil
|
|
55
|
+
@breakpoints.clear
|
|
56
|
+
@breakpoints_by_file.clear
|
|
57
|
+
|
|
58
|
+
@enabled = false
|
|
59
|
+
puts "[AIVory Monitor] Trace manager disabled" if @config.debug
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_breakpoint(backend_id, file_path, line_number, condition = nil, max_hits = 1)
|
|
63
|
+
bp = BreakpointInfo.new(backend_id, file_path, line_number, condition, max_hits)
|
|
64
|
+
@breakpoints[backend_id] = bp
|
|
65
|
+
|
|
66
|
+
@breakpoints_by_file[bp.normalized_path] ||= []
|
|
67
|
+
@breakpoints_by_file[bp.normalized_path] << bp
|
|
68
|
+
|
|
69
|
+
puts "[AIVory Monitor] Breakpoint set: #{backend_id} at #{file_path}:#{line_number}" if @config.debug
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def remove_breakpoint(backend_id)
|
|
73
|
+
bp = @breakpoints.delete(backend_id)
|
|
74
|
+
return unless bp
|
|
75
|
+
|
|
76
|
+
file_bps = @breakpoints_by_file[bp.normalized_path]
|
|
77
|
+
if file_bps
|
|
78
|
+
file_bps.reject! { |b| b.backend_id == backend_id }
|
|
79
|
+
@breakpoints_by_file.delete(bp.normalized_path) if file_bps.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
puts "[AIVory Monitor] Breakpoint removed: #{backend_id}" if @config.debug
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def handle_command(command, payload)
|
|
88
|
+
case command
|
|
89
|
+
when "set"
|
|
90
|
+
set_breakpoint(
|
|
91
|
+
payload["id"] || "",
|
|
92
|
+
payload["file_path"] || payload["file"] || "",
|
|
93
|
+
(payload["line_number"] || payload["line"] || 0).to_i,
|
|
94
|
+
payload["condition"],
|
|
95
|
+
(payload["max_hits"] || 1).to_i
|
|
96
|
+
)
|
|
97
|
+
when "remove"
|
|
98
|
+
remove_breakpoint(payload["id"] || "")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trace_callback(tp)
|
|
103
|
+
return if @breakpoints_by_file.empty?
|
|
104
|
+
|
|
105
|
+
file_path = tp.path
|
|
106
|
+
return unless file_path
|
|
107
|
+
|
|
108
|
+
normalized = begin
|
|
109
|
+
File.expand_path(file_path).downcase
|
|
110
|
+
rescue StandardError
|
|
111
|
+
file_path.downcase
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Look up breakpoints for this file (exact match, then suffix match)
|
|
115
|
+
file_bps = @breakpoints_by_file[normalized]
|
|
116
|
+
unless file_bps
|
|
117
|
+
@breakpoints_by_file.each do |bp_path, bps|
|
|
118
|
+
if normalized.end_with?(bp_path) || bp_path.end_with?(normalized)
|
|
119
|
+
file_bps = bps
|
|
120
|
+
break
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
return unless file_bps
|
|
125
|
+
|
|
126
|
+
line = tp.lineno
|
|
127
|
+
file_bps.each do |bp|
|
|
128
|
+
handle_hit(bp, tp) if bp.line_number == line
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def handle_hit(bp, tp)
|
|
133
|
+
return if bp.hit_count >= bp.max_hits
|
|
134
|
+
return unless rate_limit_ok?
|
|
135
|
+
|
|
136
|
+
# Evaluate condition if present
|
|
137
|
+
if bp.condition && !bp.condition.empty?
|
|
138
|
+
begin
|
|
139
|
+
result = tp.binding.eval(bp.condition)
|
|
140
|
+
return unless result
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
puts "[AIVory Monitor] Condition eval error: #{e.message}" if @config.debug
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
bp.hit_count += 1
|
|
148
|
+
|
|
149
|
+
puts "[AIVory Monitor] Breakpoint hit: #{bp.backend_id}" if @config.debug
|
|
150
|
+
|
|
151
|
+
local_variables = capture_locals(tp.binding)
|
|
152
|
+
stack_trace = build_stack_trace
|
|
153
|
+
|
|
154
|
+
@connection.send_breakpoint_hit(bp.backend_id, {
|
|
155
|
+
captured_at: (Time.now.to_f * 1000).to_i,
|
|
156
|
+
file_path: bp.file_path,
|
|
157
|
+
line_number: bp.line_number,
|
|
158
|
+
stack_trace: stack_trace,
|
|
159
|
+
local_variables: local_variables,
|
|
160
|
+
hit_count: bp.hit_count
|
|
161
|
+
})
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def rate_limit_ok?
|
|
165
|
+
now = Time.now
|
|
166
|
+
if now - @capture_window_start >= 1.0
|
|
167
|
+
@capture_count = 0
|
|
168
|
+
@capture_window_start = now
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if @capture_count >= MAX_CAPTURES_PER_SECOND
|
|
172
|
+
puts "[AIVory Monitor] Rate limit reached, skipping capture" if @config.debug
|
|
173
|
+
return false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
@capture_count += 1
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def capture_locals(binding)
|
|
181
|
+
variables = {}
|
|
182
|
+
|
|
183
|
+
binding.local_variables.each do |name|
|
|
184
|
+
next if name.to_s.start_with?("_")
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
value = binding.local_variable_get(name)
|
|
188
|
+
variables[name.to_s] = capture_variable(name.to_s, value, 0)
|
|
189
|
+
rescue StandardError
|
|
190
|
+
# Skip if can't access
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
variables
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def capture_variable(name, value, depth)
|
|
198
|
+
result = { name: name, type: value.class.name }
|
|
199
|
+
|
|
200
|
+
if depth > @config.max_variable_depth
|
|
201
|
+
result[:value] = "<max depth exceeded>"
|
|
202
|
+
result[:is_truncated] = true
|
|
203
|
+
return result
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if value.nil?
|
|
207
|
+
result[:value] = "nil"
|
|
208
|
+
result[:is_null] = true
|
|
209
|
+
elsif value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
210
|
+
result[:value] = value.to_s
|
|
211
|
+
elsif value.is_a?(String)
|
|
212
|
+
if value.length > 500
|
|
213
|
+
result[:value] = value[0, 500]
|
|
214
|
+
result[:is_truncated] = true
|
|
215
|
+
else
|
|
216
|
+
result[:value] = value
|
|
217
|
+
end
|
|
218
|
+
elsif value.is_a?(Symbol)
|
|
219
|
+
result[:value] = ":#{value}"
|
|
220
|
+
elsif value.is_a?(Array)
|
|
221
|
+
result[:value] = "Array(#{value.size})"
|
|
222
|
+
if depth < @config.max_variable_depth && value.size <= 10
|
|
223
|
+
result[:children] = value.each_with_index.to_h do |v, i|
|
|
224
|
+
["[#{i}]", capture_variable("[#{i}]", v, depth + 1)]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
elsif value.is_a?(Hash)
|
|
228
|
+
result[:value] = "Hash(#{value.size})"
|
|
229
|
+
if depth < @config.max_variable_depth && value.size <= 10
|
|
230
|
+
result[:children] = value.transform_keys(&:to_s).transform_values do |v|
|
|
231
|
+
capture_variable(v.class.name, v, depth + 1)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
result[:value] = value.class.name
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
result
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_stack_trace
|
|
242
|
+
caller_locations(3, 50).map do |loc|
|
|
243
|
+
{
|
|
244
|
+
method_name: loc.label,
|
|
245
|
+
file_path: loc.absolute_path || loc.path,
|
|
246
|
+
file_name: loc.path ? File.basename(loc.path) : nil,
|
|
247
|
+
line_number: loc.lineno,
|
|
248
|
+
is_native: (loc.path || "").start_with?("<") || (loc.path || "").include?("/gems/")
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
data/lib/aivory_monitor.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "aivory_monitor/config"
|
|
|
5
5
|
require_relative "aivory_monitor/models"
|
|
6
6
|
require_relative "aivory_monitor/backend_connection"
|
|
7
7
|
require_relative "aivory_monitor/exception_capture"
|
|
8
|
+
require_relative "aivory_monitor/trace_manager"
|
|
8
9
|
|
|
9
10
|
# AIVory Monitor Ruby Agent
|
|
10
11
|
#
|
|
@@ -81,6 +82,12 @@ module AIVoryMonitor
|
|
|
81
82
|
# Install exception handlers
|
|
82
83
|
@exception_capture.install
|
|
83
84
|
|
|
85
|
+
# Initialize breakpoint support
|
|
86
|
+
if config.enable_breakpoints
|
|
87
|
+
@trace_manager = TraceManager.new(config, @connection)
|
|
88
|
+
@trace_manager.enable
|
|
89
|
+
end
|
|
90
|
+
|
|
84
91
|
# Connect to backend
|
|
85
92
|
@connection.connect
|
|
86
93
|
|
|
@@ -131,6 +138,7 @@ module AIVoryMonitor
|
|
|
131
138
|
|
|
132
139
|
puts "[AIVory Monitor] Shutting down agent" if @config&.debug
|
|
133
140
|
|
|
141
|
+
@trace_manager&.disable
|
|
134
142
|
@exception_capture&.uninstall
|
|
135
143
|
@connection&.disconnect
|
|
136
144
|
|
|
@@ -138,6 +146,7 @@ module AIVoryMonitor
|
|
|
138
146
|
@config = nil
|
|
139
147
|
@connection = nil
|
|
140
148
|
@exception_capture = nil
|
|
149
|
+
@trace_manager = nil
|
|
141
150
|
end
|
|
142
151
|
end
|
|
143
152
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aivory_monitor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- AIVory
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: websocket-client-simple
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/aivory_monitor/config.rb
|
|
68
68
|
- lib/aivory_monitor/exception_capture.rb
|
|
69
69
|
- lib/aivory_monitor/models.rb
|
|
70
|
+
- lib/aivory_monitor/trace_manager.rb
|
|
70
71
|
- lib/aivory_monitor/version.rb
|
|
71
72
|
homepage: https://aivory.net/monitor/
|
|
72
73
|
licenses:
|