errsight 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6a7079dd5ddd0a725362a26c5d27610b7b45211ba86d3536bd47bae927a63880
4
+ data.tar.gz: 4b8343248a41207ff06a94785dda20f055c9fef026904ee9fc37ff9ccc45e45b
5
+ SHA512:
6
+ metadata.gz: 35f7f650aa33896bb8438481f0982f7d9222e9027e29a481505f33f17b6c4100e4de9d654e044bd82554788f74d1362b5a615eb941637a6ab06291d5040bb597
7
+ data.tar.gz: dd8dc8cdff1cdfedce818896e6c1deb8c871969ed01f4cafe639eb6d6b4067bc90ae208b9523c80adef46ed2f655645f753209b75859298200da4b7f3835be04
data/CHANGELOG.md ADDED
@@ -0,0 +1,163 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-05-05
9
+
10
+ A foundational release that hardens the SDK for production Rails apps and
11
+ adds the integrations that distinguish ErrSight as a Rails-native error
12
+ tracker. Several long-standing bugs that silently dropped events on
13
+ Puma cluster mode and during shutdown are fixed, and PII handling is
14
+ significantly stricter.
15
+
16
+ Recommended for everyone on `0.1.x` — there are no breaking API changes
17
+ beyond a default behavior flip on `attach_to_rails_logger` (see Changed).
18
+
19
+ ### Added
20
+
21
+ - **Sidekiq integration.** Server middleware captures job exceptions with
22
+ structured context (`sidekiq.worker`, `sidekiq.queue`, `sidekiq.jid`,
23
+ `sidekiq.retry_count`) plus filtered job args and timestamps. Client
24
+ middleware propagates `user`, `tags`, and manual breadcrumbs from the
25
+ enqueueing request into the job payload so a job's errors carry the
26
+ right context. Auto-wired by the Railtie when Sidekiq is loaded; no
27
+ manual configuration required. Job args are scrubbed through
28
+ `ActiveSupport::ParameterFilter` honoring the host's
29
+ `Rails.application.config.filter_parameters`. Args > 4 KB and runs of
30
+ arbitrary objects are truncated defensively.
31
+
32
+ - **SQL breadcrumbs via `sql.active_record`.** Every non-cached,
33
+ non-schema query becomes a breadcrumb on the current scope, so a
34
+ captured `ActiveRecord::RecordNotFound` ships with the queries that
35
+ ran before it. SQL is truncated at 2 KB. Bind values are *off* by
36
+ default — they often carry PII (emails, tokens, customer IDs) — and
37
+ can be enabled with `breadcrumbs_active_record_capture_binds = true`.
38
+ Disable entirely with `breadcrumbs_active_record = false`.
39
+
40
+ - **`Rails.error.subscribe` integration on Rails 7+.** Catches errors
41
+ from Active Job (after retries are exhausted), Active Storage, Action
42
+ Mailer, Action Cable, and any explicit `Rails.error.handle / .record /
43
+ .report` call in app code. Tags each capture with
44
+ `rails.error.severity`, `rails.error.handled`, `rails.error.source`.
45
+ Caller-supplied `context:` flows into `metadata[:rails_error_context]`.
46
+ Deduplicated against the existing middleware/notifications path via the
47
+ thread-local seen-set so a single 500 doesn't fan out into multiple
48
+ issues.
49
+
50
+ - **Exception cause chain.** `capture_exception` now walks
51
+ `Exception#cause` (depth-capped at 5, cycle-protected) and ships each
52
+ cause as `metadata[:exception_causes]` with `class`, `message`, and
53
+ the first 20 backtrace frames. A `RuntimeError` rescued from a
54
+ `Net::ReadTimeout` now reports both, not just the outer wrapper.
55
+
56
+ - **`before_send` callback** for final-mile filtering. Receives the
57
+ event hash, returns a (possibly modified) hash to send, or `nil` to
58
+ drop. If the callback raises, the event is sent through unmodified
59
+ rather than silently dropped — a buggy filter shouldn't mask
60
+ production errors. Enables PII scrubbing, error suppression, and
61
+ per-tenant routing without touching the SDK.
62
+
63
+ Errsight.configure do |c|
64
+ c.before_send = ->(event) {
65
+ event[:metadata].delete(:credit_card)
66
+ event
67
+ }
68
+ end
69
+
70
+ - **Per-thread scope stack with `Errsight.with_scope { … }`.** Push/pop
71
+ primitive that lets callers isolate `set_user` / `set_tag` /
72
+ `add_breadcrumb` to a block. The Rails request middleware and Sidekiq
73
+ server middleware use this internally to scope state to a single
74
+ request or job.
75
+
76
+ - **`Errsight::Scope`** and **`Errsight::Hub`** as public classes for
77
+ scope management, with `Scope#to_h` / `Scope.from_h` for cross-process
78
+ propagation.
79
+
80
+ - **`configuration.shutdown_timeout`** (default `5` seconds) — bound on
81
+ how long `Errsight.client.shutdown!` will wait for the flush thread
82
+ to drain before killing it.
83
+
84
+ ### Changed
85
+
86
+ - **`attach_to_rails_logger` now defaults to `false`.** Previously
87
+ `true`, which forwarded every `Rails.logger.warn` call as an event.
88
+ In practice this buried genuine errors under framework deprecation
89
+ noise and burned customer event quota for what belongs in a log
90
+ aggregator, not an error tracker. Customers who want log forwarding
91
+ can opt back in:
92
+
93
+ Errsight.configure { |c| c.attach_to_rails_logger = true }
94
+
95
+ - **Breadcrumbs split into two ring buffers** (50 manual + 30 DB)
96
+ instead of a single 50-cap ring. A request that runs 500 queries can
97
+ no longer evict the user's manual breadcrumbs. `Scope#breadcrumbs`
98
+ returns a merged, timestamp-sorted view; the split is internal.
99
+
100
+ - **`set_user` / `set_tag` / `add_breadcrumb` operate on the current
101
+ scope** (top of the hub stack) rather than `Thread.current` directly.
102
+ Existing code keeps working — the public API is unchanged — but state
103
+ no longer leaks across requests on long-lived Puma threads.
104
+
105
+ - **Cross-process scope propagation only ships manual breadcrumbs.** DB
106
+ breadcrumbs stay process-local — the receiving worker collects its
107
+ own from its own queries. Affects Sidekiq job payloads.
108
+
109
+ ### Fixed
110
+
111
+ - **PII leak across requests.** `Errsight.set_user(user)` previously
112
+ stored on `Thread.current` and was never auto-cleared. On long-lived
113
+ Puma threads, request B's exceptions could be tagged with request A's
114
+ user. Now scoped to a per-request scope frame that's pushed by the
115
+ Rack middleware and popped on exit — even if the controller raises
116
+ and never calls `clear_user`. **This was a real cross-user PII bug
117
+ in `0.1.x`.**
118
+
119
+ - **Puma cluster mode silently dropped all events.** The SDK's flush
120
+ thread is started in `Client#initialize`, which runs in the Puma
121
+ primary at boot. Threads don't survive `fork()`, so worker processes
122
+ inherited a dead thread reference and never delivered any captured
123
+ event. The client now detects fork via `Process.pid` change at
124
+ `enqueue` time and rebuilds the queue, mutexes, HTTP connection, and
125
+ flush thread. Verified end-to-end with a real `fork()` test.
126
+
127
+ - **Brutal shutdown via `flush_thread.exit`.** On `SIGTERM`, the old
128
+ code killed the flush thread mid-execution — possibly mid-HTTP
129
+ request — and called `flush!` on whatever queue state was left. Now
130
+ signals shutdown via a `Mutex + ConditionVariable`, joins with a
131
+ `shutdown_timeout` cap, falls back to `kill` only if a send is hung,
132
+ and runs a final drain on the way out so events queued during the
133
+ last flush interval aren't lost.
134
+
135
+ - **429 rate-limit response parked the flush thread for up to 60 s.**
136
+ `sleep retry_after` blocked the only worker; new events filled the
137
+ queue past `max_queue_size` and were silently dropped via
138
+ `enqueue`'s overflow path. The 429 path now sets a
139
+ `@rate_limited_until` timestamp; the flush thread keeps ticking and
140
+ stays responsive to shutdown. `Retry-After` is capped at 600 s as
141
+ defense against an upstream returning an absurd value.
142
+
143
+ - **`shutdown!` raised `NoMethodError`** when no HTTP request had ever
144
+ fired (the lazily-initialized `@http_mutex` was nil). Now goes
145
+ through the `http_mutex` accessor that lazy-inits.
146
+
147
+ - **Sidekiq client middleware** was building its own scope snapshot and
148
+ would have leaked DB breadcrumbs across process boundaries. Now
149
+ delegates to `Scope#to_h`, which is the canonical serializer.
150
+
151
+ ### Security
152
+
153
+ - **PII scrubbing hook** (`before_send`) lets compliance-conscious
154
+ customers strip sensitive fields before events leave the process.
155
+ - **Sidekiq job args** are filtered through
156
+ `ActiveSupport::ParameterFilter` so passwords, tokens, and other
157
+ filtered fields configured by the host don't leak into error reports.
158
+ - **Bind values in SQL breadcrumbs** are off by default. They often
159
+ carry PII; opt in only when the customer has confirmed the use case.
160
+
161
+ ## [0.1.6] - 2026-04-XX
162
+
163
+ Earlier releases — see git history.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Errsight
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,120 @@
1
+ # errsight-ruby
2
+
3
+ Ruby/Rails client for [ErrSight](https://errsight.com) error tracking. Captures exceptions and log entries and ships them to the ErrSight API in a background thread.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby >= 3.0
8
+ - Rails 6+ (optional — also works in plain Ruby)
9
+
10
+ ## Installation
11
+
12
+ Add to your `Gemfile`:
13
+
14
+ ```ruby
15
+ gem "errsight"
16
+ ```
17
+
18
+ Then run:
19
+
20
+ ```sh
21
+ bundle install
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ ### Rails
27
+
28
+ Create an initializer at `config/initializers/errsight.rb`:
29
+
30
+ ```ruby
31
+ Errsight.configure do |config|
32
+ config.api_key = ENV["ERRSIGHT_API_KEY"] # required
33
+ config.environment = Rails.env # default: ENV["ERRSIGHT_ENV"] || "production"
34
+ end
35
+ ```
36
+
37
+ That is all you need. The Railtie handles the rest automatically:
38
+
39
+ - Attaches to `Rails.logger` so every log line is forwarded to ErrSight.
40
+ - Subscribes to `process_action.action_controller` notifications to capture unhandled exceptions with request context (controller, action, path, params, current user).
41
+ - Calls `Errsight.client.shutdown!` on process exit to drain the queue.
42
+
43
+ ### Plain Ruby
44
+
45
+ ```ruby
46
+ require "errsight"
47
+
48
+ Errsight.configure do |config|
49
+ config.api_key = ENV["ERRSIGHT_API_KEY"]
50
+ config.environment = "production"
51
+ end
52
+ ```
53
+
54
+ ### Environment variables
55
+
56
+ | Variable | Default | Description |
57
+ |-------------------|--------------------------|--------------------------------------|
58
+ | `ERRSIGHT_API_KEY` | — | Your project API key (required) |
59
+ | `ERRSIGHT_ENV` | `"production"` | Environment tag attached to events |
60
+
61
+ ### All configuration options
62
+
63
+ ```ruby
64
+ Errsight.configure do |config|
65
+ config.api_key = ENV["ERRSIGHT_API_KEY"]
66
+ config.environment = "production"
67
+ config.min_level = :warning # :debug | :info | :warning | :error | :fatal
68
+ config.host = "https://errsight.com"
69
+ config.timeout = 5 # HTTP timeout in seconds
70
+ config.batch_size = 10 # events per HTTP request
71
+ config.flush_interval = 2 # background flush cadence in seconds
72
+ config.max_queue_size = 1_000 # drop events when queue exceeds this
73
+ config.attach_to_rails_logger = true # broadcast Rails.logger to ErrSight
74
+ end
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ ### Capture an exception
80
+
81
+ ```ruby
82
+ begin
83
+ do_something_risky
84
+ rescue => e
85
+ Errsight.capture_exception(e, metadata: { user_id: current_user.id })
86
+ end
87
+ ```
88
+
89
+ ### Log a message directly
90
+
91
+ ```ruby
92
+ Errsight.log(level: :error, message: "Payment gateway timeout", metadata: { order_id: 42 })
93
+ ```
94
+
95
+ ### Use as a Logger sink
96
+
97
+ ```ruby
98
+ logger = Errsight::Logger.new # standalone — forwards to API only
99
+ logger = Errsight::Logger.new($stdout) # tee — writes to $stdout AND the API
100
+
101
+ logger.warn "Cache miss"
102
+ logger.error "Unprocessable entity"
103
+ ```
104
+
105
+ ### Rails — automatic exception capture
106
+
107
+ In a Rails app the Railtie automatically captures every unhandled exception raised during a controller action. Each event ships with:
108
+
109
+ - **User context** (top-level `user` block): `id`, `email`, `username` from the signed-in Devise/Warden user (any scope, including ActiveAdmin), plus `ip_address` from `request.remote_ip` — populated even for anonymous requests.
110
+ - **Tags** (filterable in the UI): `controller`, `action`, `request_method`, `status`, `ruby_version`, `rails_version`, `hostname`.
111
+ - **Metadata**: `path`, `full_path`, `format`, `duration`, filtered `params` (respects Rails' `filter_parameters`), `exception_class`.
112
+ - **Backtrace**: full `exception.backtrace`.
113
+
114
+ No additional code is required.
115
+
116
+ ## How it works
117
+
118
+ Events are pushed onto an in-memory queue and flushed to the API in batches by a background thread every `flush_interval` seconds (default: 2 s). A flush also triggers immediately when the queue reaches `batch_size`. On process exit the queue is drained synchronously before the process terminates.
119
+
120
+ The HTTP transport uses `Net::HTTP` with no extra dependencies beyond `concurrent-ruby` for thread-safe queue operations.
data/errsight.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ require_relative "lib/errsight/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "errsight"
5
+ spec.version = Errsight::VERSION
6
+ spec.authors = ["Errsight"]
7
+ spec.email = ["support@errsight.com"]
8
+
9
+ spec.summary = "Ruby/Rails client for Errsight error tracking"
10
+ spec.description = "A lightweight Ruby gem that hooks into Rails.logger and sends logs/errors to the Errsight API."
11
+ spec.homepage = "https://errsight.com"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 3.0"
14
+
15
+ spec.metadata = {
16
+ "homepage_uri" => "https://errsight.com",
17
+ "source_code_uri" => "https://github.com/errsight/errsight-ruby",
18
+ "bug_tracker_uri" => "https://github.com/errsight/errsight-ruby/issues",
19
+ "changelog_uri" => "https://github.com/errsight/errsight-ruby/blob/main/CHANGELOG.md"
20
+ }
21
+
22
+ spec.files = Dir["lib/**/*", "LICENSE", "README.md", "CHANGELOG.md", "errsight.gemspec"]
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_dependency "concurrent-ruby", "~> 1.0"
26
+ end
@@ -0,0 +1,117 @@
1
+ module Errsight
2
+ # Parses Ruby backtrace strings into structured frames and classifies each
3
+ # frame as in_app (customer code) vs framework/gem.
4
+ #
5
+ # Sentry-ruby was the reference for the parser regex and in_app strategy
6
+ # (study + reimplement, not copy). Their `Sentry::Backtrace` handles the
7
+ # same edge cases — JIT frames, eval'd code, native extensions — so the
8
+ # patterns are similar by necessity. The implementation is ours; the bug
9
+ # surface is ours to maintain.
10
+ module Backtrace
11
+ # Ruby 3.4+ formats methods with single quotes — "in 'method'".
12
+ # Earlier Ruby uses backticks — "in `method'". The character class
13
+ # accepts either opener; the closer is always a single quote.
14
+ RUBY_FRAME = %r{
15
+ \A
16
+ (?<file>.+?)
17
+ :(?<line>\d+)
18
+ (?::in\s+
19
+ ['`](?<method>.+)'
20
+ )?
21
+ \z
22
+ }x
23
+
24
+ # Cap on frames per event. A pathological infinite-recursion crash can
25
+ # produce 10k+ frames; without a cap the event blows past the 512 KB
26
+ # ingestion limit and gets rejected. Sentry caps at 50; we match.
27
+ MAX_FRAMES = 50
28
+
29
+ class << self
30
+ # Parses an Array<String> backtrace into Array<Hash> structured frames,
31
+ # most-recent-first (matching exception.backtrace's natural order).
32
+ def parse(lines, project_root: nil, gem_paths: nil)
33
+ return [] unless lines.is_a?(Array)
34
+ project_root ||= default_project_root
35
+ gem_paths ||= default_gem_paths
36
+
37
+ lines.first(MAX_FRAMES).filter_map do |line|
38
+ parse_line(line, project_root: project_root, gem_paths: gem_paths)
39
+ end
40
+ end
41
+
42
+ def parse_line(line, project_root:, gem_paths:)
43
+ return nil unless line.is_a?(String)
44
+ match = RUBY_FRAME.match(line)
45
+ return nil unless match
46
+
47
+ abs_path = match[:file]
48
+ {
49
+ filename: relative_filename(abs_path, project_root),
50
+ abs_path: abs_path,
51
+ lineno: match[:line].to_i,
52
+ function: match[:method],
53
+ in_app: in_app?(abs_path, project_root, gem_paths)
54
+ }
55
+ end
56
+
57
+ # An "in_app" frame is customer code — the kind of frame the issue UI
58
+ # should highlight, expand, and show source context for. Framework
59
+ # frames (Rails, gems) collapse into a "show 14 framework frames"
60
+ # group so the eye lands on what actually broke.
61
+ #
62
+ # The order of checks matters: gem_paths first because Bundler can
63
+ # vendor gems inside the project tree (vendor/bundle), so a frame at
64
+ # /app/vendor/bundle/gems/foo/foo.rb starts with project_root but is
65
+ # not in_app.
66
+ def in_app?(abs_path, project_root, gem_paths)
67
+ return false if abs_path.nil? || abs_path.empty?
68
+ # Internal frames (<internal:...>, <main>, (eval)) aren't files we
69
+ # can show source context for — treat as not-in-app.
70
+ return false if abs_path.start_with?("<", "(")
71
+ return false if gem_paths.any? { |p| p && abs_path.start_with?(p) }
72
+ return false if project_root.nil? || project_root.empty?
73
+ abs_path.start_with?(project_root)
74
+ end
75
+
76
+ def relative_filename(abs_path, project_root)
77
+ return abs_path if abs_path.nil?
78
+ return abs_path if project_root.nil? || project_root.empty?
79
+ return abs_path unless abs_path.start_with?(project_root)
80
+ # Strip "<project_root>/" prefix; leaves "app/models/user.rb" etc.
81
+ rest = abs_path[project_root.size..]
82
+ rest.sub(%r{\A/}, "")
83
+ end
84
+
85
+ def default_project_root
86
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
87
+ ::Rails.root.to_s
88
+ else
89
+ Dir.pwd
90
+ end
91
+ end
92
+
93
+ # Bundler.bundle_path can sit inside the project (vendor/bundle), so
94
+ # we include it AND Gem.path. Anything inside any of these is "not
95
+ # customer code." Memoization is per-process; these paths don't move
96
+ # at runtime.
97
+ def default_gem_paths
98
+ return @default_gem_paths if defined?(@default_gem_paths)
99
+ paths = []
100
+ paths.concat(Gem.path.map(&:to_s)) if defined?(::Gem)
101
+ begin
102
+ paths << ::Bundler.bundle_path.to_s if defined?(::Bundler) && ::Bundler.respond_to?(:bundle_path)
103
+ rescue StandardError
104
+ # Bundler.bundle_path can raise if Bundler isn't fully loaded
105
+ # (some test harnesses, gem-from-source setups). Ignore.
106
+ end
107
+ @default_gem_paths = paths.compact.uniq
108
+ end
109
+
110
+ # Test/dev only: drop the memoized paths so a test that mutates
111
+ # Rails.root or Bundler can re-derive.
112
+ def reset_defaults!
113
+ remove_instance_variable(:@default_gem_paths) if defined?(@default_gem_paths)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,95 @@
1
+ module Errsight
2
+ # Catches exceptions raised by any inner middleware/controller before
3
+ # ActionDispatch::DebugExceptions converts them into an HTTP response, so
4
+ # errors that occur outside the controller (e.g. ActiveRecord::Migration::
5
+ # CheckPending, Rack middleware, routing) are still reported with a real
6
+ # backtrace. Inserted *after* DebugExceptions in the stack so the dev/error
7
+ # page still renders normally on re-raise.
8
+ class CaptureMiddleware
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ # Push a per-request scope so set_user/set_tag/add_breadcrumb calls made
16
+ # by app code are isolated to this request. Capture happens *inside* the
17
+ # block so the exception is tagged with the request's scope before pop.
18
+ Errsight.with_scope do
19
+ begin
20
+ @app.call(env)
21
+ rescue Exception => exception
22
+ capture(exception, env, started)
23
+ raise
24
+ end
25
+ end
26
+ ensure
27
+ Thread.current[:errsight_captured_exceptions] = nil
28
+ end
29
+
30
+ private
31
+
32
+ def capture(exception, env, started_monotonic)
33
+ seen = Thread.current[:errsight_captured_exceptions] ||= []
34
+ return if seen.include?(exception.object_id)
35
+ seen << exception.object_id
36
+
37
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_monotonic) * 1000).round(2)
38
+ request = build_request(env)
39
+
40
+ metadata = build_metadata(request, env, duration_ms)
41
+ user_ctx = (Errsight::Railtie.build_user_context(request) if defined?(Errsight::Railtie) && request)
42
+ tags = build_tags(request, env)
43
+
44
+ Errsight.capture_exception(exception, metadata: metadata, user: user_ctx, tags: tags)
45
+ rescue StandardError
46
+ # Never let our own capture failure suppress the original exception.
47
+ end
48
+
49
+ def build_request(env)
50
+ return nil unless defined?(ActionDispatch::Request)
51
+ ActionDispatch::Request.new(env)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ def build_metadata(request, env, duration_ms)
57
+ meta = { duration: duration_ms }
58
+
59
+ if request
60
+ meta[:path] = request.path
61
+ meta[:full_path] = (request.url rescue nil)
62
+ meta[:format] = (request.formats.first&.to_s rescue nil)
63
+
64
+ if %w[POST PATCH PUT DELETE].include?(request.request_method)
65
+ begin
66
+ filtered = request.filtered_parameters
67
+ .except("controller", "action", "format", "authenticity_token")
68
+ meta[:params] = filtered unless filtered.empty?
69
+ rescue StandardError
70
+ # filtered_parameters can raise on malformed bodies — ignore
71
+ end
72
+ end
73
+ else
74
+ meta[:path] = env["PATH_INFO"]
75
+ end
76
+
77
+ meta.compact
78
+ end
79
+
80
+ def build_tags(request, env)
81
+ params = env["action_dispatch.request.path_parameters"] || {}
82
+ tags = {
83
+ "controller" => params[:controller],
84
+ "action" => params[:action],
85
+ "request_method" => request&.request_method || env["REQUEST_METHOD"],
86
+ "ruby_version" => RUBY_VERSION
87
+ }
88
+ tags["rails_version"] = Rails.version if defined?(Rails) && Rails.respond_to?(:version)
89
+ if defined?(Errsight::Railtie) && Errsight::Railtie::HOSTNAME
90
+ tags["hostname"] = Errsight::Railtie::HOSTNAME
91
+ end
92
+ tags.compact.reject { |_, v| v.to_s.strip.empty? }
93
+ end
94
+ end
95
+ end