activerabbit-ai 0.5.2 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d349dcd013eaa9c787d218d695e4e45d779b4c236fc908b84edbda42ac0471b8
4
- data.tar.gz: 92c559e16a816706558028588f8869212b05e41fb10507d5fc8c40902ca95a62
3
+ metadata.gz: f74f9015abfef72171776a4ffc3b2773ba9c065b988d9e4f8ee23e9cdd2b282e
4
+ data.tar.gz: 4b28180c68924cc06116b19b21bc9b33f0d97f8ac3ed4caa68072c8efdff9fcd
5
5
  SHA512:
6
- metadata.gz: f26d6cd48e6ba74b87cbdfb5adae1c89dfba13c623c0acfc7418c7061bf3559c66a48061a136a166bdb69a5e06cf1a5d6ee5067c35e20932dd4b98966ad03bcf
7
- data.tar.gz: daf29ace4927b42c216a8c88df12291f783b599d32a4443e19426854f336c00fb571ae63d2f6003d05707409b150768cc0ed1c42fd12d348e7bb80689a96aabb
6
+ metadata.gz: 0fb79f1162dc1648c9ffe8d6cc05e813b20f3674143acbf935ecc657c7e1273a4ba35a8f873bf87e1e4e491f579b22568d0f45898de1106947ad41973b7317a9
7
+ data.tar.gz: f2bb325c0780e79160b6acbc4e6e10f98ca427459b58448562166d8c88cb5776e38c10fe3620d713945585ef0206f3a9465a04bdbec26c78ec47e0f78c1ba011
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.6.0] - 2026-01-09
6
+
7
+ ### Added
8
+ - **Sentry-style Stack Traces**: Full source code context captured at error time
9
+ - New `SourceCodeReader` class captures 5 lines before/after each error line
10
+ - `structured_stack_trace` field with rich frame data (file, line, method, in_app, frame_type, source_context)
11
+ - `culprit_frame` identifies the first in-app frame where error occurred
12
+ - Frame classification: controller, model, service, job, view, helper, mailer, concern, library, gem
13
+ - **Comprehensive Test Coverage**: 34 new tests for source code reader and exception tracker
14
+
15
+ ### Fixed
16
+ - **Nil backtrace handling**: Fixed `undefined method 'include?' for nil:NilClass` when backtrace contains nil entries
17
+
5
18
  ## [0.5.2] - 2025-12-22
6
19
 
7
20
  ### 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,209 @@
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
+ # In-app if it's in app/, lib/, or similar app directories
160
+ # and NOT in gems or ruby stdlib
161
+ (file.start_with?("app/") ||
162
+ file.start_with?("lib/") ||
163
+ file.start_with?("config/") ||
164
+ (file.include?("/app/") && !file.include?("/gems/"))) &&
165
+ !file.include?("/gems/") &&
166
+ !file.include?("/ruby/") &&
167
+ !file.include?("/rubygems/") &&
168
+ !file.include?("/.bundle/")
169
+ end
170
+
171
+ def classify_frame(file)
172
+ return :unknown if blank?(file)
173
+
174
+ case file
175
+ when /controllers/ then :controller
176
+ when /models/ then :model
177
+ when /services/ then :service
178
+ when /jobs/ then :job
179
+ when /views/ then :view
180
+ when /helpers/ then :helper
181
+ when /mailers/ then :mailer
182
+ when /concerns/ then :concern
183
+ when /lib\// then :library
184
+ when /gems?[\/\\]/ then :gem
185
+ else :other
186
+ end
187
+ end
188
+
189
+ def binary_file?(path)
190
+ # Check first few bytes for binary content
191
+ File.open(path, "rb") do |f|
192
+ bytes = f.read(512)
193
+ return false if bytes.nil? || bytes.empty?
194
+ # If more than 30% are non-printable, consider binary
195
+ non_printable = bytes.bytes.count { |b| b < 32 && ![9, 10, 13].include?(b) }
196
+ (non_printable.to_f / bytes.length) > 0.3
197
+ end
198
+ rescue StandardError
199
+ false
200
+ end
201
+
202
+ def truncate_line(line)
203
+ return "" if line.nil?
204
+ line.length > MAX_LINE_LENGTH ? line[0, MAX_LINE_LENGTH] + "..." : line
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -1,5 +1,5 @@
1
1
  module ActiveRabbit
2
2
  module Client
3
- VERSION = "0.5.2"
3
+ VERSION = "0.6.0"
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.0
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