rails_discord_notifier 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d11c790a2d82647c56a5c2e01a96e9be1d55dc89d5d7042c3b0f06baca195ff
4
- data.tar.gz: 56f2cadcc53da57692a4e54ad203d5a818294f790bd3934fa514213df442b667
3
+ metadata.gz: fbf5bc3adc89129d2ab69ef41705e363a214a53eb31dd1bd0dd2a4fb36253a8b
4
+ data.tar.gz: b35128c48b420cd7577a9a455f5d857ef952c08f0ef427136299a2e698e85101
5
5
  SHA512:
6
- metadata.gz: d07906322b8103001b7185d6594a55ea09e271fe1aca239cf69e1a93aa0e316a7c0440e4268b40be6476c97432c44861b8bc83bed4d51a8c2318e09bf23ff807
7
- data.tar.gz: 6a411b50c842d47a2944c12fd89d1f1e27dab0dd0aa1bf8285e1bdb0a38e3c2651454f2310012a7f2a5d3b4fc8374bb62d4802d6f7d7d02ccc6856a8acad3145
6
+ metadata.gz: 8187d0173472e8d83c8f4ce848b9d7fa9feb218db585853bbed1e784cd820f81fe93086f34c23c4b35800a4cefbe3997022d67b40a062bcd093b28afb8e03056
7
+ data.tar.gz: 975248f04642541592a440169438e6356f53e951a380f440d175a38cc42a81c7f4bd168b9786e21b20ab06bf08e576cc143eefb3efb06e7321934623a76c5b32
data/CHANGELOG.md CHANGED
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - **Backtraces show your app's frames.** The backtrace goes through
15
+ `Rails.backtrace_cleaner`, so your own lines are no longer crowded out by gem
16
+ frames and cut off at the size limit. An error that never reaches your code
17
+ keeps its library frames, with short gem paths.
18
+ - **`Host` shows the Heroku dyno name** (`web.1`, `worker.2`) instead of the
19
+ dyno's random hostname.
20
+ - **Google Chat reports from outside a request no longer repeat the error
21
+ class** as a heading above the message.
22
+
23
+ ## [0.3.0] - 2026-10-28
24
+
25
+ ### Added
26
+
27
+ - **Google Chat support.** Set `webhook_url` to a Google Chat incoming webhook
28
+ (`https://chat.googleapis.com/v1/spaces/…`) and reports are sent as a Google
29
+ Chat text message instead of a Discord embed. They are redacted and size-limited
30
+ the same way, and fit within Google Chat's 32,000 byte limit. `username` and
31
+ `avatar_url` don't apply to Google Chat.
32
+ - `rails_discord_notifier test` also reads `GOOGLE_CHAT_WEBHOOK_URL`.
33
+
34
+ ### Security
35
+
36
+ - **Chat markup in reports is defused for Google Chat.** Attacker-influenced
37
+ text (exception message, path, User-Agent) can no longer produce an
38
+ `<users/all>` mention or a `<url|text>` masked link.
39
+ - **The CLI no longer echoes a rejected webhook URL.** The URL is a credential
40
+ and the output may land in CI logs.
41
+ - **Message scrubbing can no longer stall the process.** On Ruby 3.1 the scrubber
42
+ regex took cubic time, so a request that put about 4 KB of text into an
43
+ exception message held the interpreter lock for about 13 seconds. Matching is
44
+ now linear on every supported Ruby.
45
+ - **The scrubber catches more credential shapes:** `Authorization` values with
46
+ the `Bearer`, `Basic`, `Token`, `Digest` or `Negotiate` scheme, JSON and
47
+ `inspect`-style quoted keys (`"password":"x"`, `"password" => "x"`), and
48
+ passwords in URLs (`redis://:pw@host`, `postgres://user:pw@host`).
49
+ - **`Rails.error` context and `config.context` are now filtered** with the same
50
+ parameter filter as request params, including nested values and
51
+ `ActionController::Parameters`. They were previously sent verbatim. If the
52
+ filter itself raises, the context is left out and a warning is logged.
53
+ - **Delivery threads are capped at 10 in flight.** Before, an error storm
54
+ against a slow webhook started one thread per report without limit. Reports
55
+ beyond the cap are dropped and logged.
56
+
57
+ ### Fixed
58
+
59
+ - **Text that isn't valid UTF-8 no longer drops the report.** A binary header
60
+ value (which Rack passes through) or a stray byte in an exception message made
61
+ JSON encoding fail. Invalid bytes are now replaced with `�`, and text in other
62
+ encodings (e.g. Windows-1252) is converted.
63
+
64
+ ### Changed
65
+
66
+ - **Repeats are recognised by route, not path.** The throttle fingerprint uses
67
+ controller and action instead of the raw request path, so `/users/1`,
68
+ `/users/2` and `/users/1.json` hitting the same bug count as one error.
69
+ - Delivery log lines no longer name Discord ("webhook rejected the message",
70
+ "rate limited by the webhook"). Update any log alerts that match the old text.
71
+
10
72
  ## [0.2.0] - 2026-09-17
11
73
 
12
74
  This release fixes three defects that could leak credentials or silently drop
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # RailsDiscordNotifier
2
2
 
3
- Send Rails exceptions to a Discord channel via an incoming webhook.
3
+ Send Rails exceptions to a Discord channel or a Google Chat space via an
4
+ incoming webhook.
4
5
 
