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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cd9562d57c9dd817e379e42470aaf15c8961fc17469c20b6fef9ee67ff19f61
4
- data.tar.gz: cea2016eab1e3e1cc26f4db1bd7cabdc851e1a11587edf934b71be16301373d1
3
+ metadata.gz: d72cabd8da9c3b5268f150cc1f02581629ca50f190310a44bd36fd3db6bc4392
4
+ data.tar.gz: 2c9037abded48539838a9b260d446f4c4e56eb4131c50d8c100d38bc0e3c3c9b
5
5
  SHA512:
6
- metadata.gz: e7d8da3e4f472c47a40e6c36ae74cab4b67b90ac1ce961b9b9d54ca402ac97e65ed1d871bb882b2f7a51ccd1af8f1df0c69670edb1b5bfe44c3c75e044162767
7
- data.tar.gz: 22be0f73a6fcb28375e410812a069de56163fa229e7753357d2caeab7061b1977dcf8b254cc8fa6232a2f6595553752825f06bd52ed2238900ee539aa9d70202
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? ? "/ws/monitor/agent" : uri.path
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/ws/monitor/agent")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AIVoryMonitor
4
- VERSION = "1.0.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -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.0
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-18 00:00:00.000000000 Z
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: