activerabbit-ai 0.5.2 → 0.6.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/CHANGELOG.md +18 -0
- data/lib/active_rabbit/client/exception_tracker.rb +37 -4
- data/lib/active_rabbit/client/source_code_reader.rb +223 -0
- data/lib/active_rabbit/client/version.rb +1 -1
- 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: 8e1cd1d7c16eb9b4cea15e63674f3c89156c9d8e3b88d51ff2cb17b149e717cf
|
|
4
|
+
data.tar.gz: b5e54da451cfa9d98e7ba55a65940af01707bb2f0b17254000ac0aa07e86a8e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d3aa17b86a6c204454d70374c653e5f50c2648cb236941f13c063e4d9d932d950b4bc8425ea115e7f16d84dee69355fba29466fef28e75daedb3c6a95f3cbf3
|
|
7
|
+
data.tar.gz: f7243d857f07247ce600ed7f98156b96e4591e68af2aa954ad6357ba20282d475cd521c55beb4bb74b02133f7ae697d3c973cfaf1b07bcb98132602045c4b220
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.6.1] - 2026-01-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Docker/Production path detection**: Fixed `in_app_frame?` to correctly detect app frames in Docker containers and production environments with absolute paths like `/app/app/controllers/...`
|
|
9
|
+
|
|
10
|
+
## [0.6.0] - 2026-01-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Sentry-style Stack Traces**: Full source code context captured at error time
|
|
14
|
+
- New `SourceCodeReader` class captures 5 lines before/after each error line
|
|
15
|
+
- `structured_stack_trace` field with rich frame data (file, line, method, in_app, frame_type, source_context)
|
|
16
|
+
- `culprit_frame` identifies the first in-app frame where error occurred
|
|
17
|
+
- Frame classification: controller, model, service, job, view, helper, mailer, concern, library, gem
|
|
18
|
+
- **Comprehensive Test Coverage**: 34 new tests for source code reader and exception tracker
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Nil backtrace handling**: Fixed `undefined method 'include?' for nil:NilClass` when backtrace contains nil entries
|
|
22
|
+
|
|
5
23
|
## [0.5.2] - 2025-12-22
|
|
6
24
|
|
|
7
25
|
### Fixed
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "time"
|
|
5
|
+
require_relative "source_code_reader"
|
|
5
6
|
|
|
6
7
|
module ActiveRabbit
|
|
7
8
|
module Client
|
|
8
9
|
class ExceptionTracker
|
|
9
10
|
attr_reader :configuration, :http_client
|
|
10
11
|
|
|
12
|
+
# Number of context lines to capture around error line
|
|
13
|
+
SOURCE_CONTEXT_LINES = 5
|
|
14
|
+
|
|
11
15
|
def initialize(configuration, http_client)
|
|
12
16
|
@configuration = configuration
|
|
13
17
|
@http_client = http_client
|
|
@@ -77,8 +81,16 @@ module ActiveRabbit
|
|
|
77
81
|
end
|
|
78
82
|
|
|
79
83
|
def build_exception_data(exception:, context:, user_id:, tags:, handled: nil)
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
raw_backtrace = exception.backtrace || []
|
|
85
|
+
|
|
86
|
+
# Parse backtrace with source code context (Sentry-style)
|
|
87
|
+
structured_frames = SourceCodeReader.parse_backtrace_with_source(
|
|
88
|
+
raw_backtrace,
|
|
89
|
+
context_lines: SOURCE_CONTEXT_LINES
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Keep simple backtrace lines for backward compatibility
|
|
93
|
+
backtrace_lines = raw_backtrace
|
|
82
94
|
|
|
83
95
|
# Fallback: synthesize a helpful frame for routing errors with no backtrace
|
|
84
96
|
if backtrace_lines.empty?
|
|
@@ -90,9 +102,24 @@ module ActiveRabbit
|
|
|
90
102
|
path = $2
|
|
91
103
|
synthetic = "#{defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : 'app'}/config/routes.rb:1:in `route_not_found' for #{path}"
|
|
92
104
|
end
|
|
93
|
-
|
|
105
|
+
if synthetic
|
|
106
|
+
backtrace_lines = [synthetic]
|
|
107
|
+
structured_frames = [{
|
|
108
|
+
file: "config/routes.rb",
|
|
109
|
+
line: 1,
|
|
110
|
+
method: "route_not_found",
|
|
111
|
+
raw: synthetic,
|
|
112
|
+
in_app: true,
|
|
113
|
+
frame_type: :other,
|
|
114
|
+
index: 0,
|
|
115
|
+
source_context: nil
|
|
116
|
+
}]
|
|
117
|
+
end
|
|
94
118
|
end
|
|
95
119
|
|
|
120
|
+
# Find culprit frame (first in-app frame)
|
|
121
|
+
culprit_frame = structured_frames.find { |f| f[:in_app] }
|
|
122
|
+
|
|
96
123
|
# Build data in the format the API expects
|
|
97
124
|
data = {
|
|
98
125
|
# Required fields
|
|
@@ -100,6 +127,10 @@ module ActiveRabbit
|
|
|
100
127
|
message: exception.message,
|
|
101
128
|
backtrace: backtrace_lines,
|
|
102
129
|
|
|
130
|
+
# Structured stack frames with source context (Sentry-style)
|
|
131
|
+
structured_stack_trace: structured_frames,
|
|
132
|
+
culprit_frame: culprit_frame,
|
|
133
|
+
|
|
103
134
|
# Timing and environment
|
|
104
135
|
occurred_at: Time.now.iso8601(3),
|
|
105
136
|
environment: configuration.environment || 'development',
|
|
@@ -150,6 +181,7 @@ module ActiveRabbit
|
|
|
150
181
|
# Log what we're sending
|
|
151
182
|
log(:debug, "[ActiveRabbit] Built exception data:")
|
|
152
183
|
log(:debug, "[ActiveRabbit] - Required fields: class=#{data[:exception_class]}, message=#{data[:message]}, backtrace=#{data[:backtrace]&.first}")
|
|
184
|
+
log(:debug, "[ActiveRabbit] - Structured frames: #{structured_frames.count} total, #{structured_frames.count { |f| f[:in_app] }} in-app")
|
|
153
185
|
log(:debug, "[ActiveRabbit] - Error details: type=#{data[:error_type]}, source=#{data[:error_source]}, component=#{data[:error_component]}")
|
|
154
186
|
log(:debug, "[ActiveRabbit] - Request info: path=#{data[:request_path]}, method=#{data[:request_method]}, action=#{data[:controller_action]}")
|
|
155
187
|
|
|
@@ -199,7 +231,8 @@ module ActiveRabbit
|
|
|
199
231
|
|
|
200
232
|
# Take the first few frames from the application (not gems/stdlib)
|
|
201
233
|
app_frames = backtrace
|
|
202
|
-
.
|
|
234
|
+
.compact # Remove any nil entries
|
|
235
|
+
.select { |line| line.is_a?(String) && line.include?(Dir.pwd) } # Only app files
|
|
203
236
|
.first(3) # First 3 frames
|
|
204
237
|
.map { |line| line.gsub(/:\d+/, ":LINE") } # Remove line numbers
|
|
205
238
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRabbit
|
|
4
|
+
module Client
|
|
5
|
+
# Reads source code context around error lines for rich stack traces
|
|
6
|
+
# Similar to how Sentry captures source context at error time
|
|
7
|
+
class SourceCodeReader
|
|
8
|
+
DEFAULT_CONTEXT_LINES = 5
|
|
9
|
+
MAX_LINE_LENGTH = 500
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Read source code context for a specific file and line
|
|
13
|
+
# Returns nil if file cannot be read
|
|
14
|
+
def read_context(file_path, line_number, context_lines: DEFAULT_CONTEXT_LINES)
|
|
15
|
+
return nil if blank?(file_path) || line_number.nil? || line_number < 1
|
|
16
|
+
|
|
17
|
+
# Resolve to absolute path
|
|
18
|
+
full_path = resolve_path(file_path)
|
|
19
|
+
return nil unless full_path && File.exist?(full_path) && File.readable?(full_path)
|
|
20
|
+
|
|
21
|
+
# Skip binary files and very large files
|
|
22
|
+
return nil if binary_file?(full_path)
|
|
23
|
+
return nil if File.size(full_path) > 1_000_000 # Skip files > 1MB
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
lines = File.readlines(full_path)
|
|
27
|
+
total_lines = lines.length
|
|
28
|
+
|
|
29
|
+
return nil if line_number > total_lines
|
|
30
|
+
|
|
31
|
+
# Calculate range (0-indexed internally)
|
|
32
|
+
start_idx = [line_number - context_lines - 1, 0].max
|
|
33
|
+
end_idx = [line_number + context_lines - 1, total_lines - 1].min
|
|
34
|
+
|
|
35
|
+
# Build context structure
|
|
36
|
+
lines_before = []
|
|
37
|
+
(start_idx...(line_number - 1)).each do |i|
|
|
38
|
+
lines_before << truncate_line(lines[i]&.chomp || "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
line_content = truncate_line(lines[line_number - 1]&.chomp || "")
|
|
42
|
+
|
|
43
|
+
lines_after = []
|
|
44
|
+
(line_number..end_idx).each do |i|
|
|
45
|
+
lines_after << truncate_line(lines[i]&.chomp || "")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
lines_before: lines_before,
|
|
50
|
+
line_content: line_content,
|
|
51
|
+
lines_after: lines_after,
|
|
52
|
+
start_line: start_idx + 1
|
|
53
|
+
}
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
# Log but don't fail
|
|
56
|
+
if defined?(Rails.logger)
|
|
57
|
+
Rails.logger.debug "[ActiveRabbit] Could not read source for #{file_path}: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse a backtrace and add source context to each frame
|
|
64
|
+
def parse_backtrace_with_source(backtrace, context_lines: DEFAULT_CONTEXT_LINES)
|
|
65
|
+
return [] if blank?(backtrace)
|
|
66
|
+
|
|
67
|
+
frames = backtrace.is_a?(Array) ? backtrace : backtrace.split("\n")
|
|
68
|
+
|
|
69
|
+
frames.map.with_index do |frame_line, index|
|
|
70
|
+
parse_frame_with_source(frame_line, index, context_lines: context_lines)
|
|
71
|
+
end.compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse a single frame and add source context
|
|
75
|
+
def parse_frame_with_source(frame_line, index = 0, context_lines: DEFAULT_CONTEXT_LINES)
|
|
76
|
+
return nil if blank?(frame_line)
|
|
77
|
+
|
|
78
|
+
# Parse frame: "path/to/file.rb:123:in `method_name'"
|
|
79
|
+
pattern = /^(.+?):(\d+)(?::in [`'](.+?)'?)?\s*$/
|
|
80
|
+
|
|
81
|
+
if (match = frame_line.match(pattern))
|
|
82
|
+
file = match[1]
|
|
83
|
+
line = match[2].to_i
|
|
84
|
+
method_name = match[3]
|
|
85
|
+
|
|
86
|
+
in_app = in_app_frame?(file)
|
|
87
|
+
frame_type = classify_frame(file)
|
|
88
|
+
|
|
89
|
+
# Only read source for in-app frames to save bandwidth
|
|
90
|
+
source_context = if in_app
|
|
91
|
+
read_context(file, line, context_lines: context_lines)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
file: file,
|
|
96
|
+
line: line,
|
|
97
|
+
method: method_name,
|
|
98
|
+
raw: frame_line,
|
|
99
|
+
in_app: in_app,
|
|
100
|
+
frame_type: frame_type,
|
|
101
|
+
index: index,
|
|
102
|
+
source_context: source_context
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
# Fallback for non-standard frame formats
|
|
106
|
+
{
|
|
107
|
+
file: nil,
|
|
108
|
+
line: nil,
|
|
109
|
+
method: nil,
|
|
110
|
+
raw: frame_line,
|
|
111
|
+
in_app: false,
|
|
112
|
+
frame_type: :unknown,
|
|
113
|
+
index: index,
|
|
114
|
+
source_context: nil
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Helper to check for blank values (works without Rails)
|
|
122
|
+
def blank?(value)
|
|
123
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def resolve_path(file_path)
|
|
127
|
+
return nil if blank?(file_path)
|
|
128
|
+
|
|
129
|
+
# Already absolute
|
|
130
|
+
if file_path.start_with?("/")
|
|
131
|
+
return file_path if File.exist?(file_path)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Try relative to Rails root
|
|
135
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
136
|
+
full_path = Rails.root.join(file_path)
|
|
137
|
+
return full_path.to_s if File.exist?(full_path)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Try relative to current directory
|
|
141
|
+
if File.exist?(file_path)
|
|
142
|
+
return File.expand_path(file_path)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Try common app paths
|
|
146
|
+
["app/", "lib/", "config/"].each do |prefix|
|
|
147
|
+
if file_path.start_with?(prefix) && defined?(Rails) && Rails.root
|
|
148
|
+
full_path = Rails.root.join(file_path)
|
|
149
|
+
return full_path.to_s if File.exist?(full_path)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def in_app_frame?(file)
|
|
157
|
+
return false if blank?(file)
|
|
158
|
+
|
|
159
|
+
# Exclude gem/library paths first (these are never in-app)
|
|
160
|
+
return false if file.include?("/gems/")
|
|
161
|
+
return false if file.include?("/bundle/")
|
|
162
|
+
return false if file.include?("/.bundle/")
|
|
163
|
+
return false if file.include?("/rubygems/")
|
|
164
|
+
# Exclude Ruby stdlib but not app paths that happen to have "ruby" in them
|
|
165
|
+
return false if file =~ %r{/ruby/\d+\.\d+\.\d+/}
|
|
166
|
+
|
|
167
|
+
# In-app patterns (common Rails app structures)
|
|
168
|
+
# Relative paths
|
|
169
|
+
return true if file.start_with?("app/")
|
|
170
|
+
return true if file.start_with?("lib/")
|
|
171
|
+
return true if file.start_with?("config/")
|
|
172
|
+
|
|
173
|
+
# Absolute paths in Docker/production (e.g., /app/app/controllers/...)
|
|
174
|
+
# Match paths that contain /app/ followed by typical app directories
|
|
175
|
+
return true if file =~ %r{/app/(controllers|models|services|jobs|views|helpers|mailers|workers|channels)/}
|
|
176
|
+
return true if file =~ %r{/lib/[^/]+\.rb$}
|
|
177
|
+
return true if file =~ %r{/config/}
|
|
178
|
+
|
|
179
|
+
# Generic: path contains /app/ but not in excluded paths (already checked above)
|
|
180
|
+
return true if file.include?("/app/") && file.end_with?(".rb")
|
|
181
|
+
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def classify_frame(file)
|
|
186
|
+
return :unknown if blank?(file)
|
|
187
|
+
|
|
188
|
+
case file
|
|
189
|
+
when /controllers/ then :controller
|
|
190
|
+
when /models/ then :model
|
|
191
|
+
when /services/ then :service
|
|
192
|
+
when /jobs/ then :job
|
|
193
|
+
when /views/ then :view
|
|
194
|
+
when /helpers/ then :helper
|
|
195
|
+
when /mailers/ then :mailer
|
|
196
|
+
when /concerns/ then :concern
|
|
197
|
+
when /lib\// then :library
|
|
198
|
+
when /gems?[\/\\]/ then :gem
|
|
199
|
+
else :other
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def binary_file?(path)
|
|
204
|
+
# Check first few bytes for binary content
|
|
205
|
+
File.open(path, "rb") do |f|
|
|
206
|
+
bytes = f.read(512)
|
|
207
|
+
return false if bytes.nil? || bytes.empty?
|
|
208
|
+
# If more than 30% are non-printable, consider binary
|
|
209
|
+
non_printable = bytes.bytes.count { |b| b < 32 && ![9, 10, 13].include?(b) }
|
|
210
|
+
(non_printable.to_f / bytes.length) > 0.3
|
|
211
|
+
end
|
|
212
|
+
rescue StandardError
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def truncate_line(line)
|
|
217
|
+
return "" if line.nil?
|
|
218
|
+
line.length > MAX_LINE_LENGTH ? line[0, MAX_LINE_LENGTH] + "..." : line
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerabbit-ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Shapalov
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -101,6 +101,7 @@ files:
|
|
|
101
101
|
- lib/active_rabbit/client/pii_scrubber.rb
|
|
102
102
|
- lib/active_rabbit/client/railtie.rb
|
|
103
103
|
- lib/active_rabbit/client/sidekiq_middleware.rb
|
|
104
|
+
- lib/active_rabbit/client/source_code_reader.rb
|
|
104
105
|
- lib/active_rabbit/client/version.rb
|
|
105
106
|
- lib/active_rabbit/middleware/error_capture_middleware.rb
|
|
106
107
|
- lib/active_rabbit/reporting.rb
|