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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile +12 -0
- data/LICENSE +21 -0
- data/README.md +210 -0
- data/Rakefile +7 -0
- data/hawk-rails.gemspec +39 -0
- data/lib/generators/hawk_rails/install/install_generator.rb +26 -0
- data/lib/generators/hawk_rails/install/templates/hawk.rb.tt +35 -0
- data/lib/hawk/rails/backtrace_parser.rb +41 -0
- data/lib/hawk/rails/catcher.rb +76 -0
- data/lib/hawk/rails/configuration.rb +61 -0
- data/lib/hawk/rails/event.rb +120 -0
- data/lib/hawk/rails/middleware.rb +69 -0
- data/lib/hawk/rails/railtie.rb +19 -0
- data/lib/hawk/rails/source_code_reader.rb +26 -0
- data/lib/hawk/rails/version.rb +7 -0
- data/lib/hawk/rails.rb +42 -0
- data/lib/hawk-rails.rb +3 -0
- metadata +164 -0
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
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
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
data/hawk-rails.gemspec
ADDED
|
@@ -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
|
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
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: []
|