5
6
  - Catches unhandled exceptions from **web requests** (Rack middleware) and from
6
7
  **background jobs, rake tasks and runners** (`Rails.error` subscriber, Rails 7.0+)
@@ -14,7 +15,7 @@ Send Rails exceptions to a Discord channel via an incoming webhook.
14
15
  ## Installation
15
16
 
16
17
  ```ruby
17
- gem "rails_discord_notifier", "~> 0.2"
18
+ gem "rails_discord_notifier", "~> 0.3"
18
19
  ```
19
20
 
20
21
  ```bash
@@ -22,25 +23,40 @@ bundle install
22
23
  rails generate rails_discord_notifier:install
23
24
  ```
24
25
 
25
- Then set your webhook (Server Settings → Integrations → Webhooks → Copy URL):
26
+ Then set your webhook. The gem works out which service it is from the URL.
27
+
28
+ **Discord:** Server Settings → Integrations → Webhooks → Copy URL.
26
29
 
27
30
  ```bash
28
31
  export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…/…"
29
32
  ```
30
33
 
34
+ **Google Chat:** open the space → Apps & integrations → Webhooks → Add webhook,
35
+ then copy the URL. Your Workspace admin must allow incoming webhooks.
36
+
37
+ ```bash
38
+ export GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/…/messages?key=…&token=…"
39
+ ```
40
+
41
+ The generated initializer reads `DISCORD_WEBHOOK_URL`, then
42
+ `GOOGLE_CHAT_WEBHOOK_URL`. Treat either URL as a password: anyone holding it
43
+ can post to your channel.
44
+
31
45
  ## Verify it works
32
46
 
33
47
  ```bash
34
48
  bundle exec rails_discord_notifier test
35
49
  ```
36
50
 
37
- This posts a real sample report to your webhook and prints what Discord said,
38
- so you find out now rather than during your next incident.
51
+ This posts a real sample report to your webhook and prints what the service
52
+ said, so you find out now rather than during your next incident. It reads
53
+ `DISCORD_WEBHOOK_URL`, then `GOOGLE_CHAT_WEBHOOK_URL`, or takes `--webhook URL`
54
+ (which ends up in your shell history, so prefer the environment variable).
39
55
 
40
56
  ## What a report looks like
41
57
 
42
- An embed titled with the failing request, followed by the details you need to
43
- find the error:
58
+ In Discord, an embed titled with the failing request, followed by the details
59
+ you need to find the error:
44
60
 
45
61
  ```
46
62
  Exception in POST /orders
@@ -55,22 +71,32 @@ Params { "order": { "email": "", "card_token": "[FILTERED]" } }
55
71
  Backtrace `app/controllers/orders_controller.rb:42:in 'create'` …
56
72
  ```
57
73
 
74
+ In Google Chat, the same details as a single text message, with the params and
75
+ backtrace in code blocks. Chat has no per-message sender name or avatar: those
76
+ come from the name and avatar you gave the webhook when you created it.
77
+
78
+ The backtrace goes through `Rails.backtrace_cleaner`, as on Rails' own error
79
+ pages, so it shows your app's frames rather than gem internals. When an error
80
+ never reaches your code, such as a job class that doesn't exist, the library
81
+ frames are shown with short gem paths instead. On Heroku, `Host` is the dyno
82
+ name (`web.1`, `worker.2`) rather than the dyno's random hostname.
83
+
58
84
  ## Configuration
59
85
 
60
86
  Everything is optional except `webhook_url`.
61
87
 
62
88
  | Option | Default | Description |
63
89
  |---|---|---|
64
- | `webhook_url` | `nil` | **Required.** Must be `https://` on `discord.com` or `discordapp.com`. Anything else leaves the notifier disabled. |
90
+ | `webhook_url` | `nil` | **Required.** Must be `https://` on `discord.com`, `discordapp.com` or `chat.googleapis.com`. Anything else leaves the notifier disabled. |
65
91
  | `enabled` | `false` in development and test, `true` elsewhere | Master switch. |
66
- | `username` | `"Error Bot"` | Bot display name in Discord. |
67
- | `avatar_url` | `nil` | Bot avatar image. |
92
+ | `username` | `"Error Bot"` | Bot display name. Discord only. |
93
+ | `avatar_url` | `nil` | Bot avatar image. Discord only. |
68
94
  | `async` | `true` | Deliver on a separate thread so a failing request is not slowed down. |
69
95
  | `throttle_period` | `60` | Seconds to collapse repeats of the same error. `0` disables. |
70
96
  | `ignored_exceptions` | routing/404-style noise (see below) | Exception class names, matched including subclasses. |
71
97
  | `include_params` | `true` | Include filtered request parameters. |
72
98
  | `filter_parameters` | `[]` | Extra redaction matchers, unioned with your app's `config.filter_parameters` and the gem's built-in list. |
73
- | `backtrace_lines` | `10` | Backtrace lines to send, trimmed to fit Discord's field limit. |
99
+ | `backtrace_lines` | `10` | Backtrace lines to send after cleaning, trimmed to fit the message size limit. |
74
100
  | `open_timeout` / `read_timeout` / `write_timeout` | `2` / `5` / `5` | Seconds. |
75
101
  | `environment` | `Rails.env`, else `RAILS_ENV`/`RACK_ENV` | Shown as a field, and decides the `enabled` default. |
