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 +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +66 -13
- data/lib/generators/rails_discord_notifier/templates/rails_discord_notifier.rb +9 -7
- data/lib/rails_discord_notifier/cli.rb +14 -10
- data/lib/rails_discord_notifier/client.rb +5 -5
- data/lib/rails_discord_notifier/configuration.rb +14 -2
- data/lib/rails_discord_notifier/error_subscriber.rb +2 -1
- data/lib/rails_discord_notifier/google_chat_payload.rb +54 -0
- data/lib/rails_discord_notifier/notifier.rb +27 -14
- data/lib/rails_discord_notifier/payload.rb +47 -22
- data/lib/rails_discord_notifier/secret_scrubber.rb +30 -4
- data/lib/rails_discord_notifier/version.rb +1 -1
- data/lib/rails_discord_notifier.rb +3 -1
- data/sig/rails_discord_notifier.rbs +5 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbf5bc3adc89129d2ab69ef41705e363a214a53eb31dd1bd0dd2a4fb36253a8b
|
|
4
|
+
data.tar.gz: b35128c48b420cd7577a9a455f5d857ef952c08f0ef427136299a2e698e85101
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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"
|
|
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.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
52
|
+
@out.puts("Sent. Check your channel.")
|
|
52
53
|
0
|
|
53
54
|
else
|
|
54
|
-
@out.puts("
|
|
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.
|
|
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
|
-
|
|
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/...
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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/...
|
|
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.
|
|
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
|
|
85
|
-
|
|
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: []
|