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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d349dcd013eaa9c787d218d695e4e45d779b4c236fc908b84edbda42ac0471b8
4
- data.tar.gz: 92c559e16a816706558028588f8869212b05e41fb10507d5fc8c40902ca95a62
3
+ metadata.gz: 8e1cd1d7c16eb9b4cea15e63674f3c89156c9d8e3b88d51ff2cb17b149e717cf
4
+ data.tar.gz: b5e54da451cfa9d98e7ba55a65940af01707bb2f0b17254000ac0aa07e86a8e8
5
5
  SHA512:
6
- metadata.gz: f26d6cd48e6ba74b87cbdfb5adae1c89dfba13c623c0acfc7418c7061bf3559c66a48061a136a166bdb69a5e06cf1a5d6ee5067c35e20932dd4b98966ad03bcf
7
- data.tar.gz: daf29ace4927b42c216a8c88df12291f783b599d32a4443e19426854f336c00fb571ae63d2f6003d05707409b150768cc0ed1c42fd12d348e7bb80689a96aabb
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
- parsed_bt = parse_backtrace(exception.backtrace || [])
81
- backtrace_lines = parsed_bt.map { |frame| frame[:line] }
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
- backtrace_lines = [synthetic] if synthetic
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
- .select { |line| line.include?(Dir.pwd) } # Only app files
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
@@ -1,5 +1,5 @@
1
1
  module ActiveRabbit
2
2
  module Client
3
- VERSION = "0.5.2"
3
+ VERSION = "0.6.1"
4
4
  end
5
5
  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.5.2
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: 2025-12-23 00:00:00.000000000 Z
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