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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +92 -2
  3. data/app/assets/javascripts/rails_informant.js +238 -0
  4. data/app/controllers/concerns/rails_informant/security_headers.rb +18 -0
  5. data/app/controllers/rails_informant/api/base_controller.rb +8 -8
  6. data/app/controllers/rails_informant/api/errors_controller.rb +2 -0
  7. data/app/controllers/rails_informant/api/occurrences_controller.rb +2 -0
  8. data/app/controllers/rails_informant/api/status_controller.rb +11 -1
  9. data/app/controllers/rails_informant/browser_reports_controller.rb +82 -0
  10. data/app/helpers/rails_informant/browser_helper.rb +34 -0
  11. data/app/models/rails_informant/error_group.rb +17 -0
  12. data/app/models/rails_informant/occurrence.rb +2 -0
  13. data/config/routes.rb +4 -0
  14. data/db/migrate/20260227000000_create_informant_tables.rb +4 -0
  15. data/lib/generators/rails_informant/browser_generator.rb +103 -0
  16. data/lib/generators/rails_informant/install_generator.rb +3 -0
  17. data/lib/generators/rails_informant/templates/add_browser_capture_to_informant_tables.rb.erb +33 -0
  18. data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +4 -0
  19. data/lib/generators/rails_informant/templates/initializer.rb.erb +6 -0
  20. data/lib/rails_informant/browser_context.rb +48 -0
  21. data/lib/rails_informant/browser_error.rb +14 -0
  22. data/lib/rails_informant/browser_fingerprint.rb +21 -0
  23. data/lib/rails_informant/browser_report.rb +102 -0
  24. data/lib/rails_informant/configuration.rb +5 -1
  25. data/lib/rails_informant/context_builder.rb +25 -2
  26. data/lib/rails_informant/current.rb +1 -1
  27. data/lib/rails_informant/engine.rb +6 -0
  28. data/lib/rails_informant/error_recorder.rb +77 -13
  29. data/lib/rails_informant/event.rb +4 -3
  30. data/lib/rails_informant/fingerprint.rb +2 -0
  31. data/lib/rails_informant/javascript_stack.rb +93 -0
  32. data/lib/rails_informant/mcp/client.rb +4 -4
  33. data/lib/rails_informant/mcp/server.rb +20 -0
  34. data/lib/rails_informant/mcp/tools/get_informant_status.rb +1 -1
  35. data/lib/rails_informant/mcp/tools/list_errors.rb +1 -0
  36. data/lib/rails_informant/mcp/tools/list_occurrences.rb +1 -0
  37. data/lib/rails_informant/notifiers/slack.rb +22 -8
  38. data/lib/rails_informant/notifiers/webhook.rb +1 -0
  39. data/lib/rails_informant.rb +32 -4
  40. data/skills/informant/SKILL.md +12 -0
  41. metadata +12 -1
@@ -10,9 +10,11 @@ module RailsInformant
10
10
  environment_context
11
11
  error_group_id
12
12
  exception_chain
13
+ failing_request_id
13
14
  git_sha
14
15
  id
15
16
  request_context
17
+ request_id
16
18
  updated_at
17
19
  user_context
18
20
  ].freeze
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 :capture_errors,
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
- group = ErrorGroup.find_or_create_for(fingerprint, attrs)
36
- group.detect_regression!
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
- return if spike_limit_exceeded?(group)
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
- store_occurrence(group, error, env:, context:) if should_store_occurrence?(group)
41
- notify(group)
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
- Rails.logger.error "[RailsInformant] Capture failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
60
+ log_capture_failure e
61
+ ensure
62
+ BreadcrumbBuffer.current.flush
44
63
  end
45
64
 
46
65
  private
47
66
 
48
- def run_before_record_callbacks(error, attrs, env:, fingerprint:)
49
- event = Event.new(error, attrs, env:, fingerprint:)
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 flag set during notification delivery.
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 || error.backtrace&.any? { it.include?("rails_informant/notifiers") }
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, error, env:, context:)
168
+ def store_occurrence(group, occurrence_attributes)
105
169
  now = Time.current
106
170
  ErrorGroup.transaction do
107
- group.occurrences.create! ContextBuilder.occurrence_attributes(error, env:, context:)
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