rails_discord_notifier 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d11c790a2d82647c56a5c2e01a96e9be1d55dc89d5d7042c3b0f06baca195ff
4
- data.tar.gz: 56f2cadcc53da57692a4e54ad203d5a818294f790bd3934fa514213df442b667
3
+ metadata.gz: 6527e49521148326dc463e191ed0fe51097c0625e7a09b89074615eb6b21f431
4
+ data.tar.gz: 3502ab8e704d8e0d995cdd17fb8d06fc36d9c6bae13122455cb33b013c9eff3c
5
5
  SHA512:
6
- metadata.gz: d07906322b8103001b7185d6594a55ea09e271fe1aca239cf69e1a93aa0e316a7c0440e4268b40be6476c97432c44861b8bc83bed4d51a8c2318e09bf23ff807
7
- data.tar.gz: 6a411b50c842d47a2944c12fd89d1f1e27dab0dd0aa1bf8285e1bdb0a38e3c2651454f2310012a7f2a5d3b4fc8374bb62d4802d6f7d7d02ccc6856a8acad3145
6
+ metadata.gz: 2933ce5afd5ee5ae228d589c4c556fdde5dee1def8146a07204fc2230b80ccdbe89dbcbd055786af2389653518d8518e3de564d1dc3147851a8236a50f48ac5f
7
+ data.tar.gz: 01a951280d579cac677a76e6e2120b9fb30b4c8c94fd584459423a0b4e1b7d65e8ab178584ef4e9b41178a216a3b010d2a0d34950d481e0ef341d28050609bc5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - **Google Chat support.** Set `webhook_url` to a Google Chat incoming webhook
15
+ (`https://chat.googleapis.com/v1/spaces/…`) and reports are sent as a Google
16
+ Chat text message instead of a Discord embed. They are redacted and size-limited
17
+ the same way, and fit within Google Chat's 32,000 byte limit. `username` and
18
+ `avatar_url` don't apply to Google Chat.
19
+ - `rails_discord_notifier test` also reads `GOOGLE_CHAT_WEBHOOK_URL`.
20
+
21
+ ### Security
22
+
23
+ - **Chat markup in reports is defused for Google Chat.** Attacker-influenced
24
+ text (exception message, path, User-Agent) can no longer produce an
25
+ `<users/all>` mention or a `<url|text>` masked link.
26
+ - **The CLI no longer echoes a rejected webhook URL.** The URL is a credential
27
+ and the output may land in CI logs.
28
+ - **Message scrubbing can no longer stall the process.** On Ruby 3.1 the scrubber
29
+ regex took cubic time, so a request that put about 4 KB of text into an
30
+ exception message held the interpreter lock for about 13 seconds. Matching is
31
+ now linear on every supported Ruby.
32
+ - **The scrubber catches more credential shapes:** `Authorization` values with
33
+ the `Bearer`, `Basic`, `Token`, `Digest` or `Negotiate` scheme, JSON and
34
+ `inspect`-style quoted keys (`"password":"x"`, `"password" => "x"`), and
35
+ passwords in URLs (`redis://:pw@host`, `postgres://user:pw@host`).
36
+ - **`Rails.error` context and `config.context` are now filtered** with the same
37
+ parameter filter as request params, including nested values and
38
+ `ActionController::Parameters`. They were previously sent verbatim. If the
39
+ filter itself raises, the context is left out and a warning is logged.
40
+ - **Delivery threads are capped at 10 in flight.** Before, an error storm
41
+ against a slow webhook started one thread per report without limit. Reports
42
+ beyond the cap are dropped and logged.
43
+
44
+ ### Fixed
45
+
46
+ - **Text that isn't valid UTF-8 no longer drops the report.** A binary header
47
+ value (which Rack passes through) or a stray byte in an exception message made
48
+ JSON encoding fail. Invalid bytes are now replaced with `�`, and text in other
49
+ encodings (e.g. Windows-1252) is converted.
50
+
51
+ ### Changed
52
+
53
+ - **Repeats are recognised by route, not path.** The throttle fingerprint uses
54
+ controller and action instead of the raw request path, so `/users/1`,
55
+ `/users/2` and `/users/1.json` hitting the same bug count as one error.
56
+ - Delivery log lines no longer name Discord ("webhook rejected the message",
57
+ "rate limited by the webhook"). Update any log alerts that match the old text.
58
+
10
59
  ## [0.2.0] - 2026-09-17
11
60
 
12
61
  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,26 @@ 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
+
58
78
  ## Configuration
59
79
 
60
80
  Everything is optional except `webhook_url`.
61
81
 
62
82
  | Option | Default | Description |
63
83
  |---|---|---|
64
- | `webhook_url` | `nil` | **Required.** Must be `https://` on `discord.com` or `discordapp.com`. Anything else leaves the notifier disabled. |
84
+ | `webhook_url` | `nil` | **Required.** Must be `https://` on `discord.com`, `discordapp.com` or `chat.googleapis.com`. Anything else leaves the notifier disabled. |
65
85
  | `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. |
86
+ | `username` | `"Error Bot"` | Bot display name. Discord only. |
87
+ | `avatar_url` | `nil` | Bot avatar image. Discord only. |
68
88
  | `async` | `true` | Deliver on a separate thread so a failing request is not slowed down. |
69
89
  | `throttle_period` | `60` | Seconds to collapse repeats of the same error. `0` disables. |
70
90
  | `ignored_exceptions` | routing/404-style noise (see below) | Exception class names, matched including subclasses. |
71
91
  | `include_params` | `true` | Include filtered request parameters. |
72
92
  | `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. |