76
102
  | `release` | `nil` | Shown as a field; handy for matching an error to a deploy. |
@@ -115,16 +141,37 @@ Parameters and query strings are filtered recursively with
115
141
 
116
142
  Because it is a union, enabling this gem can never redact less than your
117
143
  application already does. To send no parameters at all, set
118
- `config.include_params = false`.
144
+ `config.include_params = false`. The same filter applies to extra fields from
145
+ `config.context` and from `Rails.error` context.
119
146
 
120
147
  Exception messages are free text rather than structured parameters, so they get
121
- a best-effort scrub instead: `token=abc`, `password="x"` and `Api_Key: abc` style
148
+ a best-effort scrub instead: `token=abc`, `password="x"`, `Api_Key: abc`,
149
+ `"password":"x"`, `Authorization: Bearer abc` and `redis://:password@host` style
122
150
  fragments are replaced with `[FILTERED]`. This catches the usual culprits (a
123
151
  database connection error carrying a password, an HTTP error carrying a URL with
124
152
  a token) but cannot be exhaustive the way parameter filtering is.
125
153
 
154
+ Path segments are sent as they are. If a route puts a secret in the path (for
155
+ example `/password_resets/:token`), move it to the query string or the body,
156
+ where it is filtered.
157
+
126
158
  Request bodies, headers and cookies are never sent.
127
159
 
