hawk-rails 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ad454243c7e4906716bba745c89009b7e4a0925fb00e7c7c246ce4abc7ee421
4
+ data.tar.gz: 8606ee7347422ec4f09f9f22815226da8668799350fae28f3a5bebcf3cff4875
5
+ SHA512:
6
+ metadata.gz: d6e47dc81d3f81dfb543c1a69ef2bb5ba706c4c913904cdb3e81b425c9278ee27c51d58cd77495066b429f3f6a94c1ffc7448c6e54865a0cba6a00920f2705b8
7
+ data.tar.gz: d8594608dee49b994127308f54695780acb67348c0dcad34f8c274e6d942c9483b67e3ccd9018f01e74146866de608cb879f62a818a2315e538a45fd0e225604
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --format documentation
3
+ --color
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-04-03
4
+
5
+ ### Added
6
+
7
+ - Initial release of hawk-rails gem
8
+ - Automatic exception catching via Rack middleware
9
+ - Manual event sending via `Hawk::Rails.send`
10
+ - Backtrace parsing with source code context
11
+ - Request info capture (URL, method, headers, params, IP)
12
+ - User auto-detection via Warden/Devise
13
+ - Anonymous user ID generation for affected users tracking
14
+ - Global and per-event context support
15
+ - `before_send` hook for event filtering and sensitive data removal
16
+ - Error level detection (fatal/error)
17
+ - Rails and Ruby addons (version, environment, platform)
18
+ - Async event delivery (configurable)
19
+ - Custom collector endpoint support
20
+ - Rails generator for initializer setup (`rails g hawk_rails:install:install`)
21
+ - Configurable source code context lines
22
+ - Environment-based activation (default: production, staging)
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "rspec", "~> 3.0"
8
+ gem "webmock", "~> 3.0"
9
+ gem "rake", "~> 13.0"
10
+ gem "rubocop", "~> 1.0"
11
+ gem "railties", ">= 8.0"
12
+ gem "actionpack", ">= 8.0"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hawk Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # Hawk Rails
2
+
3
+ Ruby on Rails catcher for [Hawk](https://hawk.so) error tracker. Captures unhandled exceptions and custom events in Rails 8+ applications and sends them to Hawk.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "hawk-rails"
11
+ ```
12
+
13
+ Run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Generate the initializer:
20
+
21
+ ```bash
22
+ rails generate hawk_rails:install:install
23
+ ```
24
+
25
+ This creates `config/initializers/hawk.rb` with all available options.
26
+
27
+ ## Configuration
28
+
29
+ Set your integration token (get it at [hawk.so](https://hawk.so) → Project Settings → Integrations):
30
+
31
+ ```ruby
32
+ # config/initializers/hawk.rb
33
+ Hawk::Rails.configure do |config|
34
+ config.token = ENV.fetch("HAWK_TOKEN")
35
+ end
36
+ ```
37
+
38
+ ### All Options
39
+
40
+ ```ruby
41
+ Hawk::Rails.configure do |config|
42
+ # Required: integration token
43
+ config.token = ENV.fetch("HAWK_TOKEN")
44
+
45
+ # Application release/version (for source maps & suspected commits)
46
+ config.release = ENV.fetch("APP_VERSION", nil)
47
+
48
+ # Global context attached to every event
49
+ config.context = { app_name: "MyApp", server: Socket.gethostname }
50
+
51
+ # Default user (overridden by per-event user or Warden/Devise auto-detection)
52
+ config.user = { id: "system", name: "Background Worker" }
53
+
54
+ # Environments where Hawk is active (default: production, staging)
55
+ config.enabled_environments = %w[production staging]
56
+
57
+ # Source code lines to include around each backtrace frame (default: 5)
58
+ config.source_code_lines = 5
59
+
60
+ # Send events asynchronously (default: true)
61
+ config.async = true
62
+
63
+ # Custom collector endpoint (overrides auto-detected URL from token)
64
+ config.collector_endpoint = "https://custom-collector.example.com/"
65
+
66
+ # Filter or modify events before sending (return false to drop)
67
+ config.before_send = ->(event) {
68
+ # Remove sensitive params
69
+ if event.dig(:payload, :context, :request, :params)
70
+ event[:payload][:context][:request][:params].delete(:password)
71
+ event[:payload][:context][:request][:params].delete(:credit_card)
72
+ end
73
+ event
74
+ }
75
+ end
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ### Automatic Error Catching
81
+
82
+ Hawk Rails automatically catches all unhandled exceptions via Rack middleware. No extra code needed — just configure your token and deploy.
83
+
84
+ ### Manual Event Sending
85
+
86
+ ```ruby
87
+ begin
88
+ risky_operation
89
+ rescue => e
90
+ Hawk::Rails.send(e)
91
+ end
92
+ ```
93
+
94
+ With context:
95
+
96
+ ```ruby
97
+ Hawk::Rails.send(error, context: { order_id: order.id, step: "payment" })
98
+ ```
99
+
100
+ With user info:
101
+
102
+ ```ruby
103
+ Hawk::Rails.send(error, user: { id: current_user.id.to_s, name: current_user.name })
104
+ ```
105
+
106
+ ### User Detection
107
+
108
+ Hawk Rails automatically detects the current user via Warden/Devise when available. If no user is found, an anonymous user ID is generated for Affected Users tracking.
109
+
110
+ You can also set a global default user:
111
+
112
+ ```ruby
113
+ Hawk::Rails.configure do |config|
114
+ config.user = { id: "worker-1", name: "Sidekiq Worker" }
115
+ end
116
+ ```
117
+
118
+ ### Sensitive Data Filtering
119
+
120
+ Use `before_send` to strip sensitive data before it leaves your server:
121
+
122
+ ```ruby
123
+ Hawk::Rails.configure do |config|
124
+ config.before_send = ->(event) {
125
+ # Drop all events from a specific error class
126
+ return false if event[:payload][:type] == "ActionController::RoutingError"
127
+
128
+ # Remove auth headers
129
+ headers = event.dig(:payload, :context, :request, :headers)
130
+ headers&.delete("Authorization")
131
+
132
+ event
133
+ }
134
+ end
135
+ ```
136
+
137
+ ### Event Format
138
+
139
+ Each event sent to Hawk follows the [Hawk Event Format](https://docs.hawk.so/event-format):
140
+
141
+ ```json
142
+ {
143
+ "token": "your-integration-token",
144
+ "catcherType": "errors/ruby",
145
+ "payload": {
146
+ "title": "undefined method `name' for nil:NilClass",
147
+ "type": "NoMethodError",
148
+ "backtrace": [
149
+ {
150
+ "file": "/app/models/user.rb",
151
+ "line": 42,
152
+ "column": 0,
153
+ "function": "full_name",
154
+ "sourceCode": [
155
+ { "line": 40, "content": " def full_name" },
156
+ { "line": 41, "content": " first = profile.first_name" },
157
+ { "line": 42, "content": " last = profile.name" }
158
+ ]
159
+ }
160
+ ],
161
+ "level": 2,
162
+ "release": "v2.1.0",
163
+ "catcherVersion": "0.1.0",
164
+ "context": {
165
+ "request": {
166
+ "url": "https://example.com/users/42",
167
+ "method": "GET",
168
+ "ip": "203.0.113.1"
169
+ }
170
+ },
171
+ "user": { "id": "42", "name": "John Doe" },
172
+ "addons": {
173
+ "rails": { "version": "8.0.0", "environment": "production" },
174
+ "ruby": { "version": "3.3.0", "platform": "x86_64-linux", "engine": "ruby" }
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ ### Addons
181
+
182
+ Hawk Rails automatically collects:
183
+
184
+ - **Rails**: version, environment
185
+ - **Ruby**: version, platform, engine
186
+
187
+ ### Error Levels
188
+
189
+ Error levels are automatically determined:
190
+
191
+ | Level | Value | Errors |
192
+ |---------|-------|--------------------------------------------------|
193
+ | Fatal | 1 | `SystemExit`, `SignalException`, `NoMemoryError` |
194
+ | Error | 2 | `RuntimeError`, `StandardError`, and subclasses |
195
+
196
+ ## Requirements
197
+
198
+ - Ruby >= 3.2
199
+ - Rails >= 8.0
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ bundle install
205
+ bundle exec rspec
206
+ ```
207
+
208
+ ## License
209
+
210
+ MIT License. See [LICENSE](LICENSE) for details.
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/hawk/rails/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "hawk-rails"
7
+ spec.version = Hawk::Rails::VERSION
8
+ spec.authors = ["Alexander Panasenkov"]
9
+ spec.email = ["apanasenkov@capaa.ru"]
10
+
11
+ spec.summary = "Hawk error tracker catcher for Ruby on Rails"
12
+ spec.description = "Captures unhandled exceptions and custom events in Rails 8+ applications and sends them to Hawk (hawk.so) error tracker."
13
+ spec.homepage = "https://github.com/capaas/hawk-rails"
14
+ spec.license = "MIT"
15
+
16
+ spec.required_ruby_version = ">= 3.2"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) ||
25
+ f.start_with?("spec/", ".git", ".github", "bin/")
26
+ end
27
+ end
28
+
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "railties", "~> 8.0"
32
+ spec.add_dependency "net-http", "~> 0.4"
33
+
34
+ spec.add_development_dependency "rspec", "~> 3.0"
35
+ spec.add_development_dependency "webmock", "~> 3.0"
36
+ spec.add_development_dependency "rake", "~> 13.0"
37
+ spec.add_development_dependency "rubocop", "~> 1.0"
38
+ spec.add_development_dependency "actionpack", "~> 8.0"
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HawkRails
4
+ module Install
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a Hawk Rails initializer file"
9
+
10
+ def create_initializer
11
+ template "hawk.rb.tt", "config/initializers/hawk.rb"
12
+ end
13
+
14
+ def show_instructions
15
+ say ""
16
+ say "Hawk Rails has been installed!", :green
17
+ say ""
18
+ say "Next steps:"
19
+ say " 1. Set your integration token in config/initializers/hawk.rb"
20
+ say " or via the HAWK_TOKEN environment variable."
21
+ say " 2. Restart your Rails server."
22
+ say ""
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ Hawk::Rails.configure do |config|
4
+ # Your Hawk integration token (required).
5
+ # Get it at https://hawk.so in Project Settings > Integrations.
6
+ config.token = ENV.fetch("HAWK_TOKEN", nil)
7
+
8
+ # Application release/version identifier.
9
+ # Used for source maps and suspected commits tracking.
10
+ # config.release = ENV.fetch("APP_VERSION", nil)
11
+
12
+ # Global context — free-format hash attached to every event.
13
+ # config.context = { app_name: "MyApp" }
14
+
15
+ # Environments where Hawk is active (default: production, staging).
16
+ config.enabled_environments = %w[production staging]
17
+
18
+ # Number of source code lines to include around each backtrace frame (default: 5).
19
+ # config.source_code_lines = 5
20
+
21
+ # Send events asynchronously in a background thread (default: true).
22
+ # config.async = true
23
+
24
+ # Custom collector endpoint (override auto-detected URL).
25
+ # config.collector_endpoint = "https://your-custom-collector.example.com/"
26
+
27
+ # Hook to filter or modify events before sending.
28
+ # Return the modified event, or false to drop it entirely.
29
+ # config.before_send = ->(event) {
30
+ # if event.dig(:payload, :context, :request, :params)
31
+ # event[:payload][:context][:request][:params].delete(:password)
32
+ # end
33
+ # event
34
+ # }
35
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ class BacktraceParser
6
+ BACKTRACE_LINE_REGEX = /\A(.+):(\d+)(?::in\s+[`'](.+)')?\z/
7
+
8
+ def initialize(backtrace, source_lines: 5)
9
+ @backtrace = backtrace || []
10
+ @source_lines = source_lines
11
+ end
12
+
13
+ def parse
14
+ @backtrace.map { |line| parse_line(line) }.compact
15
+ end
16
+
17
+ private
18
+
19
+ def parse_line(line)
20
+ match = BACKTRACE_LINE_REGEX.match(line)
21
+ return nil unless match
22
+
23
+ file = match[1]
24
+ line_number = match[2].to_i
25
+ function = match[3]
26
+
27
+ entry = {
28
+ file: file,
29
+ line: line_number,
30
+ column: 0,
31
+ function: function
32
+ }
33
+
34
+ source_code = SourceCodeReader.read(file, line_number, @source_lines)
35
+ entry[:sourceCode] = source_code if source_code
36
+
37
+ entry
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Hawk
6
+ module Rails
7
+ class Catcher
8
+ include Singleton
9
+
10
+ def send_event(error, context: nil, user: nil, request_info: nil)
11
+ return unless enabled?
12
+
13
+ event = Event.new(error, context: context, user: user, request_info: request_info)
14
+ payload = event.to_payload
15
+
16
+ if (hook = Hawk::Rails.configuration.before_send)
17
+ payload = hook.call(payload)
18
+ return if payload == false
19
+ end
20
+
21
+ deliver(payload)
22
+ rescue => e
23
+ warn "[Hawk] Failed to process event: #{e.message}"
24
+ end
25
+
26
+ def self.reset!
27
+ @singleton__instance__ = nil
28
+ @singleton__mutex__ = Mutex.new
29
+ end
30
+
31
+ private
32
+
33
+ def enabled?
34
+ config = Hawk::Rails.configuration
35
+ return false unless config.valid?
36
+
37
+ if defined?(::Rails)
38
+ config.enabled_environments.include?(::Rails.env.to_s)
39
+ else
40
+ true
41
+ end
42
+ end
43
+
44
+ def deliver(payload)
45
+ if Hawk::Rails.configuration.async
46
+ Thread.new { post(payload) }
47
+ else
48
+ post(payload)
49
+ end
50
+ end
51
+
52
+ def post(payload)
53
+ endpoint = Hawk::Rails.configuration.resolved_endpoint
54
+ uri = URI.parse(endpoint)
55
+
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.use_ssl = (uri.scheme == "https")
58
+ http.open_timeout = 5
59
+ http.read_timeout = 10
60
+
61
+ request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
62
+ request["Content-Type"] = "application/json"
63
+ request["Accept"] = "application/json"
64
+ request.body = JSON.generate(payload)
65
+
66
+ response = http.request(request)
67
+
68
+ unless response.is_a?(Net::HTTPSuccess)
69
+ warn "[Hawk] Failed to send event: HTTP #{response.code}"
70
+ end
71
+ rescue => e
72
+ warn "[Hawk] Network error: #{e.message}"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ class Configuration
6
+ LEVELS = {
7
+ fatal: 1,
8
+ error: 2,
9
+ warning: 4,
10
+ info: 8,
11
+ debug: 16
12
+ }.freeze
13
+
14
+ attr_accessor :token,
15
+ :release,
16
+ :context,
17
+ :user,
18
+ :before_send,
19
+ :collector_endpoint,
20
+ :source_code_lines,
21
+ :enabled_environments,
22
+ :async
23
+
24
+ def initialize
25
+ @token = nil
26
+ @release = nil
27
+ @context = {}
28
+ @user = nil
29
+ @before_send = nil
30
+ @collector_endpoint = nil
31
+ @source_code_lines = 5
32
+ @enabled_environments = %w[production staging]
33
+ @async = true
34
+ end
35
+
36
+ def integration_id
37
+ return nil unless @token
38
+
39
+ decoded = JSON.parse(Base64.decode64(@token))
40
+ decoded["integrationId"]
41
+ rescue JSON::ParserError, ArgumentError
42
+ nil
43
+ end
44
+
45
+ def resolved_endpoint
46
+ @collector_endpoint || begin
47
+ id = integration_id
48
+ raise ConfigurationError, "Invalid integration token" unless id
49
+
50
+ "https://#{id}.k1.hawk.so/"
51
+ end
52
+ end
53
+
54
+ def valid?
55
+ !@token.nil? && !@token.empty? && !integration_id.nil?
56
+ end
57
+ end
58
+
59
+ class ConfigurationError < StandardError; end
60
+ end
61
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ class Event
6
+ attr_reader :error, :context, :user, :request_info
7
+
8
+ def initialize(error, context: nil, user: nil, request_info: nil)
9
+ @error = error
10
+ @context = context
11
+ @user = user
12
+ @request_info = request_info
13
+ end
14
+
15
+ def to_payload
16
+ config = Hawk::Rails.configuration
17
+
18
+ {
19
+ token: config.token,
20
+ catcherType: CATCHER_TYPE,
21
+ payload: build_payload(config)
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def build_payload(config)
28
+ payload = {
29
+ title: error.message,
30
+ type: error.class.name,
31
+ backtrace: parse_backtrace(config),
32
+ release: config.release,
33
+ catcherVersion: VERSION,
34
+ context: merged_context(config),
35
+ user: resolved_user(config),
36
+ addons: build_addons
37
+ }
38
+
39
+ payload[:level] = determine_level
40
+ payload.compact
41
+ end
42
+
43
+ def parse_backtrace(config)
44
+ BacktraceParser.new(
45
+ error.backtrace,
46
+ source_lines: config.source_code_lines
47
+ ).parse
48
+ end
49
+
50
+ def merged_context(config)
51
+ result = {}
52
+ result.merge!(config.context) if config.context.is_a?(Hash)
53
+ result.merge!(@context) if @context.is_a?(Hash)
54
+ result.merge!(request_context) if @request_info
55
+ result.empty? ? nil : result
56
+ end
57
+
58
+ def request_context
59
+ return {} unless @request_info
60
+
61
+ {
62
+ request: {
63
+ url: @request_info[:url],
64
+ method: @request_info[:method],
65
+ headers: @request_info[:headers],
66
+ params: @request_info[:params],
67
+ ip: @request_info[:ip]
68
+ }.compact
69
+ }
70
+ end
71
+
72
+ def resolved_user(config)
73
+ return @user if @user
74
+ return config.user if config.user
75
+
76
+ { id: generate_anonymous_id }
77
+ end
78
+
79
+ def generate_anonymous_id
80
+ require "digest"
81
+ Digest::SHA256.hexdigest("hawk-anonymous-#{Socket.gethostname}")[0..15]
82
+ end
83
+
84
+ def build_addons
85
+ {
86
+ rails: {
87
+ version: ::Rails::VERSION::STRING,
88
+ environment: ::Rails.env
89
+ },
90
+ ruby: {
91
+ version: RUBY_VERSION,
92
+ platform: RUBY_PLATFORM,
93
+ engine: RUBY_ENGINE
94
+ }
95
+ }
96
+ rescue NameError
97
+ {
98
+ ruby: {
99
+ version: RUBY_VERSION,
100
+ platform: RUBY_PLATFORM,
101
+ engine: RUBY_ENGINE
102
+ }
103
+ }
104
+ end
105
+
106
+ def determine_level
107
+ case error
108
+ when SystemExit, SignalException, NoMemoryError, SystemStackError
109
+ Configuration::LEVELS[:fatal]
110
+ when ScriptError, SecurityError
111
+ Configuration::LEVELS[:error]
112
+ when RuntimeError, StandardError
113
+ Configuration::LEVELS[:error]
114
+ else
115
+ Configuration::LEVELS[:error]
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ class Middleware
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ @app.call(env)
12
+ rescue Exception => e
13
+ request = ActionDispatch::Request.new(env) if defined?(ActionDispatch::Request)
14
+
15
+ request_info = if request
16
+ {
17
+ url: request.url,
18
+ method: request.request_method,
19
+ headers: extract_headers(env),
20
+ params: safe_params(request),
21
+ ip: request.remote_ip
22
+ }
23
+ end
24
+
25
+ user = extract_user(env)
26
+
27
+ Catcher.instance.send_event(e, request_info: request_info, user: user)
28
+
29
+ raise
30
+ end
31
+
32
+ private
33
+
34
+ def extract_headers(env)
35
+ env.each_with_object({}) do |(key, value), headers|
36
+ next unless key.start_with?("HTTP_")
37
+ next if key == "HTTP_COOKIE"
38
+
39
+ header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
40
+ headers[header_name] = value
41
+ end
42
+ end
43
+
44
+ def safe_params(request)
45
+ request.filtered_parameters
46
+ rescue
47
+ {}
48
+ end
49
+
50
+ def extract_user(env)
51
+ return nil unless defined?(::Warden) || env["warden"]
52
+
53
+ warden = env["warden"]
54
+ return nil unless warden
55
+
56
+ current_user = warden.user
57
+ return nil unless current_user
58
+
59
+ {
60
+ id: current_user.try(:id)&.to_s,
61
+ name: current_user.try(:name) || current_user.try(:email),
62
+ url: nil
63
+ }.compact
64
+ rescue
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "hawk_rails.configure" do |app|
7
+ app.middleware.insert_before(0, Hawk::Rails::Middleware)
8
+ end
9
+
10
+ config.after_initialize do
11
+ if Hawk::Rails.configuration.valid?
12
+ ::Rails.logger&.info("[Hawk] Catcher initialized for #{::Rails.env}")
13
+ else
14
+ ::Rails.logger&.warn("[Hawk] Integration token is not configured. Set it in config/initializers/hawk.rb")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ class SourceCodeReader
6
+ class << self
7
+ def read(file, line_number, context_lines = 5)
8
+ return nil unless file && File.exist?(file) && File.readable?(file)
9
+
10
+ lines = File.readlines(file)
11
+ start_line = [line_number - context_lines, 1].max
12
+ end_line = [line_number + context_lines, lines.size].min
13
+
14
+ (start_line..end_line).map do |n|
15
+ {
16
+ line: n,
17
+ content: lines[n - 1]&.chomp || ""
18
+ }
19
+ end
20
+ rescue Errno::ENOENT, Errno::EACCES
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/hawk/rails.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ require_relative "rails/version"
9
+ require_relative "rails/configuration"
10
+ require_relative "rails/event"
11
+ require_relative "rails/backtrace_parser"
12
+ require_relative "rails/source_code_reader"
13
+ require_relative "rails/catcher"
14
+ require_relative "rails/middleware"
15
+ require_relative "rails/railtie"
16
+
17
+ module Hawk
18
+ module Rails
19
+ CATCHER_TYPE = "errors/ruby"
20
+
21
+ class << self
22
+ attr_writer :configuration
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield(configuration)
30
+ end
31
+
32
+ def send(error, context: nil, user: nil)
33
+ Catcher.instance.send_event(error, context: context, user: user)
34
+ end
35
+
36
+ def reset!
37
+ @configuration = Configuration.new
38
+ Catcher.reset!
39
+ end
40
+ end
41
+ end
42
+ end
data/lib/hawk-rails.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hawk/rails"
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hawk-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander Panasenkov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '8.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '8.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: net-http
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: actionpack
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '8.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '8.0'
111
+ description: Captures unhandled exceptions and custom events in Rails 8+ applications
112
+ and sends them to Hawk (hawk.so) error tracker.
113
+ email:
114
+ - apanasenkov@capaa.ru
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - ".rspec"
120
+ - CHANGELOG.md
121
+ - Gemfile
122
+ - LICENSE
123
+ - README.md
124
+ - Rakefile
125
+ - hawk-rails.gemspec
126
+ - lib/generators/hawk_rails/install/install_generator.rb
127
+ - lib/generators/hawk_rails/install/templates/hawk.rb.tt
128
+ - lib/hawk-rails.rb
129
+ - lib/hawk/rails.rb
130
+ - lib/hawk/rails/backtrace_parser.rb
131
+ - lib/hawk/rails/catcher.rb
132
+ - lib/hawk/rails/configuration.rb
133
+ - lib/hawk/rails/event.rb
134
+ - lib/hawk/rails/middleware.rb
135
+ - lib/hawk/rails/railtie.rb
136
+ - lib/hawk/rails/source_code_reader.rb
137
+ - lib/hawk/rails/version.rb
138
+ homepage: https://github.com/capaas/hawk-rails
139
+ licenses:
140
+ - MIT
141
+ metadata:
142
+ homepage_uri: https://github.com/capaas/hawk-rails
143
+ source_code_uri: https://github.com/capaas/hawk-rails
144
+ changelog_uri: https://github.com/capaas/hawk-rails/blob/main/CHANGELOG.md
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '3.2'
154
+ required_rubygems_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ requirements: []
160
+ rubygems_version: 3.2.22
161
+ signing_key:
162
+ specification_version: 4
163
+ summary: Hawk error tracker catcher for Ruby on Rails
164
+ test_files: []