rails-informant 0.6.2 → 0.7.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 +4 -4
- data/README.md +92 -2
- data/app/assets/javascripts/rails_informant.js +238 -0
- data/app/controllers/concerns/rails_informant/security_headers.rb +18 -0
- data/app/controllers/rails_informant/api/base_controller.rb +8 -8
- data/app/controllers/rails_informant/api/errors_controller.rb +2 -0
- data/app/controllers/rails_informant/api/occurrences_controller.rb +2 -0
- data/app/controllers/rails_informant/api/status_controller.rb +11 -1
- data/app/controllers/rails_informant/browser_reports_controller.rb +82 -0
- data/app/helpers/rails_informant/browser_helper.rb +34 -0
- data/app/models/rails_informant/error_group.rb +17 -0
- data/app/models/rails_informant/occurrence.rb +2 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20260227000000_create_informant_tables.rb +4 -0
- data/lib/generators/rails_informant/browser_generator.rb +103 -0
- data/lib/generators/rails_informant/install_generator.rb +3 -0
- data/lib/generators/rails_informant/templates/add_browser_capture_to_informant_tables.rb.erb +33 -0
- data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +4 -0
- data/lib/generators/rails_informant/templates/initializer.rb.erb +6 -0
- data/lib/rails_informant/browser_context.rb +48 -0
- data/lib/rails_informant/browser_error.rb +14 -0
- data/lib/rails_informant/browser_fingerprint.rb +21 -0
- data/lib/rails_informant/browser_report.rb +102 -0
- data/lib/rails_informant/configuration.rb +5 -1
- data/lib/rails_informant/context_builder.rb +25 -2
- data/lib/rails_informant/current.rb +1 -1
- data/lib/rails_informant/engine.rb +6 -0
- data/lib/rails_informant/error_recorder.rb +77 -13
- data/lib/rails_informant/event.rb +4 -3
- data/lib/rails_informant/fingerprint.rb +2 -0
- data/lib/rails_informant/javascript_stack.rb +93 -0
- data/lib/rails_informant/mcp/client.rb +4 -4
- data/lib/rails_informant/mcp/server.rb +20 -0
- data/lib/rails_informant/mcp/tools/get_informant_status.rb +1 -1
- data/lib/rails_informant/mcp/tools/list_errors.rb +1 -0
- data/lib/rails_informant/mcp/tools/list_occurrences.rb +1 -0
- data/lib/rails_informant/notifiers/slack.rb +22 -8
- data/lib/rails_informant/notifiers/webhook.rb +1 -0
- data/lib/rails_informant.rb +32 -4
- data/skills/informant/SKILL.md +12 -0
- metadata +12 -1
data/config/routes.rb
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
RailsInformant::Engine.routes.draw do
|
|
2
|
+
# Checked per request: the host enables capture in an initializer, which
|
|
3
|
+
# runs after the engine's routes are loaded.
|
|
4
|
+
resources :browser_reports, only: :create, constraints: ->(_) { RailsInformant.capture_browser_errors }
|
|
5
|
+
|
|
2
6
|
namespace :api do
|
|
3
7
|
scope "v1" do
|
|
4
8
|
resources :errors, only: [ :index, :show, :update, :destroy ] do
|
|
@@ -9,6 +9,7 @@ class CreateInformantTables < ActiveRecord::Migration[8.1]
|
|
|
9
9
|
t.string :first_backtrace_line
|
|
10
10
|
t.string :controller_action
|
|
11
11
|
t.string :job_class
|
|
12
|
+
t.string :source, null: false, default: "server"
|
|
12
13
|
t.integer :total_occurrences, default: 0, null: false
|
|
13
14
|
t.references :duplicate_of, foreign_key: { to_table: :informant_error_groups }
|
|
14
15
|
t.string :fix_sha
|
|
@@ -23,6 +24,7 @@ class CreateInformantTables < ActiveRecord::Migration[8.1]
|
|
|
23
24
|
t.datetime :last_occurrence_stored_at
|
|
24
25
|
t.timestamps
|
|
25
26
|
|
|
27
|
+
t.index [ :source, :status ]
|
|
26
28
|
t.index [ :status, :last_seen_at ]
|
|
27
29
|
t.index [ :status, :original_sha ]
|
|
28
30
|
t.index [ :status, :resolved_at ]
|
|
@@ -55,7 +57,9 @@ class CreateInformantTables < ActiveRecord::Migration[8.1]
|
|
|
55
57
|
t.jsonb :custom_context
|
|
56
58
|
t.jsonb :environment_context
|
|
57
59
|
t.jsonb :breadcrumbs
|
|
60
|
+
t.string :failing_request_id, index: true
|
|
58
61
|
t.string :git_sha
|
|
62
|
+
t.string :request_id, index: true
|
|
59
63
|
t.timestamps
|
|
60
64
|
|
|
61
65
|
t.index [ :error_group_id, :created_at ]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module RailsInformant
|
|
5
|
+
class BrowserGenerator < Rails::Generators::Base
|
|
6
|
+
include ActiveRecord::Generators::Migration
|
|
7
|
+
|
|
8
|
+
HEAD_CLOSE_LINE = /^([ \t]*)<\/head>/
|
|
9
|
+
IMPORTMAP = "config/importmap.rb"
|
|
10
|
+
IMPORTMAP_TAGS_LINE = /^([ \t]*)<%=\s*javascript_importmap_tags/
|
|
11
|
+
INITIALIZER = "config/initializers/rails_informant.rb"
|
|
12
|
+
LAYOUT = "app/views/layouts/application.html.erb"
|
|
13
|
+
TAG = "<%= informant_browser_tag %>"
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
if browser_columns_present?
|
|
19
|
+
say "Database already has the browser capture columns, skipping the migration.", :yellow
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
migration_template "add_browser_capture_to_informant_tables.rb.erb",
|
|
24
|
+
"db/migrate/add_browser_capture_to_informant_tables.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pin_module
|
|
28
|
+
return say_missing(IMPORTMAP, %(add `pin "rails_informant"` to your import map)) unless exist?(IMPORTMAP)
|
|
29
|
+
return if read(IMPORTMAP).match?(/^\s*pin\s+["']rails_informant["']/)
|
|
30
|
+
|
|
31
|
+
separator = read(IMPORTMAP).end_with?("\n") ? "" : "\n"
|
|
32
|
+
append_to_file IMPORTMAP, %(#{separator}pin "rails_informant"\n)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_layout_tag
|
|
36
|
+
return say_missing(LAYOUT, "add #{TAG} to your layout's <head>, before javascript_importmap_tags") unless exist?(LAYOUT)
|
|
37
|
+
|
|
38
|
+
layout = read(LAYOUT)
|
|
39
|
+
return if layout.include?("informant_browser_tag")
|
|
40
|
+
|
|
41
|
+
if (indent = layout[IMPORTMAP_TAGS_LINE, 1])
|
|
42
|
+
inject_into_file LAYOUT, "#{indent}#{TAG}\n", before: IMPORTMAP_TAGS_LINE
|
|
43
|
+
elsif (indent = layout[HEAD_CLOSE_LINE, 1])
|
|
44
|
+
inject_into_file LAYOUT, "#{indent} #{TAG}\n", before: HEAD_CLOSE_LINE
|
|
45
|
+
else
|
|
46
|
+
say "No javascript_importmap_tags or </head> in #{LAYOUT}. Add #{TAG} to your layout's <head> manually.", :red
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def enable_capture
|
|
51
|
+
return say_missing(INITIALIZER, "set `config.capture_browser_errors = true` in your RailsInformant.configure block") unless exist?(INITIALIZER)
|
|
52
|
+
|
|
53
|
+
initializer = read(INITIALIZER)
|
|
54
|
+
commented = /^([ \t]*)#\s*(config\.capture_browser_errors = true)$/
|
|
55
|
+
|
|
56
|
+
if initializer.match?(commented)
|
|
57
|
+
gsub_file INITIALIZER, commented, '\1\2'
|
|
58
|
+
elsif initializer.include?("capture_browser_errors")
|
|
59
|
+
nil
|
|
60
|
+
elsif (block = initializer.match(/^RailsInformant\.configure do \|(\w+)\|\n.*?(?=^end\b)/m))
|
|
61
|
+
inject_into_file INITIALIZER, " #{block[1]}.capture_browser_errors = true\n", after: block[0]
|
|
62
|
+
else
|
|
63
|
+
say "No RailsInformant.configure block in #{INITIALIZER}. Set config.capture_browser_errors = true manually.", :red
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def print_next_steps
|
|
68
|
+
say ""
|
|
69
|
+
say "Browser error capture set up!", :green
|
|
70
|
+
say ""
|
|
71
|
+
say "Next steps:", :yellow
|
|
72
|
+
say " 1. Run migrations:"
|
|
73
|
+
say " bin/rails db:migrate"
|
|
74
|
+
say ""
|
|
75
|
+
say " 2. Restart the app server."
|
|
76
|
+
say ""
|
|
77
|
+
say " 3. Load a page and check the browser console and network panel"
|
|
78
|
+
say " for the rails_informant module."
|
|
79
|
+
say ""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# No connection or no informant tables yet (install migration not run) both
|
|
85
|
+
# count as "absent": the migration's own guards make writing it harmless.
|
|
86
|
+
def browser_columns_present?
|
|
87
|
+
connection = ActiveRecord::Base.connection
|
|
88
|
+
connection.column_exists?(:informant_error_groups, :source) &&
|
|
89
|
+
connection.column_exists?(:informant_occurrences, :request_id) &&
|
|
90
|
+
connection.column_exists?(:informant_occurrences, :failing_request_id)
|
|
91
|
+
rescue ActiveRecord::ActiveRecordError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def exist?(relative_path) = File.exist?(File.join(destination_root, relative_path))
|
|
96
|
+
|
|
97
|
+
def read(relative_path) = File.read(File.join(destination_root, relative_path))
|
|
98
|
+
|
|
99
|
+
def say_missing(relative_path, instruction)
|
|
100
|
+
say "#{relative_path} not found, skipping. #{instruction.upcase_first}.", :red
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -37,6 +37,9 @@ module RailsInformant
|
|
|
37
37
|
say " 3. Install Claude Code integration:"
|
|
38
38
|
say " bin/rails generate rails_informant:skill"
|
|
39
39
|
say ""
|
|
40
|
+
say " 4. Optional: capture browser JavaScript errors (importmap apps):"
|
|
41
|
+
say " bin/rails generate rails_informant:browser"
|
|
42
|
+
say ""
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
private
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class AddBrowserCaptureToInformantTables < ActiveRecord::Migration[8.1]
|
|
2
|
+
# up/down rather than change: a reversed `change` re-evaluates the guards
|
|
3
|
+
# against the migrated schema, skips every step and leaves the columns behind.
|
|
4
|
+
def up
|
|
5
|
+
unless column_exists?(:informant_error_groups, :source)
|
|
6
|
+
add_column :informant_error_groups, :source, :string, null: false, default: "server"
|
|
7
|
+
end
|
|
8
|
+
unless index_exists?(:informant_error_groups, [ :source, :status ])
|
|
9
|
+
add_index :informant_error_groups, [ :source, :status ]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
unless column_exists?(:informant_occurrences, :failing_request_id)
|
|
13
|
+
add_column :informant_occurrences, :failing_request_id, :string
|
|
14
|
+
end
|
|
15
|
+
unless index_exists?(:informant_occurrences, :failing_request_id)
|
|
16
|
+
add_index :informant_occurrences, :failing_request_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
unless column_exists?(:informant_occurrences, :request_id)
|
|
20
|
+
add_column :informant_occurrences, :request_id, :string
|
|
21
|
+
end
|
|
22
|
+
unless index_exists?(:informant_occurrences, :request_id)
|
|
23
|
+
add_index :informant_occurrences, :request_id
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def down
|
|
28
|
+
remove_column :informant_occurrences, :request_id, if_exists: true
|
|
29
|
+
remove_column :informant_occurrences, :failing_request_id, if_exists: true
|
|
30
|
+
remove_index :informant_error_groups, [ :source, :status ], if_exists: true
|
|
31
|
+
remove_column :informant_error_groups, :source, if_exists: true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -9,6 +9,7 @@ class CreateInformantTables < ActiveRecord::Migration[8.1]
|
|
|
9
9
|
t.string :first_backtrace_line
|
|
10
10
|
t.string :controller_action
|
|
11
11
|
t.string :job_class
|
|
12
|
+
t.string :source, null: false, default: "server"
|
|
12
13
|
t.integer :total_occurrences, default: 0, null: false
|
|
13
14
|
t.references :duplicate_of, foreign_key: { to_table: :informant_error_groups }
|
|
14
15
|
t.string :fix_sha
|
|
@@ -23,6 +24,7 @@ class CreateInformantTables < ActiveRecord::Migration[8.1]
|
|
|
23
24
|
t.datetime :last_occurrence_stored_at
|
|
24
25
|
t.timestamps
|
|
25
26
|
|
|
27
|
+
t.index [ :source, :status ]
|
|
26
28
|
t.index [ :status, :last_seen_at ]
|
|
27
29
|
t.index [ :status, :original_sha ]
|
|
28
30
|
t.index [ :status, :resolved_at ]
|
|
@@ -45,7 +47,9 @@ class CreateInformantTables < ActiveRecord::Migration[8.1]
|
|
|
45
47
|
t.<%= json_column_type %> :custom_context
|
|
46
48
|
t.<%= json_column_type %> :environment_context
|
|
47
49
|
t.<%= json_column_type %> :breadcrumbs
|
|
50
|
+
t.string :failing_request_id, index: true
|
|
48
51
|
t.string :git_sha
|
|
52
|
+
t.string :request_id, index: true
|
|
49
53
|
t.timestamps
|
|
50
54
|
|
|
51
55
|
t.index [ :error_group_id, :created_at ]
|
|
@@ -26,4 +26,10 @@ RailsInformant.configure do |config|
|
|
|
26
26
|
|
|
27
27
|
# Auto-purge resolved errors after N days (nil = keep forever)
|
|
28
28
|
# config.retention_days = 30
|
|
29
|
+
|
|
30
|
+
# Browser error capture (off by default; `rails g rails_informant:browser` enables it)
|
|
31
|
+
# config.capture_browser_errors = true
|
|
32
|
+
|
|
33
|
+
# Most new browser error groups created per hour (default 50)
|
|
34
|
+
# config.browser_new_group_limit = 50
|
|
29
35
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
# The render-time context a page echoes back in its reports. Encrypted, not
|
|
3
|
+
# just signed, so user details stay unreadable in page HTML that third-party
|
|
4
|
+
# scripts or cached copies can see.
|
|
5
|
+
module BrowserContext
|
|
6
|
+
IDENTITY_TTL = 24.hours
|
|
7
|
+
PURPOSE = :browser_report
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def generate(request)
|
|
11
|
+
encryptor.encrypt_and_sign({
|
|
12
|
+
"rendered_at" => Time.current.iso8601(6),
|
|
13
|
+
"request_id" => request.request_id,
|
|
14
|
+
"revision" => RailsInformant.current_git_sha,
|
|
15
|
+
"user_context" => ContextBuilder.build_user_context(request.env)
|
|
16
|
+
}, purpose: PURPOSE)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The message has no expiry so a long-open tab still supplies its render
|
|
20
|
+
# time and revision, which keep a stale page from reopening a fixed
|
|
21
|
+
# group; only the identity parts age out.
|
|
22
|
+
def verify(blob)
|
|
23
|
+
return unless blob.is_a?(String)
|
|
24
|
+
|
|
25
|
+
payload = encryptor.decrypt_and_verify(blob, purpose: PURPOSE) or return
|
|
26
|
+
rendered_at = Time.iso8601(payload["rendered_at"])
|
|
27
|
+
fresh = rendered_at > IDENTITY_TTL.ago
|
|
28
|
+
|
|
29
|
+
BrowserReport::Context.new(
|
|
30
|
+
rendered_at:,
|
|
31
|
+
request_id: (payload["request_id"] if fresh),
|
|
32
|
+
revision: payload["revision"],
|
|
33
|
+
user_context: (payload["user_context"] if fresh)
|
|
34
|
+
)
|
|
35
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# The serializer is explicit so the host's message_serializer default
|
|
42
|
+
# cannot change the payload's shape or key types.
|
|
43
|
+
def encryptor
|
|
44
|
+
ActiveSupport::MessageEncryptor.new Rails.application.key_generator.generate_key("informant_browser", 32), serializer: :json
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
# Stands in for a JavaScript error so before_record callbacks can read
|
|
3
|
+
# message and backtrace as they do for Ruby errors. The JS name lives in
|
|
4
|
+
# the group's error_class string; no constant is ever made from it.
|
|
5
|
+
class BrowserError < StandardError
|
|
6
|
+
attr_reader :name
|
|
7
|
+
|
|
8
|
+
def initialize(message, backtrace:, name:)
|
|
9
|
+
super(message)
|
|
10
|
+
@name = name
|
|
11
|
+
set_backtrace backtrace
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module RailsInformant
|
|
4
|
+
class BrowserFingerprint
|
|
5
|
+
HEX = /\b(?:0x)?(?=[0-9a-f]*\d)[0-9a-f]{8,}\b/i
|
|
6
|
+
URL = %r{\b[a-z][a-z0-9+.-]*://\S+}i
|
|
7
|
+
UUID = /\b\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\b/
|
|
8
|
+
|
|
9
|
+
# The message is left out when an app frame exists because browsers word
|
|
10
|
+
# the same failure differently, which would split one bug into a group
|
|
11
|
+
# per engine.
|
|
12
|
+
def self.generate(error_class:, message:, stack:)
|
|
13
|
+
key = stack.first_app_frame&.fingerprint_key || normalize_message(message)
|
|
14
|
+
Digest::SHA256.hexdigest "browser:#{error_class}:#{key}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.normalize_message(message)
|
|
18
|
+
message.to_s.gsub(URL, "<url>").gsub(UUID, "<uuid>").gsub(HEX, "<hex>").gsub(/\d+/, "<n>")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
# One browser report, normalized at the boundary: every field comes from
|
|
3
|
+
# the page and is attacker-controlled, so only allowlisted keys and types
|
|
4
|
+
# are read, strings are capped and filtered, and identity comes only from
|
|
5
|
+
# the verified render-time context.
|
|
6
|
+
class BrowserReport
|
|
7
|
+
# What the server vouched for when it rendered the page. Any field may be
|
|
8
|
+
# nil, and the whole context is nil when the page sent none or it failed
|
|
9
|
+
# verification.
|
|
10
|
+
Context = Data.define(:rendered_at, :request_id, :revision, :user_context)
|
|
11
|
+
|
|
12
|
+
BREADCRUMB_KEYS = %w[category duration message metadata timestamp].freeze
|
|
13
|
+
URL_BREADCRUMB_CATEGORIES = %w[fetch.error turbo.visit].freeze
|
|
14
|
+
MAX_BACKTRACE_LINE_LENGTH = 255
|
|
15
|
+
MAX_BREADCRUMBS = 20
|
|
16
|
+
MAX_MESSAGE_LENGTH = 1_000
|
|
17
|
+
MAX_REQUEST_ID_LENGTH = 255
|
|
18
|
+
MAX_STACK_LENGTH = 20_000
|
|
19
|
+
NAME_PATTERN = /\A[A-Z][A-Za-z0-9_]{0,63}\z/
|
|
20
|
+
|
|
21
|
+
attr_reader :context, :error, :error_class, :message, :page_path, :stack
|
|
22
|
+
|
|
23
|
+
def initialize(report, context:, base_url:, remote_ip:, user_agent:)
|
|
24
|
+
@report = report
|
|
25
|
+
@context = context
|
|
26
|
+
@remote_ip = remote_ip
|
|
27
|
+
@user_agent = user_agent
|
|
28
|
+
|
|
29
|
+
@name = string(report["name"])
|
|
30
|
+
@error_class = "JS::#{NAME_PATTERN.match?(@name) ? @name : "Error"}"
|
|
31
|
+
@message = string(report["message"]).truncate(MAX_MESSAGE_LENGTH)
|
|
32
|
+
@stack = JavascriptStack.parse(string(report["stack"]).first(MAX_STACK_LENGTH), origin: base_url)
|
|
33
|
+
@page_url = string(report["url"]).split("#").first.presence
|
|
34
|
+
@page_path = ContextBuilder.page_path(@page_url)
|
|
35
|
+
@error = BrowserError.new(message, backtrace:, name: @name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def group_attributes(now:)
|
|
39
|
+
{
|
|
40
|
+
controller_action: nil,
|
|
41
|
+
created_at: now,
|
|
42
|
+
error_class:,
|
|
43
|
+
first_backtrace_line:,
|
|
44
|
+
first_seen_at: now,
|
|
45
|
+
job_class: nil,
|
|
46
|
+
last_seen_at: now,
|
|
47
|
+
message:,
|
|
48
|
+
severity: "error",
|
|
49
|
+
source: "browser",
|
|
50
|
+
total_occurrences: 1,
|
|
51
|
+
updated_at: now
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def occurrence_attributes
|
|
56
|
+
{
|
|
57
|
+
backtrace: error.backtrace,
|
|
58
|
+
breadcrumbs:,
|
|
59
|
+
environment_context: ContextBuilder.build_environment_context,
|
|
60
|
+
failing_request_id: string(report["failing_request_id"]).first(MAX_REQUEST_ID_LENGTH).presence,
|
|
61
|
+
git_sha: context&.revision,
|
|
62
|
+
request_context: ContextFilter.filter(ip: remote_ip, url: page_url && ContextBuilder.filtered_url(page_url), user_agent:),
|
|
63
|
+
request_id: context&.request_id,
|
|
64
|
+
user_context: ContextFilter.filter(context&.user_context)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :name, :page_url, :remote_ip, :report, :user_agent
|
|
71
|
+
|
|
72
|
+
# V8 starts error.stack with the error's own "Name: message" line, which
|
|
73
|
+
# is not a frame and would otherwise show up as the top of the backtrace.
|
|
74
|
+
def backtrace
|
|
75
|
+
lines = stack.backtrace
|
|
76
|
+
lines = lines.drop(1) if v8_header?(lines.first)
|
|
77
|
+
ContextFilter.filter_backtrace(lines.map { it.first(MAX_BACKTRACE_LINE_LENGTH) })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def v8_header?(line)
|
|
81
|
+
name.present? && (line == name || line&.start_with?("#{name}:"))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def breadcrumbs
|
|
85
|
+
Array(report["breadcrumbs"]).grep(Hash).first(MAX_BREADCRUMBS).map do |crumb|
|
|
86
|
+
crumb = ContextFilter.filter(crumb.slice(*BREADCRUMB_KEYS))
|
|
87
|
+
if URL_BREADCRUMB_CATEGORIES.include?(crumb["category"]) && crumb["message"].is_a?(String)
|
|
88
|
+
crumb["message"] = ContextBuilder.filtered_url(crumb["message"])
|
|
89
|
+
end
|
|
90
|
+
crumb
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def first_backtrace_line
|
|
95
|
+
(stack.first_app_frame || stack.frames.first)&.to_s&.first(MAX_BACKTRACE_LINE_LENGTH)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def string(value)
|
|
99
|
+
value.is_a?(String) ? value : ""
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module RailsInformant
|
|
2
2
|
class Configuration
|
|
3
|
-
attr_accessor :
|
|
3
|
+
attr_accessor :browser_new_group_limit,
|
|
4
|
+
:capture_browser_errors,
|
|
5
|
+
:capture_errors,
|
|
4
6
|
:capture_runner_errors,
|
|
5
7
|
:capture_user_email,
|
|
6
8
|
:api_token,
|
|
@@ -17,6 +19,8 @@ module RailsInformant
|
|
|
17
19
|
@before_record_callbacks = []
|
|
18
20
|
@api_token = ENV["INFORMANT_API_TOKEN"]
|
|
19
21
|
@app_name = ENV["INFORMANT_APP_NAME"]
|
|
22
|
+
@browser_new_group_limit = ENV.fetch("INFORMANT_BROWSER_NEW_GROUP_LIMIT", "50").to_i
|
|
23
|
+
@capture_browser_errors = ENV["INFORMANT_CAPTURE_BROWSER_ERRORS"] == "true"
|
|
20
24
|
@capture_errors = ENV.fetch("INFORMANT_CAPTURE_ERRORS", "true") != "false"
|
|
21
25
|
@capture_runner_errors = ENV.fetch("INFORMANT_CAPTURE_RUNNER_ERRORS", "true") != "false"
|
|
22
26
|
@capture_user_email = false
|
|
@@ -68,7 +68,7 @@ module RailsInformant
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def group_attributes(error, severity:, context:, env:, now:)
|
|
71
|
-
{
|
|
71
|
+
attrs = {
|
|
72
72
|
error_class: error.class.name, severity:,
|
|
73
73
|
message: ContextFilter.filter_message(error.message),
|
|
74
74
|
first_backtrace_line: Fingerprint.first_app_frame(error),
|
|
@@ -77,10 +77,12 @@ module RailsInformant
|
|
|
77
77
|
first_seen_at: now, last_seen_at: now,
|
|
78
78
|
total_occurrences: 1, created_at: now, updated_at: now
|
|
79
79
|
}
|
|
80
|
+
attrs[:source] = "server" if RailsInformant.browser_schema_ready?
|
|
81
|
+
attrs
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
def occurrence_attributes(error, env:, context:)
|
|
83
|
-
{
|
|
85
|
+
attrs = {
|
|
84
86
|
backtrace: ContextFilter.filter_backtrace(error.backtrace),
|
|
85
87
|
exception_chain: build_exception_chain(error),
|
|
86
88
|
request_context: build_request_context(env),
|
|
@@ -90,6 +92,8 @@ module RailsInformant
|
|
|
90
92
|
breadcrumbs: BreadcrumbBuffer.current.flush,
|
|
91
93
|
git_sha: RailsInformant.current_git_sha
|
|
92
94
|
}
|
|
95
|
+
attrs[:request_id] = extract_request_id(env, context) if RailsInformant.browser_schema_ready?
|
|
96
|
+
attrs
|
|
93
97
|
end
|
|
94
98
|
|
|
95
99
|
def build_custom_context(error)
|
|
@@ -112,6 +116,19 @@ module RailsInformant
|
|
|
112
116
|
end
|
|
113
117
|
end
|
|
114
118
|
|
|
119
|
+
# Rails assigns the ID in ActionDispatch::RequestId, so it exists even
|
|
120
|
+
# when no proxy sent an X-Request-Id header.
|
|
121
|
+
def extract_request_id(env, context)
|
|
122
|
+
if env
|
|
123
|
+
env["action_dispatch.request_id"]
|
|
124
|
+
else
|
|
125
|
+
case context
|
|
126
|
+
in { controller: ActionController::Metal => controller } then controller.request.request_id
|
|
127
|
+
else nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
115
132
|
def extract_job_class(context)
|
|
116
133
|
case context[:job]
|
|
117
134
|
in ActiveJob::Base => job then job.class.name
|
|
@@ -120,6 +137,12 @@ module RailsInformant
|
|
|
120
137
|
end
|
|
121
138
|
end
|
|
122
139
|
|
|
140
|
+
def page_path(url)
|
|
141
|
+
URI.parse(url).path.presence if url
|
|
142
|
+
rescue URI::InvalidURIError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
123
146
|
def filtered_url(url)
|
|
124
147
|
uri = URI.parse(url)
|
|
125
148
|
if uri.query.present?
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module RailsInformant
|
|
2
2
|
class Current < ActiveSupport::CurrentAttributes
|
|
3
|
-
attribute :breadcrumbs, :custom_context, :delivering_notification, :silenced, :user_context
|
|
3
|
+
attribute :breadcrumbs, :browser_group_limit_logged, :custom_context, :delivering_notification, :ingesting_browser_report, :silenced, :user_context
|
|
4
4
|
end
|
|
5
5
|
end
|
|
@@ -2,6 +2,12 @@ module RailsInformant
|
|
|
2
2
|
class Engine < ::Rails::Engine
|
|
3
3
|
isolate_namespace RailsInformant
|
|
4
4
|
|
|
5
|
+
# Always included: the generator writes the tag into the host layout for
|
|
6
|
+
# good, and the helper itself returns nil while browser capture is off.
|
|
7
|
+
initializer "rails_informant.browser_helper" do
|
|
8
|
+
ActiveSupport.on_load(:action_view) { include RailsInformant::BrowserHelper }
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
initializer "rails_informant.error_subscriber" do
|
|
6
12
|
next unless RailsInformant.capture_errors
|
|
7
13
|
|
|
@@ -29,24 +29,86 @@ module RailsInformant
|
|
|
29
29
|
event = run_before_record_callbacks(error, attrs, env:, fingerprint:)
|
|
30
30
|
return if event.halted?
|
|
31
31
|
|
|
32
|
-
fingerprint = event.fingerprint
|
|
33
32
|
attrs[:severity] = event.severity if %w[error warning info].include?(event.severity)
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
record_group(event.fingerprint, attrs) { ContextBuilder.occurrence_attributes(error, env:, context:) }
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
log_capture_failure e
|
|
37
|
+
end
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
# Called by the browser ingest endpoint once per report. The ingest
|
|
40
|
+
# request's own env, params and breadcrumbs describe the endpoint, not
|
|
41
|
+
# the page, so none of them reach the stored group or occurrence.
|
|
42
|
+
def record_browser(report, context:, base_url:, remote_ip:, user_agent:)
|
|
43
|
+
return unless RailsInformant.initialized?
|
|
44
|
+
|
|
45
|
+
browser_report = BrowserReport.new(report, context:, base_url:, remote_ip:, user_agent:)
|
|
46
|
+
return if RailsInformant.ignored_request_path?(browser_report.page_path)
|
|
47
|
+
return if RailsInformant.ignored_error_class?(browser_report.error_class)
|
|
48
|
+
|
|
49
|
+
attrs = browser_report.group_attributes(now: Time.current)
|
|
50
|
+
fingerprint = BrowserFingerprint.generate(error_class: attrs[:error_class], message: attrs[:message], stack: browser_report.stack)
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
event = run_before_record_callbacks(browser_report.error, attrs, fingerprint:, request_path: browser_report.page_path, source: "browser")
|
|
53
|
+
return if event.halted?
|
|
54
|
+
|
|
55
|
+
attrs[:severity] = event.severity if %w[error warning info].include?(event.severity)
|
|
56
|
+
return if browser_group_limit_reached?(event.fingerprint)
|
|
57
|
+
|
|
58
|
+
record_group(event.fingerprint, attrs, page_rendered_at: context&.rendered_at, browser: true) { browser_report.occurrence_attributes }
|
|
42
59
|
rescue StandardError => e
|
|
43
|
-
|
|
60
|
+
log_capture_failure e
|
|
61
|
+
ensure
|
|
62
|
+
BreadcrumbBuffer.current.flush
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
private
|
|
47
66
|
|
|
48
|
-
def
|
|
49
|
-
|
|
67
|
+
def record_group(fingerprint, attrs, browser: false, page_rendered_at: nil, &occurrence_attributes)
|
|
68
|
+
group = ErrorGroup.find_or_create_for(fingerprint, attrs)
|
|
69
|
+
stale_page = browser && group.rendered_before_resolution?(page_rendered_at)
|
|
70
|
+
group.detect_regression! unless stale_page
|
|
71
|
+
|
|
72
|
+
return if spike_limit_exceeded?(group)
|
|
73
|
+
|
|
74
|
+
store_occurrence(group, occurrence_attributes) if should_store_occurrence?(group)
|
|
75
|
+
# The group stays resolved, so the notification policy would
|
|
76
|
+
# otherwise alert on an error the fix already addressed.
|
|
77
|
+
notify(group) unless stale_page
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A DB count rather than a process counter, so the cap holds across
|
|
81
|
+
# workers and under a null cache store.
|
|
82
|
+
def browser_group_limit_reached?(fingerprint)
|
|
83
|
+
return false if ErrorGroup.exists?(fingerprint:)
|
|
84
|
+
|
|
85
|
+
limit = RailsInformant.config.browser_new_group_limit
|
|
86
|
+
return false if ErrorGroup.browser_created_this_hour.count < limit
|
|
87
|
+
|
|
88
|
+
unless Current.browser_group_limit_logged
|
|
89
|
+
Current.browser_group_limit_logged = true
|
|
90
|
+
Rails.logger.warn "[RailsInformant] Dropped browser report: browser_new_group_limit (#{limit}) new groups already created this hour"
|
|
91
|
+
record_browser_group_limit limit
|
|
92
|
+
end
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Recorded as an ordinary server group so a full cap alerts like any
|
|
97
|
+
# error instead of only reaching the log. It skips record's
|
|
98
|
+
# self-caused check, which the ingest request would otherwise trip.
|
|
99
|
+
def record_browser_group_limit(limit)
|
|
100
|
+
raise BrowserGroupLimitReached, "browser_new_group_limit (#{limit}) reached: new browser error groups are dropped for the rest of the hour"
|
|
101
|
+
rescue BrowserGroupLimitReached => e
|
|
102
|
+
attrs = ContextBuilder.group_attributes(e, severity: "warning", context: {}, env: nil, now: Time.current)
|
|
103
|
+
record_group(Fingerprint.generate(e), attrs) { ContextBuilder.occurrence_attributes(e, env: nil, context: {}) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def log_capture_failure(error)
|
|
107
|
+
Rails.logger.error "[RailsInformant] Capture failed: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_before_record_callbacks(error, attrs, fingerprint:, **)
|
|
111
|
+
event = Event.new(error, attrs, fingerprint:, **)
|
|
50
112
|
callbacks = RailsInformant.config.before_record_callbacks
|
|
51
113
|
|
|
52
114
|
callbacks.each do |callback|
|
|
@@ -89,10 +151,12 @@ module RailsInformant
|
|
|
89
151
|
end
|
|
90
152
|
|
|
91
153
|
# Detect errors caused by RailsInformant itself to prevent feedback loops.
|
|
92
|
-
# Primary: CurrentAttributes
|
|
154
|
+
# Primary: CurrentAttributes flags set during notification delivery and
|
|
155
|
+
# browser report ingest, where a recorded failure could amplify a flood.
|
|
93
156
|
# Fallback: backtrace heuristic for cross-execution scenarios (e.g. queue retries).
|
|
94
157
|
def self_caused_error?(error)
|
|
95
|
-
Current.delivering_notification ||
|
|
158
|
+
Current.delivering_notification || Current.ingesting_browser_report ||
|
|
159
|
+
error.backtrace&.any? { it.include?("rails_informant/notifiers") }
|
|
96
160
|
end
|
|
97
161
|
|
|
98
162
|
def should_store_occurrence?(group)
|
|
@@ -101,10 +165,10 @@ module RailsInformant
|
|
|
101
165
|
group.last_occurrence_stored_at.before?(OCCURRENCE_COOLDOWN.seconds.ago)
|
|
102
166
|
end
|
|
103
167
|
|
|
104
|
-
def store_occurrence(group,
|
|
168
|
+
def store_occurrence(group, occurrence_attributes)
|
|
105
169
|
now = Time.current
|
|
106
170
|
ErrorGroup.transaction do
|
|
107
|
-
group.occurrences.create!
|
|
171
|
+
group.occurrences.create! occurrence_attributes.call
|
|
108
172
|
ErrorGroup.where(id: group.id).update_all last_occurrence_stored_at: now, updated_at: now
|
|
109
173
|
group.last_occurrence_stored_at = now
|
|
110
174
|
end
|