160
+ In Google Chat, text like `<users/all>` or `<https://…|click here>` would become
161
+ an @all mention or a disguised link. Since anyone who can make your app raise
162
+ controls part of the report (the path, the User-Agent, often the exception
163
+ message), the gem defuses these with an invisible zero-width space after each
164
+ `<`. The report looks the same but can't ping the space or hide a link. The
165
+ space is still there if you copy text out of the report (for example
166
+ ``in `<main>'`` from a backtrace), so retype the `<` if a search comes up empty.
167
+
168
+ ## Google Chat rate limits
169
+
170
+ Google Chat accepts about one message per second per space, shared with
171
+ anything else posting there. Repeats of the same error are already collapsed
172
+ (`throttle_period`), but a burst of *different* errors beyond that rate is
173
+ rejected by Google with HTTP 429. Those rejections are logged, not retried.
174
+
128
175
  ## Reporting an exception yourself
129
176
 
130
177
  ```ruby
@@ -144,6 +191,12 @@ end
144
191
  The middleware observes and re-raises, so it never changes how your application
145
192
  handles errors. When both entry points see the same exception, it is reported once.
146
193
 
194
+ "The same error" means the same exception class, raised from the same line, on
195
+ the same controller action. So `/orders/1` and `/orders/2` failing the same way
196
+ count as one error for `throttle_period`. At most 10 reports are delivered at
197
+ once; during a storm, anything beyond that is dropped with a log line rather
198
+ than starting more threads.
199
+
147
200
  Asynchronous deliveries still in flight are waited for at process exit, so a
148
201
  report from a rake task or `rails runner` is not lost when the process ends. Call
149
202
  `RailsDiscordNotifier.notifier.flush` to wait for them explicitly.
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RailsDiscordNotifier.configure do |config|
4
- # Required. Must be an https://discord.com/api/webhooks/... URL.
5
- # If it is missing or malformed the notifier stays disabled and logs a warning
6
- # rather than breaking boot.
7
- config.webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL", nil)
8
-
9
- # Appearance of the bot in Discord.
4
+ # Required. A Discord (https://discord.com/api/webhooks/...) or Google Chat
5
+ # (https://chat.googleapis.com/v1/spaces/...) webhook URL. The message format
6
+ # follows the URL. If it is missing or malformed the notifier stays disabled and
7
+ # logs a warning rather than breaking boot. Keep it out of source control.
8
+ config.webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL", nil).presence || ENV.fetch("GOOGLE_CHAT_WEBHOOK_URL", nil)
9
+
10
+ # Appearance of the bot in Discord. Google Chat uses the name and avatar set on
11
+ # the webhook itself.
10
12
  config.username = ENV.fetch("ERROR_BOT_NAME", "Error Bot")
11
13
  # config.avatar_url = ENV.fetch("ERROR_BOT_AVATAR_URL", nil)
12
14
 
@@ -20,7 +22,7 @@ RailsDiscordNotifier.configure do |config|
20
22
  # Collapse repeats of the same error for this many seconds. 0 disables it.
21
23
  # config.throttle_period = 60
22
24
 
23
- # Network timeouts, in seconds. Kept short so a slow Discord never slows you down.
25
+ # Network timeouts, in seconds. Kept short so a slow webhook never slows you down.
24
26
  # config.open_timeout = 2
25
27
  # config.read_timeout = 5
26
28
  # config.write_timeout = 5
@@ -2,19 +2,20 @@
2
2
 
3
3
  module RailsDiscordNotifier
4
4
  # `rails_discord_notifier test` posts a real message to your webhook and prints
5
- # what Discord said. It answers the first question every user of this gem has:
5
+ # what the service said. It answers the first question every user of this gem has:
6
6
  # "is my webhook actually wired up?"
7
7
  class CLI
8
8
  USAGE = <<~TEXT
9
9
  Usage: rails_discord_notifier <command> [options]
10
10
 
11
11
  Commands:
12
- test Send a sample exception report to your Discord webhook
12
+ test Send a sample exception report to your Discord or Google Chat webhook
13
13
  version Print the gem version
14
14
  help Show this message
15
15
 
16
16
  Options:
17
- --webhook URL Webhook to use (defaults to $DISCORD_WEBHOOK_URL)
17
+ --webhook URL Webhook to use (defaults to $DISCORD_WEBHOOK_URL,
18
+ then $GOOGLE_CHAT_WEBHOOK_URL)
18
19
  TEXT
19
20
 
20
21
  def initialize(argv, out: $stdout, env: ENV)
@@ -42,16 +43,16 @@ module RailsDiscordNotifier
42
43
  return 1
43
44
  end
44
45
 
45
- @out.puts("Posting a sample exception report to Discord…")
46
+ @out.puts("Posting a sample exception report to your webhook…")
46
47
  deliver(config)
47
48
  end
48
49
 
49
50
  def deliver(config)
50
51
  if Client.new(config).post(sample_payload(config))
51
- @out.puts("Sent. Check your Discord channel.")
52
+ @out.puts("Sent. Check your channel.")
52
53
  0
53
54
  else
54
- @out.puts("Discord did not accept the message. See the logged response above.")
55
+ @out.puts("The webhook did not accept the message. See the logged response above.")
55
56
  1
56
57
  end
57
58
  end
@@ -62,7 +63,7 @@ module RailsDiscordNotifier
62
63
  rescue StandardError => e
63
64
  e
64
65
  end
65
- Payload.new(exception, config: config).to_h
66
+ Payload.build(exception, config: config).to_h
66
67
  end
67
68
 
68
69
  def test_config
@@ -78,14 +79,17 @@ module RailsDiscordNotifier
78
79
  index = @argv.index("--webhook")
79
80
  return @argv[index + 1] if index && @argv[index + 1]
80
81
 
81
- @env["DISCORD_WEBHOOK_URL"]
82
+ # Blank counts as unset: deploy templates often export unused variables as "".
83
+ @env.values_at("DISCORD_WEBHOOK_URL", "GOOGLE_CHAT_WEBHOOK_URL").find { |url| !url.to_s.strip.empty? }
82
84
  end
83
85
 
86
+ # Never echoes the URL: it is a credential and this may be a CI log.
84
87
  def webhook_help
85
88
  if webhook_url.to_s.strip.empty?
86
- "No webhook configured. Set DISCORD_WEBHOOK_URL or pass --webhook URL."
89
+ "No webhook configured. Set DISCORD_WEBHOOK_URL or GOOGLE_CHAT_WEBHOOK_URL, or pass --webhook URL."
87
90
  else
88
- "Invalid webhook: expected an https://discord.com/api/webhooks/... URL, got #{webhook_url}"
91
+ "Invalid webhook: expected an https://discord.com/api/webhooks/... " \
92
+ "or https://chat.googleapis.com/v1/spaces/... URL"
89
93
  end
90
94
  end
91
95
 
@@ -5,7 +5,7 @@ require "net/http"
5
5
  require "uri"
6
6
 
7
7
  module RailsDiscordNotifier
8
- # Posts a prepared payload to the Discord webhook.
8
+ # Posts a prepared payload to the configured webhook.
9
9
  #
10
10
  # Never raises: a notifier that breaks the request it is reporting on is worse
11
11
  # than no notifier. Every failure is logged and swallowed.
@@ -16,7 +16,7 @@ module RailsDiscordNotifier
16
16
  @config = config
17
17
  end
18
18
 
19
- # Returns true when Discord accepted the message.
19
+ # Returns true when the service accepted the message.
20
20
  def post(payload)
21
21
  uri = target_uri
22
22
  return false if uri.nil?
@@ -54,17 +54,17 @@ module RailsDiscordNotifier
54
54
  end
55
55
  end
56
56
 
57
- # Logs whatever Discord said, and answers whether the message got through.
57
+ # Logs whatever the service said, and answers whether the message got through.
58
58
  def accepted?(response)
59
59
  return true if SUCCESS.cover?(response.code.to_i)
60
60
 
61
61
  if response.code.to_i == 429
62
62
  config.logger.warn(
63
- "[RailsDiscordNotifier] rate limited by Discord, retry after #{response["Retry-After"] || "unknown"}s"
63
+ "[RailsDiscordNotifier] rate limited by the webhook, retry after #{response["Retry-After"] || "unknown"}s"
64
64
  )
65
65
  else
66
66
  config.logger.error(
67
- "[RailsDiscordNotifier] Discord rejected the message: HTTP #{response.code} #{response.body}"
67
+ "[RailsDiscordNotifier] webhook rejected the message: HTTP #{response.code} #{response.body}"
68
68
  )
69
69
  end
70
70
  false
@@ -12,6 +12,9 @@ module RailsDiscordNotifier
12
12
  # misconfiguration or an attempt to point the notifier at a third party.
13
13
  WEBHOOK_HOSTS = %w[discord.com discordapp.com].freeze
14
14
 
15
+ # Google Chat incoming webhooks live on this one host, never a subdomain.
16
+ GOOGLE_CHAT_HOST = "chat.googleapis.com"
17
+
15
18
  # Noisy, client-caused exceptions that almost always mean "bad request",
16
19
  # not "bug worth waking someone for".
17
20
  DEFAULT_IGNORED_EXCEPTIONS = %w[
@@ -74,7 +77,7 @@ module RailsDiscordNotifier
74
77
  @enabled.nil? ? default_enabled? : @enabled
75
78
  end
76
79
 
77
- # True only when the notifier should actually talk to Discord.
80
+ # True only when the notifier should actually talk to the webhook.
78
81
  def enabled?
79
82
  !!enabled && valid_webhook_url?
80
83
  end
@@ -83,7 +86,12 @@ module RailsDiscordNotifier
83
86
  uri = parsed_webhook_url
84
87
  return false if uri.nil?
85
88
 
86
- uri.scheme == "https" && discord_host?(uri.host)
89
+ uri.scheme == "https" && (discord_host?(uri.host) || google_chat_host?(uri.host))
90
+ end
91
+
92
+ # Decides the message format: Google Chat, or Discord for everything else.
93
+ def google_chat?
94
+ google_chat_host?(parsed_webhook_url&.host)
87
95
  end
88
96
 
89
97
  def ignore?(exception)
@@ -123,6 +131,10 @@ module RailsDiscordNotifier
123
131
  WEBHOOK_HOSTS.any? { |allowed| host == allowed || host.end_with?(".#{allowed}") }
124
132
  end
125
133
 
134
+ def google_chat_host?(host)
135
+ host.to_s.downcase == GOOGLE_CHAT_HOST
136
+ end
137
+
126
138
  def default_enabled?
127
139
  !NON_REPORTING_ENVIRONMENTS.include?(environment.to_s)
128
140
  end
@@ -28,7 +28,8 @@ module RailsDiscordNotifier
28
28
  def extra_fields(handled, severity, source, context)
29
29
  fields = { "Severity" => severity.to_s, "Handled" => handled.to_s }
30
30
  fields["Source"] = source.to_s if source
31
- context.except(:env, "env").each { |key, value| fields[key.to_s] = value.to_s }
31
+ # Left as-is: the payload filters nested values, then stringifies them.
32
+ context.except(:env, "env").each { |key, value| fields[key.to_s] = value }
32
33
  fields
33
34
  end
34
35
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDiscordNotifier
4
+ # Turns an exception (plus optional Rack env) into a Google Chat webhook body.
5
+ #
6
+ # Gathers, redacts and budgets exactly what the Discord payload does, then
7
+ # renders it as one plain-text message. Discord's 6000 character embed budget
8
+ # keeps the result far below Google Chat's 32,000 byte message limit.
9
+ class GoogleChatPayload < Payload
10
+ CODE_FENCE = "```"
11
+ CODE_BLOCK_OVERHEAD = "#{CODE_FENCE}\n\n#{CODE_FENCE}".length
12
+
13
+ # Chat renders "<users/all>" as an @all mention and "<url|text>" as a masked
14
+ # link. A zero-width space after every "<" defuses both without changing
15
+ # what the message looks like. The message itself never needs a "<".
16
+ MARKUP_OPENER = "<"
17
+ DEFUSED_OPENER = "<\u200B"
18
+
19
+ def to_h
20
+ @to_h ||= { text: render(embed).gsub(MARKUP_OPENER, DEFUSED_OPENER) }
21
+ end
22
+
23
+ private
24
+
25
+ # Outside a request the title is only the class name, which the description
26
+ # already opens with, so it is left out rather than repeated.
27
+ def render(embed)
28
+ heading = [embed[:description], ""]
29
+ heading.unshift(bold(embed[:title])) if request
30
+ (heading + embed[:fields].map { |field| render_field(field) }).join("\n")
31
+ end
32
+
33
+ def render_field(field)
34
+ separator = field[:value].include?("\n") ? "\n" : " "
35
+ "#{bold("#{field[:name]}:")}#{separator}#{field[:value]}"
36
+ end
37
+
38
+ def bold(text)
39
+ "*#{text}*"
40
+ end
41
+
42
+ # Chat code blocks take no language tag; a "json" one would be shown as text.
43
+ def fence(text)
44
+ "#{CODE_FENCE}\n#{truncate(text, FIELD_VALUE_LIMIT - CODE_BLOCK_OVERHEAD)}\n#{CODE_FENCE}"
45
+ end
46
+
47
+ def formatted_backtrace
48
+ lines = clean_backtrace
49
+ return "No backtrace available" if lines.empty?
50
+
51
+ fence(lines.join("\n"))
52
+ end
53
+ end
54
+ end
@@ -7,10 +7,14 @@ module RailsDiscordNotifier
7
7
  #
8
8
  # Delivery happens off the request thread by default, and repeats of the same
9
9
  # error are collapsed, so an error storm cannot slow the application down or
10
- # bury the Discord channel.
10
+ # bury the channel.
11
11
  class Notifier
12
12
  MAX_TRACKED_FINGERPRINTS = 1_000
13
13
 
14
+ # Asynchronous deliveries allowed at once. Beyond this a report is dropped,
15
+ # so an error storm against a slow webhook cannot exhaust the host's threads.
16
+ MAX_IN_FLIGHT = 10
17
+
14
18
  # Stamped on an exception once it has been reported, so that the middleware
15
19
  # and the Rails.error subscriber cannot both announce the same failure.
16
20
  REPORTED_FLAG = :@__rails_discord_notifier_reported
@@ -37,16 +41,17 @@ module RailsDiscordNotifier
37
41
  end
38
42
 
39
43
  # Returns the delivery Thread when asynchronous, the client result when
40
- # synchronous, and false when the exception was not reportable.
44
+ # synchronous, and false when the exception was not reportable or the report
45
+ # was dropped because MAX_IN_FLIGHT deliveries were already running.
41
46
  def notify(exception, env: nil, context: {})
42
47
  return false unless report?(exception)
43
48
  return false if already_reported?(exception)
44
49
 
45
50
  mark_reported(exception)
46
- payload = Payload.new(exception, config: config, env: env, context: context)
51
+ payload = Payload.build(exception, config: config, env: env, context: context)
47
52
  return false if throttled?(payload.fingerprint)
48
53
 
49
- deliver(payload.to_h)
54
+ deliver(payload.to_h, payload.fingerprint)
50
55
  rescue StandardError => e
51
56
  log_internal_failure(e)
52
57
  false
@@ -73,23 +78,31 @@ module RailsDiscordNotifier
73
78
  nil
74
79
  end
75
80
 
76
- def deliver(body)
81
+ def deliver(body, fingerprint)
77
82
  return client.post(body) unless config.async
78
83
 
79
84
  register_flush_at_exit
80
- track(Thread.new do
85
+ thread = @lock.synchronize do
86
+ @threads.select!(&:alive?)
87
+ if @threads.size >= MAX_IN_FLIGHT
88
+ @seen.delete(fingerprint) # never sent, so the next occurrence is not a repeat
89
+ next
90
+ end
91
+
92
+ (@threads << spawn_delivery(body)).last
93
+ end
94
+ return thread if thread
95
+
96
+ config.logger.warn("[RailsDiscordNotifier] report dropped: #{MAX_IN_FLIGHT} deliveries already in flight")
97
+ false
98
+ end
99
+
100
+ def spawn_delivery(body)
101
+ Thread.new do
81
102
  client.post(body)
82
103
  rescue StandardError => e
83
104
  log_internal_failure(e)
84
- end)
85
- end
86
-
87
- def track(thread)
88
- @lock.synchronize do
89
- @threads.select!(&:alive?)
90
- @threads << thread
91
105
  end
92
- thread
93
106
  end
94
107
 
95
108
  def register_flush_at_exit
@@ -34,6 +34,11 @@ module RailsDiscordNotifier
34
34
  DROP_ORDER = ["Params", "User Agent", "Host", "Request ID", "Release",
35
35
  "Environment", "Action", "Controller", "URL"].freeze
36
36
 
37
+ # The payload for whichever service the configured webhook belongs to.
38
+ def self.build(exception, config:, **options)
39
+ (config.google_chat? ? GoogleChatPayload : Payload).new(exception, config: config, **options)
40
+ end
41
+
37
42
  def initialize(exception, config:, env: nil, context: {})
38
43
  @exception = exception
39
44
  @config = config
@@ -46,15 +51,15 @@ module RailsDiscordNotifier
46
51
  end
47
52
 
48
53
  def size
49
- embed = to_h[:embeds].first
50
- embed[:title].to_s.length + embed[:description].to_s.length +
51
- embed[:fields].sum { |f| f[:name].to_s.length + f[:value].to_s.length }
54
+ embed_size(embed)
52
55
  end
53
56
 
54
- # Stable identity for an error occurrence, used to throttle repeats.
57
+ # Stable identity for an error occurrence, used to throttle repeats. Keyed on
58
+ # the route, not the path, so /users/1, /users/2 and /users/1.json are one
59
+ # error rather than a way around the throttle.
55
60
  def fingerprint
56
61
  @fingerprint ||= Digest::SHA256.hexdigest(
57
- [exception.class.name, Array(exception.backtrace).first, location].join("|")
62
+ [exception.class.name, Array(exception.backtrace).first, *rails_params.values_at("controller", "action")].join("|")
58
63
  )
59
64
  end
60
65
 
@@ -63,7 +68,7 @@ module RailsDiscordNotifier
63
68
  attr_reader :exception, :config, :env, :context
64
69
 
65
70
  def embed
66
- enforce_total_budget(
71
+ @embed ||= enforce_total_budget(
67
72
  {
68
73
  title: truncate(title, TITLE_LIMIT),
69
74
  description: truncate(description, DESCRIPTION_LIMIT),
@@ -74,22 +79,18 @@ module RailsDiscordNotifier
74
79
  )
75
80
  end
76
81
 
77
- # Coarse "where did this happen" key: request path, or nothing outside a request.
78
- def location
79
- request ? "#{env["REQUEST_METHOD"]} #{request.path}" : nil
80
- end
81
-
82
82
  def title
83
- return exception.class.name unless request
84
-
85
- "Exception in #{env["REQUEST_METHOD"]} #{request.path}"
83
+ request ? "Exception in #{env["REQUEST_METHOD"]} #{request.path}" : exception.class.name
86
84
  end
87
85
 
88
86
  def description
89
- text = "**#{exception.class}**: #{scrubber.scrub(exception.message)}"
90
- cause = exception.cause
91
- text << "\nCaused by **#{cause.class}**: #{scrubber.scrub(cause.message)}" if cause
92
- text
87
+ [exception, exception.cause].compact
88
+ .map { |error| "#{bold(error.class)}: #{scrubber.scrub(error.message)}" }
89
+ .join("\nCaused by ")
90
+ end
91
+
92
+ def bold(text)
93
+ "**#{text}**"
93
94
  end
94
95
 
95
96
  def scrubber
@@ -122,9 +123,16 @@ module RailsDiscordNotifier
122
123
  ]
123
124
  end
124
125
 
126
+ # ParameterFilter only walks into real Hashes, so ActionController::Parameters
127
+ # is unwrapped first. Context the filter cannot process is left out: sending
128
+ # it unfiltered could leak, and failing here would drop the whole report.
125
129
  def context_fields
126
130
  extra = context.merge(configured_context)
127
- extra.map { |name, value| { name: name.to_s, value: value.to_s, inline: true } }
131
+ .transform_values { |value| value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value }
132
+ parameter_filter.filter(extra).map { |name, value| { name: name.to_s, value: value.to_s, inline: true } }
133
+ rescue StandardError => e
134
+ config.logger.warn("[RailsDiscordNotifier] context left out, the filter raised #{e.class}: #{e.message}")
135
+ []
128
136
  end
129
137
 
130
138
  def configured_context
@@ -161,7 +169,7 @@ module RailsDiscordNotifier
161
169
  end
162
170
 
163
171
  def formatted_backtrace
164
- lines = Array(exception.backtrace).first(config.backtrace_lines)
172
+ lines = clean_backtrace
165
173
  return "No backtrace available" if lines.empty?
166
174
 
167
175
  lines.each_with_object(+"") do |line, out|
@@ -173,6 +181,21 @@ module RailsDiscordNotifier
173
181
  end
174
182
  end
175
183
 
184
+ # The app's own frames, as on Rails' error pages, so the lines that matter
185
+ # fit the message. An error raised entirely inside library code keeps its
186
+ # library frames, with gem paths shortened. Raw frames when the cleaner is
187
+ # unavailable (no Rails) or raises, and without an app root: Rails 6.1 and
188
+ # 7.0 would then strip the leading "/" from every path.
189
+ def clean_backtrace
190
+ lines = Array(exception.backtrace)
191
+ return lines.first(config.backtrace_lines) unless Rails.root
192
+
193
+ app_frames = Rails.backtrace_cleaner.clean(lines)
194
+ (app_frames.empty? ? Rails.backtrace_cleaner.clean(lines, :all) : app_frames).first(config.backtrace_lines)
195
+ rescue StandardError
196
+ Array(exception.backtrace).first(config.backtrace_lines)
197
+ end
198
+
176
199
  def filtered_url
177
200
  return nil unless request
178
201
 
@@ -224,8 +247,10 @@ module RailsDiscordNotifier
224
247
  @parameter_filter ||= ActiveSupport::ParameterFilter.new(config.filter_parameters)
225
248
  end
226
249
 
250
+ # Heroku hostnames are random IDs; DYNO ("web.1", "worker.2") names the process.
227
251
  def hostname
228
- @hostname ||= Socket.gethostname
252
+ dyno = ENV.fetch("DYNO", "")
253
+ @hostname ||= dyno.empty? ? Socket.gethostname : dyno
229
254
  rescue StandardError
230
255
  nil
231
256
  end
@@ -263,7 +288,7 @@ module RailsDiscordNotifier
263
288
  end
264
289
 
265
290
  def truncate(string, limit)
266
- string = string.to_s
291
+ string = SecretScrubber.utf8(string)
267
292
  return string if string.length <= limit
268
293
  return string[0, limit].to_s if limit < ELLIPSIS.length
269
294
 
@@ -6,27 +6,53 @@ module RailsDiscordNotifier
6
6
  # Exception messages cannot be filtered the way structured parameters can, so
7
7
  # this is deliberately a best effort: it catches the shapes credentials
8
8
  # actually take when they end up in a message - `token=abc`, `password="x"`,
9
- # `Api_Key: abc` - and leaves ordinary prose ("password reset email") alone.
9
+ # `Api_Key: abc`, `"password":"x"`, `Authorization: Bearer abc`,
10
+ # `redis://:pw@host` - and leaves ordinary prose ("password reset email") alone.
10
11
  class SecretScrubber
12
+ KEY_CHAR = '[\w.\[\]-]'
13
+ # An optional closing quote on the key, then `=`, `:` or `=>`.
14
+ SEPARATOR = %q{(["']?\s*(?:=>|[=:])\s*)}
15
+ # A quoted string, an HTTP auth scheme with its credential, or one word.
16
+ VALUE = %q{(?:"[^"]*"|'[^']*'|(?:bearer|basic|token|digest|negotiate)\s+\S+|\S+)}
17
+ # Up to the last "@" before the host, so a raw "@" inside the password is covered too.
18
+ URL_PASSWORD = %r{(://[^:/@\s]*:)[^/\s]+(@)}
19
+
20
+ # Valid UTF-8, or JSON encoding fails and the report is lost. Binary (how Rack
21
+ # hands over headers) is read as UTF-8, other encodings are converted, and
22
+ # invalid bytes become U+FFFD either way.
23
+ def self.utf8(value)
24
+ string = value.to_s
25
+ string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
26
+ string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
27
+ end
28
+
11
29
  def initialize(matchers)
12
30
  @matchers = matchers
13
31
  end
14
32
 
33
+ # Key patterns run first: URL_PASSWORD would otherwise read "host:8443?token=s3cr@"
34
+ # as a username and password, and swallow the key that marks the real secret.
15
35
  def scrub(text)
16
- patterns.reduce(text.to_s) do |scrubbed, pattern|
17
- scrubbed.gsub(pattern) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)}[FILTERED]" }
36
+ scrubbed = patterns.reduce(self.class.utf8(text)) do |result, pattern|
37
+ result.gsub(pattern) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)}[FILTERED]" }
18
38
  end
39
+ scrubbed.gsub(URL_PASSWORD) { "#{Regexp.last_match(1)}[FILTERED]#{Regexp.last_match(2)}" }
19
40
  end
20
41
 
21
42
  private
22
43
 
23
44
  # Regexp and Proc filters are skipped: they are written to match parameter
24
45
  # keys, not the inside of a sentence.
46
+ #
47
+ # A key may only start where a run of key characters starts, and is matched
48
+ # atomically. That keeps matching linear: before Ruby 3.2 the unanchored form
49
+ # took cubic time on input like "tokentoken…", stalling the whole process.
25
50
  def patterns
26
51
  @patterns ||= @matchers.filter_map do |matcher|
27
52
  next unless matcher.is_a?(String) || matcher.is_a?(Symbol)
28
53
 
29
- /([\w.\[\]-]*#{Regexp.escape(matcher.to_s)}[\w.\[\]-]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/i
54
+ key = "(?<!#{KEY_CHAR})((?>#{KEY_CHAR}*?#{Regexp.escape(matcher.to_s)}#{KEY_CHAR}*+))"
55
+ /#{key}#{SEPARATOR}#{VALUE}/i
30
56
  end
31
57
  end
32
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsDiscordNotifier
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -4,6 +4,7 @@ require "rails_discord_notifier/version"
4
4
  require "rails_discord_notifier/configuration"
5
5
  require "rails_discord_notifier/secret_scrubber"
6
6
  require "rails_discord_notifier/payload"
7
+ require "rails_discord_notifier/google_chat_payload"
7
8
  require "rails_discord_notifier/client"
8
9
  require "rails_discord_notifier/notifier"
9
10
  require "rails_discord_notifier/middleware"
@@ -66,7 +67,8 @@ module RailsDiscordNotifier
66
67
  return if !config.enabled || config.valid_webhook_url?
67
68
 
68
69
  config.logger.warn(
69
- "[RailsDiscordNotifier] disabled: webhook_url must be an https://discord.com/api/webhooks/... URL"
70
+ "[RailsDiscordNotifier] disabled: webhook_url must be an https://discord.com/api/webhooks/... " \
71
+ "or https://chat.googleapis.com/v1/spaces/... URL"
70
72
  )
71
73
  end
72
74
  end
@@ -32,6 +32,7 @@ module RailsDiscordNotifier
32
32
  def enabled: () -> bool
33
33
  def enabled?: () -> bool
34
34
  def valid_webhook_url?: () -> bool
35
+ def google_chat?: () -> bool
35
36
  def ignore?: (Exception) -> bool
36
37
  def logger: () -> untyped
37
38
  def environment: () -> String
@@ -39,12 +40,16 @@ module RailsDiscordNotifier
39
40
  end
40
41
 
41
42
  class Payload
43
+ def self.build: (Exception, config: Configuration, ?env: Hash[String, untyped]?, ?context: Hash[untyped, untyped]) -> Payload
42
44
  def initialize: (Exception, config: Configuration, ?env: Hash[String, untyped]?, ?context: Hash[untyped, untyped]) -> void
43
45
  def to_h: () -> Hash[Symbol, untyped]
44
46
  def size: () -> Integer
45
47
  def fingerprint: () -> String
46
48
  end
47
49
 
50
+ class GoogleChatPayload < Payload
51
+ end
52
+
48
53
  class Client
49
54
  def initialize: (Configuration) -> void
50
55
  def post: (Hash[Symbol, untyped]) -> bool
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_discord_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GettinDrikkieWithIt
@@ -81,8 +81,8 @@ dependencies:
81
81
  version: '6.1'
82
82
  description: |
83
83
  RailsDiscordNotifier captures unhandled exceptions in your Rails application
84
- and sends detailed error reports to a Discord channel using an incoming webhook.
85
- Customize the bot username, avatar, and message format via initializer.
84
+ and sends detailed, redacted error reports to a Discord channel or a Google
85
+ Chat space using an incoming webhook.
86
86
  email:
87
87
  - 33983365+gettinDrikkieWithIt@users.noreply.github.com
88
88
  executables:
@@ -102,6 +102,7 @@ files:
102
102
  - lib/rails_discord_notifier/client.rb
103
103
  - lib/rails_discord_notifier/configuration.rb
104
104
  - lib/rails_discord_notifier/error_subscriber.rb
105
+ - lib/rails_discord_notifier/google_chat_payload.rb
105
106
  - lib/rails_discord_notifier/middleware.rb
106
107
  - lib/rails_discord_notifier/notifier.rb
107
108
  - lib/rails_discord_notifier/payload.rb
@@ -134,5 +135,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
135
  requirements: []
135
136
  rubygems_version: 3.6.9
136
137
  specification_version: 4
137
- summary: Send Rails exceptions to Discord via configurable webhook
138
+ summary: Send Rails exceptions to Discord or Google Chat via configurable webhook
138
139
  test_files: []