93
+ | `backtrace_lines` | `10` | Backtrace lines to send, trimmed to fit the message size limit. |
74
94
  | `open_timeout` / `read_timeout` / `write_timeout` | `2` / `5` / `5` | Seconds. |
75
95
  | `environment` | `Rails.env`, else `RAILS_ENV`/`RACK_ENV` | Shown as a field, and decides the `enabled` default. |
76
96
  | `release` | `nil` | Shown as a field; handy for matching an error to a deploy. |
@@ -115,16 +135,37 @@ Parameters and query strings are filtered recursively with
115
135
 
116
136
  Because it is a union, enabling this gem can never redact less than your
117
137
  application already does. To send no parameters at all, set
118
- `config.include_params = false`.
138
+ `config.include_params = false`. The same filter applies to extra fields from
139
+ `config.context` and from `Rails.error` context.
119
140
 
120
141
  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
142
+ a best-effort scrub instead: `token=abc`, `password="x"`, `Api_Key: abc`,
143
+ `"password":"x"`, `Authorization: Bearer abc` and `redis://:password@host` style
122
144
  fragments are replaced with `[FILTERED]`. This catches the usual culprits (a
123
145
  database connection error carrying a password, an HTTP error carrying a URL with
124
146
  a token) but cannot be exhaustive the way parameter filtering is.
125
147
 
148
+ Path segments are sent as they are. If a route puts a secret in the path (for
149
+ example `/password_resets/:token`), move it to the query string or the body,
150
+ where it is filtered.
151
+
126
152
  Request bodies, headers and cookies are never sent.
127
153
 
154
+ In Google Chat, text like `<users/all>` or `<https://…|click here>` would become
155
+ an @all mention or a disguised link. Since anyone who can make your app raise
156
+ controls part of the report (the path, the User-Agent, often the exception
157
+ message), the gem defuses these with an invisible zero-width space after each
158
+ `<`. The report looks the same but can't ping the space or hide a link. The
159
+ space is still there if you copy text out of the report (for example
160
+ ``in `<main>'`` from a backtrace), so retype the `<` if a search comes up empty.
161
+
162
+ ## Google Chat rate limits
163
+
164
+ Google Chat accepts about one message per second per space, shared with
165
+ anything else posting there. Repeats of the same error are already collapsed
166
+ (`throttle_period`), but a burst of *different* errors beyond that rate is
167
+ rejected by Google with HTTP 429. Those rejections are logged, not retried.
168
+
128
169
  ## Reporting an exception yourself
129
170
 
130
171
  ```ruby
@@ -144,6 +185,12 @@ end
144
185
  The middleware observes and re-raises, so it never changes how your application
145
186
  handles errors. When both entry points see the same exception, it is reported once.
146
187
 
188
+ "The same error" means the same exception class, raised from the same line, on
189
+ the same controller action. So `/orders/1` and `/orders/2` failing the same way
190
+ count as one error for `throttle_period`. At most 10 reports are delivered at
191
+ once; during a storm, anything beyond that is dropped with a log line rather
192
+ than starting more threads.
193
+
147
194
  Asynchronous deliveries still in flight are waited for at process exit, so a
148
195
  report from a rake task or `rails runner` is not lost when the process ends. Call
149
196
  `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,51 @@
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
+ def render(embed)
26
+ heading = [bold(embed[:title]), embed[:description], ""]
27
+ (heading + embed[:fields].map { |field| render_field(field) }).join("\n")
28
+ end
29
+
30
+ def render_field(field)
31
+ separator = field[:value].include?("\n") ? "\n" : " "
32
+ "#{bold("#{field[:name]}:")}#{separator}#{field[:value]}"
33
+ end
34
+
35
+ def bold(text)
36
+ "*#{text}*"
37
+ end
38
+
39
+ # Chat code blocks take no language tag; a "json" one would be shown as text.
40
+ def fence(text)
41
+ "#{CODE_FENCE}\n#{truncate(text, FIELD_VALUE_LIMIT - CODE_BLOCK_OVERHEAD)}\n#{CODE_FENCE}"
42
+ end
43
+
44
+ def formatted_backtrace
45
+ lines = Array(exception.backtrace).first(config.backtrace_lines)
46
+ return "No backtrace available" if lines.empty?
47
+
48
+ fence(lines.join("\n"))
49
+ end
50
+ end
51
+ 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
@@ -263,7 +271,7 @@ module RailsDiscordNotifier
263
271
  end
264
272
 
265
273
  def truncate(string, limit)
266
- string = string.to_s
274
+ string = SecretScrubber.utf8(string)
267
275
  return string if string.length <= limit
268
276
  return string[0, limit].to_s if limit < ELLIPSIS.length
269
277
 
@@ -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.0"
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.0
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: []