tg_error_notifier 0.1.2 → 0.4.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 +83 -0
- data/lib/tg_error_notifier/configuration.rb +31 -1
- data/lib/tg_error_notifier/grouper.rb +74 -0
- data/lib/tg_error_notifier/notifier.rb +174 -18
- data/lib/tg_error_notifier/railtie.rb +4 -0
- data/lib/tg_error_notifier/topic_manager.rb +178 -0
- data/lib/tg_error_notifier/version.rb +1 -1
- data/lib/tg_error_notifier.rb +11 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03b6d8e79dd258e7487a99925017dc62d9fd7d66255af5e80c62aecff7b6638b
|
|
4
|
+
data.tar.gz: 062f6f0253004feb248c55e9cf2962244a7683c6d6771dac55f9a762a1c97bec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8caa97214339f4f0af2683a40f7d3a705a80fd0ecb9dd65f7a242d97efb38b2cab5e284b5b4f2d2cff3bb0121034190b09f8ad87793529937e89a2624bf1bc3c
|
|
7
|
+
data.tar.gz: 8e546d58081bd0e50abae5bf375a7d9adac0ada19032fb44fbfe089fedb62fbaf55e0abe4b35489857716fa21352466c90224d36592faa43926d6fd578330b5b
|
data/README.md
CHANGED
|
@@ -70,6 +70,42 @@ Examples:
|
|
|
70
70
|
- Ensure bot has permission to send messages.
|
|
71
71
|
- Put value into `TELEGRAM_ERRORS_CHAT_ID` and run a smoke test.
|
|
72
72
|
|
|
73
|
+
## Error grouping
|
|
74
|
+
|
|
75
|
+
Group identical errors to avoid flooding. When the same exception repeats within a time window, only the first message is sent — subsequent occurrences are suppressed and reported as a count in the next message.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
TgErrorNotifier.configure do |config|
|
|
79
|
+
config.grouping_enabled = true
|
|
80
|
+
config.grouping_window = 60 # seconds (default)
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Errors are grouped by exception class + normalized message (IDs and UUIDs are replaced with placeholders for better deduplication).
|
|
85
|
+
|
|
86
|
+
## Forum topics (threads)
|
|
87
|
+
|
|
88
|
+
Automatically create a Telegram Forum topic (thread) per unique error type. Each error gets its own topic in a supergroup with Forum Topics enabled.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
TgErrorNotifier.configure do |config|
|
|
92
|
+
config.topics_enabled = true
|
|
93
|
+
config.topic_icon_color = 0xFB6F5F # red (default), optional
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Requirements:** The chat must be a supergroup with Forum Topics enabled. The bot must have `can_manage_topics` admin permission.
|
|
98
|
+
|
|
99
|
+
You can combine both features — errors will be grouped within their respective topics:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
TgErrorNotifier.configure do |config|
|
|
103
|
+
config.grouping_enabled = true
|
|
104
|
+
config.grouping_window = 60
|
|
105
|
+
config.topics_enabled = true
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
73
109
|
## Manual notification
|
|
74
110
|
```ruby
|
|
75
111
|
begin
|
|
@@ -102,3 +138,50 @@ TgErrorNotifier.capture_message(
|
|
|
102
138
|
```
|
|
103
139
|
|
|
104
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
|
+
## Buttons under the message
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
TgErrorNotifier.configure do |config|
|
|
167
|
+
config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
|
|
168
|
+
next nil unless kind == :exception
|
|
169
|
+
|
|
170
|
+
token = ErrorTaskLink.issue(exception: exception, source: source, fingerprint: fingerprint)
|
|
171
|
+
[[{ text: "\u2795 To the board", url: "https://example.com/e/#{token}" }]]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Arguments:
|
|
177
|
+
|
|
178
|
+
| key | value |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `kind` | `:exception` for `capture_exception`/rescued errors, `:message` for `capture_message` |
|
|
181
|
+
| `exception` | the exception (nil for `:message`) |
|
|
182
|
+
| `message` | the message text (nil for `:exception`) |
|
|
183
|
+
| `source` | where it came from (`"Sidekiq: MyWorker"`, `"manual"`, ...) |
|
|
184
|
+
| `context` | the context hash passed to the notifier |
|
|
185
|
+
| `fingerprint` | grouping key of the exception — stable across repeats, use it to deduplicate whatever the button creates (nil for `:message`) |
|
|
186
|
+
|
|
187
|
+
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.
|
|
@@ -19,7 +19,14 @@ module TgErrorNotifier
|
|
|
19
19
|
:proxy_addr,
|
|
20
20
|
:proxy_port,
|
|
21
21
|
:proxy_user,
|
|
22
|
-
:proxy_pass
|
|
22
|
+
:proxy_pass,
|
|
23
|
+
:grouping_enabled,
|
|
24
|
+
:grouping_window,
|
|
25
|
+
:topics_enabled,
|
|
26
|
+
:topic_icon_color,
|
|
27
|
+
:topic_store_read,
|
|
28
|
+
:topic_store_write,
|
|
29
|
+
:buttons
|
|
23
30
|
|
|
24
31
|
def initialize
|
|
25
32
|
@enabled = true
|
|
@@ -44,6 +51,29 @@ module TgErrorNotifier
|
|
|
44
51
|
@proxy_port = nil
|
|
45
52
|
@proxy_user = nil
|
|
46
53
|
@proxy_pass = nil
|
|
54
|
+
@grouping_enabled = false
|
|
55
|
+
@grouping_window = 60
|
|
56
|
+
@topics_enabled = false
|
|
57
|
+
@topic_icon_color = nil
|
|
58
|
+
# Persistent storage for named topics (created via capture_message topic:).
|
|
59
|
+
# Without a store each process/restart would create a duplicate forum topic.
|
|
60
|
+
# topic_store_read = ->(name) { ... } # returns thread_id or nil
|
|
61
|
+
# topic_store_write = ->(name, thread_id) { ... }
|
|
62
|
+
@topic_store_read = nil
|
|
63
|
+
@topic_store_write = nil
|
|
64
|
+
# Inline keyboard attached under the message. The gem does not interpret
|
|
65
|
+
# the buttons: whatever the host returns goes to Telegram as is, so the
|
|
66
|
+
# host is free to use url buttons, callback_data or web_app.
|
|
67
|
+
#
|
|
68
|
+
# config.buttons = lambda do |kind:, source:, context:, fingerprint:, exception: nil, message: nil|
|
|
69
|
+
# [[{ text: "To the board", url: "https://example.com/e/#{token}" }]]
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# Return value: array of rows, each row an array of button hashes.
|
|
73
|
+
# A flat array of buttons is accepted too and becomes a single row.
|
|
74
|
+
# Return nil or [] for no keyboard. Errors raised inside are logged and
|
|
75
|
+
# the message is still delivered, without the keyboard.
|
|
76
|
+
@buttons = nil
|
|
47
77
|
end
|
|
48
78
|
|
|
49
79
|
def proxy?
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TgErrorNotifier
|
|
4
|
+
class Grouper
|
|
5
|
+
Entry = Struct.new(:count, :first_at, :last_sent_at, :thread_id, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
CLEANUP_INTERVAL = 100
|
|
8
|
+
|
|
9
|
+
def initialize(window:)
|
|
10
|
+
@window = window
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@entries = {}
|
|
13
|
+
@call_count = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns:
|
|
17
|
+
# { action: :send, count: N, thread_id: id_or_nil }
|
|
18
|
+
# { action: :suppress }
|
|
19
|
+
def process(key:, thread_id: nil)
|
|
20
|
+
now = Time.now
|
|
21
|
+
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@call_count += 1
|
|
24
|
+
lazy_cleanup!(now) if (@call_count % CLEANUP_INTERVAL).zero?
|
|
25
|
+
|
|
26
|
+
entry = @entries[key]
|
|
27
|
+
|
|
28
|
+
if entry.nil?
|
|
29
|
+
@entries[key] = Entry.new(count: 0, first_at: now, last_sent_at: now, thread_id: thread_id)
|
|
30
|
+
return { action: :send, count: 0, thread_id: thread_id }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
entry.thread_id = thread_id if entry.thread_id.nil? && thread_id
|
|
34
|
+
|
|
35
|
+
elapsed = now - entry.last_sent_at
|
|
36
|
+
|
|
37
|
+
if elapsed >= @window
|
|
38
|
+
accumulated = entry.count
|
|
39
|
+
entry.count = 0
|
|
40
|
+
entry.last_sent_at = now
|
|
41
|
+
{ action: :send, count: accumulated, thread_id: entry.thread_id }
|
|
42
|
+
else
|
|
43
|
+
entry.count += 1
|
|
44
|
+
{ action: :suppress }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rollback(key)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@entries.delete(key)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def grouping_key(exception)
|
|
56
|
+
"#{exception.class.name}:#{normalize_message(exception.message)}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def normalize_message(message)
|
|
62
|
+
msg = message.to_s
|
|
63
|
+
msg = msg.gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "<UUID>")
|
|
64
|
+
msg = msg.gsub(/\b\d{4,}\b/, "<ID>")
|
|
65
|
+
msg = msg.gsub(/#<\w+:0x[0-9a-f]+>/i, "#<Object>")
|
|
66
|
+
msg.strip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def lazy_cleanup!(now)
|
|
70
|
+
cutoff = now - 3600
|
|
71
|
+
@entries.delete_if { |_, e| e.last_sent_at < cutoff }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
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
|
|
@@ -23,21 +27,63 @@ module TgErrorNotifier
|
|
|
23
27
|
return { sent: false, status: :skipped, reason: "ignored_exception" }
|
|
24
28
|
end
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
key = nil
|
|
31
|
+
thread_id = nil
|
|
32
|
+
suppressed_count = 0
|
|
33
|
+
|
|
34
|
+
if config.topics_enabled || config.grouping_enabled || config.buttons
|
|
35
|
+
key = grouper.grouping_key(exception)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if config.topics_enabled
|
|
39
|
+
thread_id = topic_manager.thread_id_for(key, exception)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if config.grouping_enabled
|
|
43
|
+
result = grouper.process(key: key, thread_id: thread_id)
|
|
44
|
+
if result[:action] == :suppress
|
|
45
|
+
return { sent: false, status: :suppressed }
|
|
46
|
+
end
|
|
47
|
+
suppressed_count = result[:count]
|
|
48
|
+
thread_id = result[:thread_id] || thread_id
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
payload = build_payload(
|
|
52
|
+
exception: exception,
|
|
53
|
+
source: source,
|
|
54
|
+
context: context,
|
|
55
|
+
thread_id: thread_id,
|
|
56
|
+
suppressed_count: suppressed_count,
|
|
57
|
+
fingerprint: key
|
|
58
|
+
)
|
|
59
|
+
response = send_payload(payload)
|
|
60
|
+
|
|
61
|
+
if !response[:sent] && config.grouping_enabled && key
|
|
62
|
+
grouper.rollback(key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response
|
|
28
66
|
rescue StandardError => e
|
|
29
67
|
log("notify failed: #{e.class}: #{e.message}")
|
|
30
68
|
{ sent: false, status: :failed, reason: e.class.name, error: e.message }
|
|
31
69
|
end
|
|
32
70
|
|
|
33
|
-
def notify_message(message:, level:, source:, context: {})
|
|
71
|
+
def notify_message(message:, level:, source:, context: {}, topic: nil, thread_id: nil)
|
|
34
72
|
enabled_check = enabled_status
|
|
35
73
|
unless enabled_check[:enabled]
|
|
36
74
|
log("skipped: #{enabled_check[:reason]}")
|
|
37
75
|
return { sent: false, status: :skipped, reason: enabled_check[:reason] }
|
|
38
76
|
end
|
|
39
77
|
|
|
40
|
-
|
|
78
|
+
thread_id ||= topic_manager.thread_id_for_name(topic) if topic
|
|
79
|
+
|
|
80
|
+
payload = build_message_payload(
|
|
81
|
+
message: message,
|
|
82
|
+
level: level,
|
|
83
|
+
source: source,
|
|
84
|
+
context: context,
|
|
85
|
+
thread_id: thread_id
|
|
86
|
+
)
|
|
41
87
|
send_payload(payload)
|
|
42
88
|
rescue StandardError => e
|
|
43
89
|
log("notify_message failed: #{e.class}: #{e.message}")
|
|
@@ -48,6 +94,14 @@ module TgErrorNotifier
|
|
|
48
94
|
|
|
49
95
|
attr_reader :config
|
|
50
96
|
|
|
97
|
+
def grouper
|
|
98
|
+
@grouper ||= Grouper.new(window: config.grouping_window)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def topic_manager
|
|
102
|
+
@topic_manager ||= TopicManager.new(config)
|
|
103
|
+
end
|
|
104
|
+
|
|
51
105
|
def enabled_status
|
|
52
106
|
return { enabled: false, reason: "disabled" } unless resolve(config.enabled)
|
|
53
107
|
if config.ignored_environments.include?(resolve(config.environment).to_s)
|
|
@@ -67,7 +121,7 @@ module TgErrorNotifier
|
|
|
67
121
|
ignored.include?(exception.class.name)
|
|
68
122
|
end
|
|
69
123
|
|
|
70
|
-
def send_payload(payload)
|
|
124
|
+
def send_payload(payload, retry_plain: true)
|
|
71
125
|
token = resolve(config.bot_token)
|
|
72
126
|
uri = URI("#{resolve(config.api_base)}/bot#{token}/sendMessage")
|
|
73
127
|
|
|
@@ -90,40 +144,103 @@ module TgErrorNotifier
|
|
|
90
144
|
end
|
|
91
145
|
|
|
92
146
|
log("telegram api error: HTTP #{response.code} #{response.body}")
|
|
147
|
+
|
|
148
|
+
# Последний рубеж: разметка не распарсилась — шлём то же самое
|
|
149
|
+
# обычным текстом, чтобы тело сообщения дошло в любом случае
|
|
150
|
+
if retry_plain && parse_entities_error?(response)
|
|
151
|
+
plain = payload.dup
|
|
152
|
+
plain.delete(:parse_mode)
|
|
153
|
+
plain[:text] = truncate(strip_html(payload[:text]))
|
|
154
|
+
return send_payload(plain, retry_plain: false)
|
|
155
|
+
end
|
|
156
|
+
|
|
93
157
|
{ sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
|
|
94
158
|
end
|
|
95
159
|
|
|
96
|
-
def
|
|
97
|
-
|
|
160
|
+
def parse_entities_error?(response)
|
|
161
|
+
response.code.to_i == 400 && response.body.to_s.include?("can't parse entities")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0, fingerprint: nil)
|
|
165
|
+
parts = [
|
|
98
166
|
"<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
|
|
99
|
-
"<b>Source:</b> #{escape(source
|
|
167
|
+
"<b>Source:</b> #{escape(clamp(source, MAX_FIELD_LENGTH))}",
|
|
100
168
|
"<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
|
|
101
|
-
"<b>Message:</b> #{escape(exception.message
|
|
102
|
-
|
|
103
|
-
|
|
169
|
+
"<b>Message:</b> #{escape(clamp(exception.message, MAX_FIELD_LENGTH))}"
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if suppressed_count > 0
|
|
173
|
+
parts << "<b>🔁 +#{suppressed_count} more in last #{config.grouping_window}s</b>"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
parts << context_block(context)
|
|
177
|
+
|
|
178
|
+
text = truncate(parts.compact.join("\n"))
|
|
104
179
|
|
|
105
180
|
if config.include_backtrace && exception.backtrace
|
|
106
181
|
lines = exception.backtrace.first(config.max_backtrace_lines)
|
|
107
|
-
|
|
108
|
-
|
|
182
|
+
header = "\n<b>Backtrace:</b>\n"
|
|
183
|
+
# Бэктрейс режем по остатку бюджета ДО оборачивания в <pre>,
|
|
184
|
+
# иначе финальный truncate обрезал бы текст внутри тега и Telegram
|
|
185
|
+
# отвечал 400 "can't find end tag corresponding to start tag pre"
|
|
186
|
+
budget = MAX_MESSAGE_LENGTH - text.length - header.length - "<pre></pre>".length
|
|
187
|
+
if budget > 100
|
|
188
|
+
bt = cut_escaped(escape(lines.join("\n")), budget)
|
|
189
|
+
text = "#{text}#{header}<pre>#{bt}</pre>"
|
|
190
|
+
end
|
|
109
191
|
end
|
|
110
192
|
|
|
111
|
-
{
|
|
193
|
+
payload = {
|
|
112
194
|
chat_id: resolve(config.chat_id),
|
|
113
195
|
text: truncate(text),
|
|
114
196
|
parse_mode: "HTML",
|
|
115
197
|
disable_web_page_preview: true
|
|
116
198
|
}
|
|
199
|
+
payload[:message_thread_id] = thread_id if thread_id
|
|
200
|
+
keyboard = build_keyboard(
|
|
201
|
+
kind: :exception,
|
|
202
|
+
exception: exception,
|
|
203
|
+
source: source,
|
|
204
|
+
context: context,
|
|
205
|
+
fingerprint: fingerprint
|
|
206
|
+
)
|
|
207
|
+
payload[:reply_markup] = keyboard if keyboard
|
|
208
|
+
payload
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Кнопки строит хост (config.buttons) — гем не знает, куда они ведут.
|
|
212
|
+
# Сбой в колбэке не должен отменять доставку самого уведомления: об ошибке
|
|
213
|
+
# надо узнать в любом случае, кнопка — дополнение.
|
|
214
|
+
def build_keyboard(kind:, source:, context:, fingerprint:, exception: nil, message: nil)
|
|
215
|
+
return nil if config.buttons.nil?
|
|
216
|
+
|
|
217
|
+
rows = config.buttons.call(
|
|
218
|
+
kind: kind,
|
|
219
|
+
exception: exception,
|
|
220
|
+
message: message,
|
|
221
|
+
source: source,
|
|
222
|
+
context: context,
|
|
223
|
+
fingerprint: fingerprint
|
|
224
|
+
)
|
|
225
|
+
return nil if rows.nil? || rows.empty?
|
|
226
|
+
|
|
227
|
+
# Плоский список кнопок принимаем как одну строку клавиатуры.
|
|
228
|
+
rows = [rows] unless rows.first.is_a?(Array)
|
|
229
|
+
|
|
230
|
+
{ inline_keyboard: rows }
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
log("buttons failed: #{e.class}: #{e.message}")
|
|
233
|
+
nil
|
|
117
234
|
end
|
|
118
235
|
|
|
119
236
|
def context_block(context)
|
|
120
237
|
return nil if context.nil? || context.empty?
|
|
121
238
|
|
|
122
|
-
formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(v
|
|
239
|
+
formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(clamp(v, MAX_FIELD_LENGTH))}" }
|
|
123
240
|
formatted.join("\n")
|
|
124
241
|
end
|
|
125
242
|
|
|
126
|
-
def build_message_payload(message:, level:, source:, context: {})
|
|
243
|
+
def build_message_payload(message:, level:, source:, context: {}, thread_id: nil)
|
|
127
244
|
text = [
|
|
128
245
|
"<b>ℹ️ #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
|
|
129
246
|
"<b>Source:</b> #{escape(source.to_s)}",
|
|
@@ -132,18 +249,57 @@ module TgErrorNotifier
|
|
|
132
249
|
context_block(context)
|
|
133
250
|
].compact.join("\n")
|
|
134
251
|
|
|
135
|
-
{
|
|
252
|
+
payload = {
|
|
136
253
|
chat_id: resolve(config.chat_id),
|
|
137
254
|
text: truncate(text),
|
|
138
255
|
parse_mode: "HTML",
|
|
139
256
|
disable_web_page_preview: true
|
|
140
257
|
}
|
|
258
|
+
payload[:message_thread_id] = thread_id if thread_id
|
|
259
|
+
keyboard = build_keyboard(
|
|
260
|
+
kind: :message,
|
|
261
|
+
message: message,
|
|
262
|
+
source: source,
|
|
263
|
+
context: context,
|
|
264
|
+
fingerprint: nil
|
|
265
|
+
)
|
|
266
|
+
payload[:reply_markup] = keyboard if keyboard
|
|
267
|
+
payload
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def clamp(value, limit)
|
|
271
|
+
text = value.to_s
|
|
272
|
+
text.length > limit ? "#{text[0...limit]}…" : text
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Обрезка уже экранированного текста: не оставляем хвост от «&»
|
|
276
|
+
def cut_escaped(text, limit)
|
|
277
|
+
return text if text.length <= limit
|
|
278
|
+
|
|
279
|
+
text[0...limit].sub(/&[#a-zA-Z0-9]*\z/, "")
|
|
141
280
|
end
|
|
142
281
|
|
|
143
282
|
def truncate(text)
|
|
144
283
|
return text if text.length <= MAX_MESSAGE_LENGTH
|
|
145
284
|
|
|
146
|
-
text
|
|
285
|
+
close_tags(cut_escaped(text, MAX_MESSAGE_LENGTH - 20)) + "\n...truncated"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Обрезка могла оставить незакрытый тег или его половину — Telegram
|
|
289
|
+
# на таком отвечает 400 и сообщение не доходит вовсе
|
|
290
|
+
def close_tags(text)
|
|
291
|
+
text = text.sub(/<[^>]*\z/, "")
|
|
292
|
+
|
|
293
|
+
HTML_TAGS.each do |tag|
|
|
294
|
+
unclosed = text.scan("<#{tag}>").size - text.scan("</#{tag}>").size
|
|
295
|
+
unclosed.times { text += "</#{tag}>" }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
text
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def strip_html(text)
|
|
302
|
+
CGI.unescapeHTML(text.to_s.gsub(/<[^>]*>/, ""))
|
|
147
303
|
end
|
|
148
304
|
|
|
149
305
|
def resolve(value)
|
|
@@ -22,6 +22,10 @@ module TgErrorNotifier
|
|
|
22
22
|
config.logger = options.logger unless options.logger.nil?
|
|
23
23
|
config.include_backtrace = options.include_backtrace unless options.include_backtrace.nil?
|
|
24
24
|
config.active_job_enabled = options.active_job_enabled unless options.active_job_enabled.nil?
|
|
25
|
+
config.grouping_enabled = options.grouping_enabled unless options.grouping_enabled.nil?
|
|
26
|
+
config.grouping_window = options.grouping_window unless options.grouping_window.nil?
|
|
27
|
+
config.topics_enabled = options.topics_enabled unless options.topics_enabled.nil?
|
|
28
|
+
config.topic_icon_color = options.topic_icon_color unless options.topic_icon_color.nil?
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module TgErrorNotifier
|
|
8
|
+
class TopicManager
|
|
9
|
+
ICON_COLOR_RED = 0xFB6F5F
|
|
10
|
+
ICON_COLOR_BLUE = 0x6FB9F0
|
|
11
|
+
MAX_TOPIC_NAME = 128
|
|
12
|
+
MAX_STORE_KEY = 200
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@condition = ConditionVariable.new
|
|
18
|
+
@topics = {} # grouping_key => message_thread_id
|
|
19
|
+
@creating = Set.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def thread_id_for(key, exception)
|
|
23
|
+
name = nil
|
|
24
|
+
store_key = "exception:#{key}"[0...MAX_STORE_KEY]
|
|
25
|
+
|
|
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
|
+
|
|
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)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
thread_id = create_topic(name)
|
|
47
|
+
|
|
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
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
thread_id
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
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
|
|
120
|
+
name.length > MAX_TOPIC_NAME ? "#{name[0...MAX_TOPIC_NAME - 1]}…" : name
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def topic_name(exception)
|
|
124
|
+
truncate_name("#{exception.class.name}: #{exception.message}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def create_topic(name, icon_color: nil)
|
|
128
|
+
token = resolve(@config.bot_token)
|
|
129
|
+
chat_id = resolve(@config.chat_id)
|
|
130
|
+
uri = URI("#{resolve(@config.api_base)}/bot#{token}/createForumTopic")
|
|
131
|
+
|
|
132
|
+
payload = {
|
|
133
|
+
chat_id: chat_id,
|
|
134
|
+
name: name,
|
|
135
|
+
icon_color: icon_color || @config.topic_icon_color || ICON_COLOR_RED
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
request = Net::HTTP::Post.new(uri)
|
|
139
|
+
request["Content-Type"] = "application/json"
|
|
140
|
+
request.body = payload.to_json
|
|
141
|
+
|
|
142
|
+
http = build_http(uri)
|
|
143
|
+
response = http.request(request)
|
|
144
|
+
|
|
145
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
146
|
+
data = JSON.parse(response.body)
|
|
147
|
+
data.dig("result", "message_thread_id")
|
|
148
|
+
else
|
|
149
|
+
log("createForumTopic failed: HTTP #{response.code} #{response.body}")
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
log("createForumTopic error: #{e.class}: #{e.message}")
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_http(uri)
|
|
158
|
+
http = if @config.proxy?
|
|
159
|
+
Net::HTTP.new(uri.host, uri.port, resolve(@config.proxy_addr), resolve(@config.proxy_port).to_i, resolve(@config.proxy_user), resolve(@config.proxy_pass))
|
|
160
|
+
else
|
|
161
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
162
|
+
end
|
|
163
|
+
http.use_ssl = uri.scheme == "https"
|
|
164
|
+
http.open_timeout = @config.open_timeout
|
|
165
|
+
http.read_timeout = @config.read_timeout
|
|
166
|
+
http
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def resolve(value)
|
|
170
|
+
value.respond_to?(:call) ? value.call : value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def log(message)
|
|
174
|
+
return unless @config.logger
|
|
175
|
+
@config.logger.error("[TgErrorNotifier::TopicManager] #{message}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/tg_error_notifier.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "rails"
|
|
4
4
|
require_relative "tg_error_notifier/version"
|
|
5
5
|
require_relative "tg_error_notifier/configuration"
|
|
6
|
+
require_relative "tg_error_notifier/grouper"
|
|
7
|
+
require_relative "tg_error_notifier/topic_manager"
|
|
6
8
|
require_relative "tg_error_notifier/notifier"
|
|
7
9
|
require_relative "tg_error_notifier/middleware"
|
|
8
10
|
require_relative "tg_error_notifier/subscriber"
|
|
@@ -28,8 +30,15 @@ module TgErrorNotifier
|
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
# API similar to Sentry.capture_message("text")
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
def reset!
|
|
40
|
+
@configuration = nil
|
|
41
|
+
@notifier = nil
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
private
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tg_error_notifier
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergei Ustinov
|
|
@@ -35,10 +35,12 @@ files:
|
|
|
35
35
|
- README.md
|
|
36
36
|
- lib/tg_error_notifier.rb
|
|
37
37
|
- lib/tg_error_notifier/configuration.rb
|
|
38
|
+
- lib/tg_error_notifier/grouper.rb
|
|
38
39
|
- lib/tg_error_notifier/middleware.rb
|
|
39
40
|
- lib/tg_error_notifier/notifier.rb
|
|
40
41
|
- lib/tg_error_notifier/railtie.rb
|
|
41
42
|
- lib/tg_error_notifier/subscriber.rb
|
|
43
|
+
- lib/tg_error_notifier/topic_manager.rb
|
|
42
44
|
- lib/tg_error_notifier/version.rb
|
|
43
45
|
homepage: https://github.com/sergeyustinov/TgErrorNotifier
|
|
44
46
|
licenses:
|