tg_error_notifier 0.2.0 → 0.5.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 +4 -4
- data/README.md +92 -0
- data/lib/tg_error_notifier/configuration.rb +34 -1
- data/lib/tg_error_notifier/grouper.rb +6 -0
- data/lib/tg_error_notifier/middleware.rb +33 -2
- data/lib/tg_error_notifier/notifier.rb +195 -19
- data/lib/tg_error_notifier/topic_manager.rb +96 -8
- data/lib/tg_error_notifier/version.rb +1 -1
- data/lib/tg_error_notifier.rb +10 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71739ce66aa4580137b5c1781b0a6ae388713379297c26895883b77b4b8ba7a4
|
|
4
|
+
data.tar.gz: 0c0303ccbdc01d133029ee4ffcd842e7fc665223c804fd125bcdd82e1fb53761
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dd5dd9a02ec60173ff81eeb6b30f84831b351d9ac12f07d4d6ead4e1fb22eee0cadefcffc7acf85638ba4e4e3aa344873091e111bb9ddd2f228f67f1bb652ee
|
|
7
|
+
data.tar.gz: 53f7ffc99e4672c9bbde0b52897a7da2c05ae5327bf69fcdfcacdbe6fdfb06038b36696d5fe691f5b50091393d7a1f4458099d52cb53f1f19ec924f70c948141
|
data/README.md
CHANGED
|
@@ -138,3 +138,95 @@ TgErrorNotifier.capture_message(
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
`capture_message` returns the same diagnostic hash format as `capture_exception`.
|
|
141
|
+
|
|
142
|
+
## Named topics for messages
|
|
143
|
+
|
|
144
|
+
Send messages to a dedicated Forum topic by name. The topic is created on first use (blue icon) and reused afterwards:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
TgErrorNotifier.capture_message("New account: #{account.name}", topic: "Registrations")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Since the topic → thread_id mapping is cached in memory per process, configure a persistent store so restarts and multiple processes (Puma workers, Sidekiq) don't create duplicate topics:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
TgErrorNotifier.configure do |config|
|
|
154
|
+
config.topic_store_read = ->(name) { MyKeyValueStore.get("tg_topic:#{name}") }
|
|
155
|
+
config.topic_store_write = ->(name, thread_id) { MyKeyValueStore.set("tg_topic:#{name}", thread_id) }
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Without a store everything still works, but each process creates its own topic. Named topics work independently of `topics_enabled` (which controls per-exception topics). Requirements are the same: forum supergroup + `can_manage_topics` bot permission.
|
|
160
|
+
|
|
161
|
+
## Filtering request data
|
|
162
|
+
|
|
163
|
+
The query string of a failed request is filtered with `Rails.application.config.filter_parameters` (the same rules as the Rails log) before it is sent: `?token=abc` arrives as `?token=[FILTERED]`. A query that cannot be parsed is not sent at all. Override the rules with `config.filter_parameters = [...]`.
|
|
164
|
+
|
|
165
|
+
Paths that carry a secret as a segment (one-time login links, invitations) are masked by prefix:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
TgErrorNotifier.configure do |config|
|
|
169
|
+
config.sensitive_path_prefixes = %w[/tsd/ /invitations/]
|
|
170
|
+
end
|
|
171
|
+
# "/tsd/abc123/scan" is reported as "/tsd/[FILTERED]/scan"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Buttons under the message
|
|
175
|
+
|
|
176
|
+
Attach an inline keyboard to error notifications — e.g. a link that files the error as a task on your board. The gem does not interpret the buttons: whatever the callable returns is passed to Telegram as is, so `url`, `callback_data` and `web_app` buttons all work.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
TgErrorNotifier.configure do |config|
|
|
180
|
+
config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
|
|
181
|
+
next nil unless kind == :exception
|
|
182
|
+
|
|
183
|
+
token = ErrorTaskLink.issue(exception: exception, source: source, fingerprint: fingerprint)
|
|
184
|
+
[[{ text: "\u2795 To the board", url: "https://example.com/e/#{token}" }]]
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Arguments:
|
|
190
|
+
|
|
191
|
+
| key | value |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `kind` | `:exception` for `capture_exception`/rescued errors, `:message` for `capture_message` |
|
|
194
|
+
| `exception` | the exception (nil for `:message`) |
|
|
195
|
+
| `message` | the message text (nil for `:exception`) |
|
|
196
|
+
| `source` | where it came from (`"Sidekiq: MyWorker"`, `"manual"`, ...) |
|
|
197
|
+
| `context` | the context hash passed to the notifier |
|
|
198
|
+
| `fingerprint` | grouping key of the exception — stable across repeats, use it to deduplicate whatever the button creates (nil for `:message`) |
|
|
199
|
+
|
|
200
|
+
Return an array of rows (`[[button, button], [button]]`); a flat array becomes a single row; `nil` or `[]` means no keyboard. Exceptions raised inside the callable are logged and the notification is still delivered — without the keyboard.
|
|
201
|
+
|
|
202
|
+
## Updating buttons later
|
|
203
|
+
|
|
204
|
+
`notify`, `capture_exception` and `capture_message` return the ids of the delivered message:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
result = TgErrorNotifier.capture_exception(error)
|
|
208
|
+
# => { sent: true, status: :sent, code: 200, message_id: 812, chat_id: -1001234567890, thread_id: 15 }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Keep them if the notification should reflect what happened next — e.g. replace "To the board" with a link to the task once it exists:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
TgErrorNotifier.edit_buttons(
|
|
215
|
+
chat_id: stored.chat_id,
|
|
216
|
+
message_id: stored.message_id,
|
|
217
|
+
buttons: [[{ text: "Task #42 · In progress", url: "https://example.com/tasks/42" }]]
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`buttons` takes rows in the same shape as `config.buttons`; `nil` or `[]` removes the keyboard. Only the keyboard changes, the text stays as it was. The result is a hash with `status`:
|
|
222
|
+
|
|
223
|
+
| status | meaning |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `:edited` | keyboard replaced |
|
|
226
|
+
| `:not_modified` | keyboard is already the same |
|
|
227
|
+
| `:not_found` | the message was deleted — forget its ids |
|
|
228
|
+
| `:rate_limited` | Telegram asked to wait `retry_after` seconds |
|
|
229
|
+
| `:skipped` | notifier is disabled in this environment |
|
|
230
|
+
| `:failed` | any other API error, with `code` and `body` |
|
|
231
|
+
|
|
232
|
+
Unlike notifications, which never raise, `edit_buttons` lets network errors through: it is called by your code (usually a background job), which knows whether to retry. Telegram allows roughly 20 edits per minute in a group, so update the latest few messages of an error, not all of them.
|
|
@@ -23,7 +23,12 @@ module TgErrorNotifier
|
|
|
23
23
|
:grouping_enabled,
|
|
24
24
|
:grouping_window,
|
|
25
25
|
:topics_enabled,
|
|
26
|
-
:topic_icon_color
|
|
26
|
+
:topic_icon_color,
|
|
27
|
+
:topic_store_read,
|
|
28
|
+
:topic_store_write,
|
|
29
|
+
:buttons,
|
|
30
|
+
:filter_parameters,
|
|
31
|
+
:sensitive_path_prefixes
|
|
27
32
|
|
|
28
33
|
def initialize
|
|
29
34
|
@enabled = true
|
|
@@ -52,6 +57,34 @@ module TgErrorNotifier
|
|
|
52
57
|
@grouping_window = 60
|
|
53
58
|
@topics_enabled = false
|
|
54
59
|
@topic_icon_color = nil
|
|
60
|
+
# Persistent storage for named topics (created via capture_message topic:).
|
|
61
|
+
# Without a store each process/restart would create a duplicate forum topic.
|
|
62
|
+
# topic_store_read = ->(name) { ... } # returns thread_id or nil
|
|
63
|
+
# topic_store_write = ->(name, thread_id) { ... }
|
|
64
|
+
@topic_store_read = nil
|
|
65
|
+
@topic_store_write = nil
|
|
66
|
+
# Inline keyboard attached under the message. The gem does not interpret
|
|
67
|
+
# the buttons: whatever the host returns goes to Telegram as is, so the
|
|
68
|
+
# host is free to use url buttons, callback_data or web_app.
|
|
69
|
+
#
|
|
70
|
+
# config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
|
|
71
|
+
# [[{ text: "To the board", url: "https://example.com/e/#{token}" }]]
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# Return value: array of rows, each row an array of button hashes.
|
|
75
|
+
# A flat array of buttons is accepted too and becomes a single row.
|
|
76
|
+
# Return nil or [] for no keyboard. Errors raised inside are logged and
|
|
77
|
+
# the message is still delivered, without the keyboard.
|
|
78
|
+
@buttons = nil
|
|
79
|
+
# Query string of a failed request is filtered before it leaves the app:
|
|
80
|
+
# tokens in links (?token=..., ?code=...) must not end up in a Telegram chat.
|
|
81
|
+
# nil means "use Rails.application.config.filter_parameters" — the same
|
|
82
|
+
# rules as the Rails log.
|
|
83
|
+
@filter_parameters = nil
|
|
84
|
+
# Paths whose next segment is a secret (one-time login links and the like):
|
|
85
|
+
# config.sensitive_path_prefixes = %w[/tsd/ /invitations/]
|
|
86
|
+
# "/tsd/abc123/x" is reported as "/tsd/[FILTERED]/x".
|
|
87
|
+
@sensitive_path_prefixes = []
|
|
55
88
|
end
|
|
56
89
|
|
|
57
90
|
def proxy?
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module TgErrorNotifier
|
|
4
4
|
class Middleware
|
|
5
|
+
FILTERED = "[FILTERED]"
|
|
6
|
+
|
|
5
7
|
def initialize(app)
|
|
6
8
|
@app = app
|
|
7
9
|
end
|
|
@@ -14,12 +16,41 @@ module TgErrorNotifier
|
|
|
14
16
|
source: "rack",
|
|
15
17
|
context: {
|
|
16
18
|
method: env["REQUEST_METHOD"],
|
|
17
|
-
path: env["PATH_INFO"],
|
|
18
|
-
query: env["QUERY_STRING"],
|
|
19
|
+
path: self.class.filter_path(env["PATH_INFO"]),
|
|
20
|
+
query: self.class.filter_query(env["QUERY_STRING"]),
|
|
19
21
|
request_id: env["action_dispatch.request_id"]
|
|
20
22
|
}
|
|
21
23
|
)
|
|
22
24
|
raise
|
|
23
25
|
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def filter_query(query)
|
|
29
|
+
return query if query.nil? || query.empty?
|
|
30
|
+
|
|
31
|
+
params = Rack::Utils.parse_nested_query(query)
|
|
32
|
+
Rack::Utils.build_nested_query(parameter_filter.filter(params))
|
|
33
|
+
.gsub(Rack::Utils.escape(FILTERED), FILTERED)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
# Unparseable query (invalid encoding, broken nesting): it cannot be
|
|
36
|
+
# filtered key by key, so it is not reported at all.
|
|
37
|
+
FILTERED
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def filter_path(path)
|
|
41
|
+
return path if path.nil? || path.empty?
|
|
42
|
+
|
|
43
|
+
TgErrorNotifier.configuration.sensitive_path_prefixes.reduce(path) do |result, prefix|
|
|
44
|
+
result.gsub(%r{(#{Regexp.escape(prefix)})[^/?]+}) { "#{Regexp.last_match(1)}#{FILTERED}" }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def parameter_filter
|
|
51
|
+
filters = TgErrorNotifier.configuration.filter_parameters || Rails.application.config.filter_parameters
|
|
52
|
+
ActiveSupport::ParameterFilter.new(filters, mask: FILTERED)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
24
55
|
end
|
|
25
56
|
end
|
|
@@ -7,6 +7,10 @@ require "cgi"
|
|
|
7
7
|
module TgErrorNotifier
|
|
8
8
|
class Notifier
|
|
9
9
|
MAX_MESSAGE_LENGTH = 3800
|
|
10
|
+
# Отдельные поля (сообщение исключения, значения контекста) режем заранее,
|
|
11
|
+
# чтобы гарантированно осталось место под бэктрейс и закрывающие теги
|
|
12
|
+
MAX_FIELD_LENGTH = 900
|
|
13
|
+
HTML_TAGS = %w[pre code b i u s].freeze
|
|
10
14
|
|
|
11
15
|
def initialize(config)
|
|
12
16
|
@config = config
|
|
@@ -27,7 +31,7 @@ module TgErrorNotifier
|
|
|
27
31
|
thread_id = nil
|
|
28
32
|
suppressed_count = 0
|
|
29
33
|
|
|
30
|
-
if config.topics_enabled || config.grouping_enabled
|
|
34
|
+
if config.topics_enabled || config.grouping_enabled || config.buttons
|
|
31
35
|
key = grouper.grouping_key(exception)
|
|
32
36
|
end
|
|
33
37
|
|
|
@@ -49,21 +53,30 @@ module TgErrorNotifier
|
|
|
49
53
|
source: source,
|
|
50
54
|
context: context,
|
|
51
55
|
thread_id: thread_id,
|
|
52
|
-
suppressed_count: suppressed_count
|
|
56
|
+
suppressed_count: suppressed_count,
|
|
57
|
+
fingerprint: key
|
|
53
58
|
)
|
|
54
|
-
send_payload(payload)
|
|
59
|
+
response = send_payload(payload)
|
|
60
|
+
|
|
61
|
+
if !response[:sent] && config.grouping_enabled && key
|
|
62
|
+
grouper.rollback(key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response
|
|
55
66
|
rescue StandardError => e
|
|
56
67
|
log("notify failed: #{e.class}: #{e.message}")
|
|
57
68
|
{ sent: false, status: :failed, reason: e.class.name, error: e.message }
|
|
58
69
|
end
|
|
59
70
|
|
|
60
|
-
def notify_message(message:, level:, source:, context: {}, thread_id: nil)
|
|
71
|
+
def notify_message(message:, level:, source:, context: {}, topic: nil, thread_id: nil)
|
|
61
72
|
enabled_check = enabled_status
|
|
62
73
|
unless enabled_check[:enabled]
|
|
63
74
|
log("skipped: #{enabled_check[:reason]}")
|
|
64
75
|
return { sent: false, status: :skipped, reason: enabled_check[:reason] }
|
|
65
76
|
end
|
|
66
77
|
|
|
78
|
+
thread_id ||= topic_manager.thread_id_for_name(topic) if topic
|
|
79
|
+
|
|
67
80
|
payload = build_message_payload(
|
|
68
81
|
message: message,
|
|
69
82
|
level: level,
|
|
@@ -77,6 +90,27 @@ module TgErrorNotifier
|
|
|
77
90
|
{ sent: false, status: :failed, reason: e.class.name, error: e.message }
|
|
78
91
|
end
|
|
79
92
|
|
|
93
|
+
# Replaces the inline keyboard of an already delivered notification —
|
|
94
|
+
# e.g. "To the board" becomes "Task #42 · In progress" once somebody picks
|
|
95
|
+
# the error up. Message ids come from the notify/capture_* result.
|
|
96
|
+
# buttons: rows as in config.buttons; nil or [] removes the keyboard.
|
|
97
|
+
#
|
|
98
|
+
# Returns { edited: true, status: :edited } or { edited: false, status: }
|
|
99
|
+
# where status is :not_modified (keyboard is already the same),
|
|
100
|
+
# :not_found (message was deleted), :rate_limited (with retry_after
|
|
101
|
+
# seconds), :skipped or :failed. Network errors are raised: unlike a
|
|
102
|
+
# notification, an edit is called by the host, which decides whether to retry.
|
|
103
|
+
def edit_buttons(chat_id:, message_id:, buttons:)
|
|
104
|
+
enabled_check = enabled_status
|
|
105
|
+
return { edited: false, status: :skipped, reason: enabled_check[:reason] } unless enabled_check[:enabled]
|
|
106
|
+
|
|
107
|
+
payload = { chat_id: chat_id, message_id: message_id }
|
|
108
|
+
keyboard = keyboard_from(buttons)
|
|
109
|
+
payload[:reply_markup] = keyboard if keyboard
|
|
110
|
+
|
|
111
|
+
edit_result(post_api("editMessageReplyMarkup", payload))
|
|
112
|
+
end
|
|
113
|
+
|
|
80
114
|
private
|
|
81
115
|
|
|
82
116
|
attr_reader :config
|
|
@@ -108,9 +142,44 @@ module TgErrorNotifier
|
|
|
108
142
|
ignored.include?(exception.class.name)
|
|
109
143
|
end
|
|
110
144
|
|
|
111
|
-
def send_payload(payload)
|
|
145
|
+
def send_payload(payload, retry_plain: true)
|
|
146
|
+
response = post_api("sendMessage", payload)
|
|
147
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
148
|
+
return { sent: true, status: :sent, code: response.code.to_i }.merge(sent_message_ids(response))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
log("telegram api error: HTTP #{response.code} #{response.body}")
|
|
152
|
+
|
|
153
|
+
# Последний рубеж: разметка не распарсилась — шлём то же самое
|
|
154
|
+
# обычным текстом, чтобы тело сообщения дошло в любом случае
|
|
155
|
+
if retry_plain && parse_entities_error?(response)
|
|
156
|
+
plain = payload.dup
|
|
157
|
+
plain.delete(:parse_mode)
|
|
158
|
+
plain[:text] = truncate(strip_html(payload[:text]))
|
|
159
|
+
return send_payload(plain, retry_plain: false)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
{ sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Ids of the delivered message, so the host can edit it later
|
|
166
|
+
# (TgErrorNotifier.edit_buttons). A success response that cannot be read
|
|
167
|
+
# still means the message was delivered: report it as sent, without ids.
|
|
168
|
+
def sent_message_ids(response)
|
|
169
|
+
result = JSON.parse(response.body.to_s).fetch("result")
|
|
170
|
+
{
|
|
171
|
+
message_id: result.fetch("message_id"),
|
|
172
|
+
chat_id: result.fetch("chat").fetch("id"),
|
|
173
|
+
thread_id: result["message_thread_id"]
|
|
174
|
+
}
|
|
175
|
+
rescue JSON::ParserError, KeyError, TypeError, NoMethodError => e
|
|
176
|
+
log("sendMessage response without message ids: #{e.class}: #{e.message}")
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def post_api(method_name, payload)
|
|
112
181
|
token = resolve(config.bot_token)
|
|
113
|
-
uri = URI("#{resolve(config.api_base)}/bot#{token}
|
|
182
|
+
uri = URI("#{resolve(config.api_base)}/bot#{token}/#{method_name}")
|
|
114
183
|
|
|
115
184
|
request = Net::HTTP::Post.new(uri)
|
|
116
185
|
request["Content-Type"] = "application/json"
|
|
@@ -125,21 +194,47 @@ module TgErrorNotifier
|
|
|
125
194
|
http.open_timeout = config.open_timeout
|
|
126
195
|
http.read_timeout = config.read_timeout
|
|
127
196
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
197
|
+
http.request(request)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def edit_result(response)
|
|
201
|
+
return { edited: true, status: :edited } if response.is_a?(Net::HTTPSuccess)
|
|
202
|
+
|
|
203
|
+
body = response.body.to_s
|
|
204
|
+
description = begin
|
|
205
|
+
JSON.parse(body)["description"].to_s
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
body
|
|
131
208
|
end
|
|
132
209
|
|
|
133
|
-
|
|
134
|
-
|
|
210
|
+
if response.code.to_i == 400 && description.include?("message is not modified")
|
|
211
|
+
{ edited: false, status: :not_modified }
|
|
212
|
+
elsif response.code.to_i == 400 && description.include?("message to edit not found")
|
|
213
|
+
{ edited: false, status: :not_found }
|
|
214
|
+
elsif response.code.to_i == 429
|
|
215
|
+
{ edited: false, status: :rate_limited, retry_after: retry_after(body) }
|
|
216
|
+
else
|
|
217
|
+
log("telegram api error on edit: HTTP #{response.code} #{body}")
|
|
218
|
+
{ edited: false, status: :failed, code: response.code.to_i, body: body }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def retry_after(body)
|
|
223
|
+
JSON.parse(body).dig("parameters", "retry_after")
|
|
224
|
+
rescue JSON::ParserError
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def parse_entities_error?(response)
|
|
229
|
+
response.code.to_i == 400 && response.body.to_s.include?("can't parse entities")
|
|
135
230
|
end
|
|
136
231
|
|
|
137
|
-
def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0)
|
|
232
|
+
def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0, fingerprint: nil)
|
|
138
233
|
parts = [
|
|
139
234
|
"<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
|
|
140
|
-
"<b>Source:</b> #{escape(source
|
|
235
|
+
"<b>Source:</b> #{escape(clamp(source, MAX_FIELD_LENGTH))}",
|
|
141
236
|
"<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
|
|
142
|
-
"<b>Message:</b> #{escape(exception.message
|
|
237
|
+
"<b>Message:</b> #{escape(clamp(exception.message, MAX_FIELD_LENGTH))}"
|
|
143
238
|
]
|
|
144
239
|
|
|
145
240
|
if suppressed_count > 0
|
|
@@ -148,12 +243,19 @@ module TgErrorNotifier
|
|
|
148
243
|
|
|
149
244
|
parts << context_block(context)
|
|
150
245
|
|
|
151
|
-
text = parts.compact.join("\n")
|
|
246
|
+
text = truncate(parts.compact.join("\n"))
|
|
152
247
|
|
|
153
248
|
if config.include_backtrace && exception.backtrace
|
|
154
249
|
lines = exception.backtrace.first(config.max_backtrace_lines)
|
|
155
|
-
|
|
156
|
-
|
|
250
|
+
header = "\n<b>Backtrace:</b>\n"
|
|
251
|
+
# Бэктрейс режем по остатку бюджета ДО оборачивания в <pre>,
|
|
252
|
+
# иначе финальный truncate обрезал бы текст внутри тега и Telegram
|
|
253
|
+
# отвечал 400 "can't find end tag corresponding to start tag pre"
|
|
254
|
+
budget = MAX_MESSAGE_LENGTH - text.length - header.length - "<pre></pre>".length
|
|
255
|
+
if budget > 100
|
|
256
|
+
bt = cut_escaped(escape(lines.join("\n")), budget)
|
|
257
|
+
text = "#{text}#{header}<pre>#{bt}</pre>"
|
|
258
|
+
end
|
|
157
259
|
end
|
|
158
260
|
|
|
159
261
|
payload = {
|
|
@@ -163,13 +265,50 @@ module TgErrorNotifier
|
|
|
163
265
|
disable_web_page_preview: true
|
|
164
266
|
}
|
|
165
267
|
payload[:message_thread_id] = thread_id if thread_id
|
|
268
|
+
keyboard = build_keyboard(
|
|
269
|
+
kind: :exception,
|
|
270
|
+
exception: exception,
|
|
271
|
+
source: source,
|
|
272
|
+
context: context,
|
|
273
|
+
fingerprint: fingerprint
|
|
274
|
+
)
|
|
275
|
+
payload[:reply_markup] = keyboard if keyboard
|
|
166
276
|
payload
|
|
167
277
|
end
|
|
168
278
|
|
|
279
|
+
# Кнопки строит хост (config.buttons) — гем не знает, куда они ведут.
|
|
280
|
+
# Сбой в колбэке не должен отменять доставку самого уведомления: об ошибке
|
|
281
|
+
# надо узнать в любом случае, кнопка — дополнение.
|
|
282
|
+
def build_keyboard(kind:, source:, context:, fingerprint:, exception: nil, message: nil)
|
|
283
|
+
return nil if config.buttons.nil?
|
|
284
|
+
|
|
285
|
+
rows = config.buttons.call(
|
|
286
|
+
kind: kind,
|
|
287
|
+
exception: exception,
|
|
288
|
+
message: message,
|
|
289
|
+
source: source,
|
|
290
|
+
context: context,
|
|
291
|
+
fingerprint: fingerprint
|
|
292
|
+
)
|
|
293
|
+
keyboard_from(rows)
|
|
294
|
+
rescue StandardError => e
|
|
295
|
+
log("buttons failed: #{e.class}: #{e.message}")
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def keyboard_from(rows)
|
|
300
|
+
return nil if rows.nil? || rows.empty?
|
|
301
|
+
|
|
302
|
+
# Плоский список кнопок принимаем как одну строку клавиатуры.
|
|
303
|
+
rows = [rows] unless rows.first.is_a?(Array)
|
|
304
|
+
|
|
305
|
+
{ inline_keyboard: rows }
|
|
306
|
+
end
|
|
307
|
+
|
|
169
308
|
def context_block(context)
|
|
170
309
|
return nil if context.nil? || context.empty?
|
|
171
310
|
|
|
172
|
-
formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(v
|
|
311
|
+
formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(clamp(v, MAX_FIELD_LENGTH))}" }
|
|
173
312
|
formatted.join("\n")
|
|
174
313
|
end
|
|
175
314
|
|
|
@@ -189,13 +328,50 @@ module TgErrorNotifier
|
|
|
189
328
|
disable_web_page_preview: true
|
|
190
329
|
}
|
|
191
330
|
payload[:message_thread_id] = thread_id if thread_id
|
|
331
|
+
keyboard = build_keyboard(
|
|
332
|
+
kind: :message,
|
|
333
|
+
message: message,
|
|
334
|
+
source: source,
|
|
335
|
+
context: context,
|
|
336
|
+
fingerprint: nil
|
|
337
|
+
)
|
|
338
|
+
payload[:reply_markup] = keyboard if keyboard
|
|
192
339
|
payload
|
|
193
340
|
end
|
|
194
341
|
|
|
342
|
+
def clamp(value, limit)
|
|
343
|
+
text = value.to_s
|
|
344
|
+
text.length > limit ? "#{text[0...limit]}…" : text
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Обрезка уже экранированного текста: не оставляем хвост от «&»
|
|
348
|
+
def cut_escaped(text, limit)
|
|
349
|
+
return text if text.length <= limit
|
|
350
|
+
|
|
351
|
+
text[0...limit].sub(/&[#a-zA-Z0-9]*\z/, "")
|
|
352
|
+
end
|
|
353
|
+
|
|
195
354
|
def truncate(text)
|
|
196
355
|
return text if text.length <= MAX_MESSAGE_LENGTH
|
|
197
356
|
|
|
198
|
-
text
|
|
357
|
+
close_tags(cut_escaped(text, MAX_MESSAGE_LENGTH - 20)) + "\n...truncated"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Обрезка могла оставить незакрытый тег или его половину — Telegram
|
|
361
|
+
# на таком отвечает 400 и сообщение не доходит вовсе
|
|
362
|
+
def close_tags(text)
|
|
363
|
+
text = text.sub(/<[^>]*\z/, "")
|
|
364
|
+
|
|
365
|
+
HTML_TAGS.each do |tag|
|
|
366
|
+
unclosed = text.scan("<#{tag}>").size - text.scan("</#{tag}>").size
|
|
367
|
+
unclosed.times { text += "</#{tag}>" }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
text
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def strip_html(text)
|
|
374
|
+
CGI.unescapeHTML(text.to_s.gsub(/<[^>]*>/, ""))
|
|
199
375
|
end
|
|
200
376
|
|
|
201
377
|
def resolve(value)
|
|
@@ -2,27 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "json"
|
|
5
|
+
require "set"
|
|
5
6
|
|
|
6
7
|
module TgErrorNotifier
|
|
7
8
|
class TopicManager
|
|
8
9
|
ICON_COLOR_RED = 0xFB6F5F
|
|
10
|
+
ICON_COLOR_BLUE = 0x6FB9F0
|
|
9
11
|
MAX_TOPIC_NAME = 128
|
|
12
|
+
MAX_STORE_KEY = 200
|
|
10
13
|
|
|
11
14
|
def initialize(config)
|
|
12
15
|
@config = config
|
|
13
16
|
@mutex = Mutex.new
|
|
17
|
+
@condition = ConditionVariable.new
|
|
14
18
|
@topics = {} # grouping_key => message_thread_id
|
|
19
|
+
@creating = Set.new
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def thread_id_for(key, exception)
|
|
23
|
+
name = nil
|
|
24
|
+
store_key = "exception:#{key}"[0...MAX_STORE_KEY]
|
|
25
|
+
|
|
18
26
|
@mutex.synchronize do
|
|
27
|
+
# Wait if another thread is already creating this topic
|
|
28
|
+
while @creating.include?(key)
|
|
29
|
+
@condition.wait(@mutex, 10)
|
|
30
|
+
end
|
|
31
|
+
|
|
19
32
|
return @topics[key] if @topics.key?(key)
|
|
33
|
+
|
|
34
|
+
# Тема ошибки должна пережить рестарт и быть общей для всех
|
|
35
|
+
# процессов: иначе каждый puma/sidekiq-воркер заводит свой дубль
|
|
36
|
+
stored = store_read(store_key)
|
|
37
|
+
if stored
|
|
38
|
+
@topics[key] = stored
|
|
39
|
+
return stored
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@creating.add(key)
|
|
43
|
+
name = topic_name(exception)
|
|
20
44
|
end
|
|
21
45
|
|
|
22
|
-
thread_id = create_topic(
|
|
46
|
+
thread_id = create_topic(name)
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
if thread_id
|
|
50
|
+
@topics[key] = thread_id
|
|
51
|
+
store_write(store_key, thread_id)
|
|
52
|
+
end
|
|
53
|
+
@creating.delete(key)
|
|
54
|
+
@condition.broadcast
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
thread_id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resolve thread_id for an explicitly named topic (capture_message topic: "...").
|
|
61
|
+
# Unlike exception topics, named topics survive restarts via the configured
|
|
62
|
+
# topic_store_read/topic_store_write callbacks.
|
|
63
|
+
def thread_id_for_name(name)
|
|
64
|
+
key = "topic:#{name}"
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
while @creating.include?(key)
|
|
68
|
+
@condition.wait(@mutex, 10)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return @topics[key] if @topics.key?(key)
|
|
72
|
+
|
|
73
|
+
stored = store_read(name)
|
|
74
|
+
if stored
|
|
75
|
+
@topics[key] = stored
|
|
76
|
+
return stored
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@creating.add(key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
thread_id = create_topic(truncate_name(name), icon_color: @config.topic_icon_color || ICON_COLOR_BLUE)
|
|
83
|
+
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
if thread_id
|
|
86
|
+
@topics[key] = thread_id
|
|
87
|
+
store_write(name, thread_id)
|
|
88
|
+
end
|
|
89
|
+
@creating.delete(key)
|
|
90
|
+
@condition.broadcast
|
|
26
91
|
end
|
|
27
92
|
|
|
28
93
|
thread_id
|
|
@@ -30,13 +95,36 @@ module TgErrorNotifier
|
|
|
30
95
|
|
|
31
96
|
private
|
|
32
97
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
98
|
+
def store_read(name)
|
|
99
|
+
reader = @config.topic_store_read
|
|
100
|
+
return nil unless reader
|
|
101
|
+
|
|
102
|
+
value = reader.call(name)
|
|
103
|
+
value.to_s.empty? ? nil : value.to_i
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
log("topic_store_read failed: #{e.class}: #{e.message}")
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def store_write(name, thread_id)
|
|
110
|
+
writer = @config.topic_store_write
|
|
111
|
+
return unless writer
|
|
112
|
+
|
|
113
|
+
writer.call(name, thread_id)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
log("topic_store_write failed: #{e.class}: #{e.message}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def truncate_name(name)
|
|
119
|
+
name = name.to_s.gsub(/\s+/, " ").strip
|
|
36
120
|
name.length > MAX_TOPIC_NAME ? "#{name[0...MAX_TOPIC_NAME - 1]}…" : name
|
|
37
121
|
end
|
|
38
122
|
|
|
39
|
-
def
|
|
123
|
+
def topic_name(exception)
|
|
124
|
+
truncate_name("#{exception.class.name}: #{exception.message}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def create_topic(name, icon_color: nil)
|
|
40
128
|
token = resolve(@config.bot_token)
|
|
41
129
|
chat_id = resolve(@config.chat_id)
|
|
42
130
|
uri = URI("#{resolve(@config.api_base)}/bot#{token}/createForumTopic")
|
|
@@ -44,7 +132,7 @@ module TgErrorNotifier
|
|
|
44
132
|
payload = {
|
|
45
133
|
chat_id: chat_id,
|
|
46
134
|
name: name,
|
|
47
|
-
icon_color: @config.topic_icon_color || ICON_COLOR_RED
|
|
135
|
+
icon_color: icon_color || @config.topic_icon_color || ICON_COLOR_RED
|
|
48
136
|
}
|
|
49
137
|
|
|
50
138
|
request = Net::HTTP::Post.new(uri)
|
data/lib/tg_error_notifier.rb
CHANGED
|
@@ -30,8 +30,16 @@ module TgErrorNotifier
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# API similar to Sentry.capture_message("text")
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
# topic: имя форум-топика — сообщение уйдёт в отдельную тему группы
|
|
34
|
+
# (топик создаётся один раз и переиспользуется через topic_store_read/write)
|
|
35
|
+
def capture_message(message, level: :info, source: "manual", context: {}, topic: nil)
|
|
36
|
+
notifier.notify_message(message: message, level: level, source: source, context: context, topic: topic)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Replace the buttons under a delivered notification. Ids come from the
|
|
40
|
+
# result of notify/capture_exception/capture_message (message_id, chat_id).
|
|
41
|
+
def edit_buttons(chat_id:, message_id:, buttons:)
|
|
42
|
+
notifier.edit_buttons(chat_id: chat_id, message_id: message_id, buttons: buttons)
|
|
35
43
|
end
|
|
36
44
|
|
|
37
45
|
def reset!
|