tg_error_notifier 0.1.2 → 0.2.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 +36 -0
- data/lib/tg_error_notifier/configuration.rb +9 -1
- data/lib/tg_error_notifier/grouper.rb +68 -0
- data/lib/tg_error_notifier/notifier.rb +63 -11
- data/lib/tg_error_notifier/railtie.rb +4 -0
- data/lib/tg_error_notifier/topic_manager.rb +90 -0
- data/lib/tg_error_notifier/version.rb +1 -1
- data/lib/tg_error_notifier.rb +7 -0
- 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: eb51823201c7e9fef5a9a3d3f1ddb9ca52390d75cf256c1488f3b3f72ebc6d2b
|
|
4
|
+
data.tar.gz: d778ac6a5c07212e617729878ad8dc9d05d338377d9788f57a44577b9d739804
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c943e6433257b3d68fb0d4630156e3073084dfa82d90ef51c38d5db4b518f840abd2e211d0aacf893ed8734d70e893cb8872969ffbe284324893b54946882610
|
|
7
|
+
data.tar.gz: 3f05a5989f3d5731eef6114b2e8acb3247ea97af1ee43d7ccc98d163f6496b5f22c55b6fed9fac1e31c5c3431dfe82919bb42c715f342c3b610552a4f6ed7849
|
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
|
|
@@ -19,7 +19,11 @@ 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
|
|
23
27
|
|
|
24
28
|
def initialize
|
|
25
29
|
@enabled = true
|
|
@@ -44,6 +48,10 @@ module TgErrorNotifier
|
|
|
44
48
|
@proxy_port = nil
|
|
45
49
|
@proxy_user = nil
|
|
46
50
|
@proxy_pass = nil
|
|
51
|
+
@grouping_enabled = false
|
|
52
|
+
@grouping_window = 60
|
|
53
|
+
@topics_enabled = false
|
|
54
|
+
@topic_icon_color = nil
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
def proxy?
|
|
@@ -0,0 +1,68 @@
|
|
|
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 grouping_key(exception)
|
|
50
|
+
"#{exception.class.name}:#{normalize_message(exception.message)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalize_message(message)
|
|
56
|
+
msg = message.to_s
|
|
57
|
+
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>")
|
|
58
|
+
msg = msg.gsub(/\b\d{4,}\b/, "<ID>")
|
|
59
|
+
msg = msg.gsub(/#<\w+:0x[0-9a-f]+>/i, "#<Object>")
|
|
60
|
+
msg.strip
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def lazy_cleanup!(now)
|
|
64
|
+
cutoff = now - 3600
|
|
65
|
+
@entries.delete_if { |_, e| e.last_sent_at < cutoff }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -23,21 +23,54 @@ module TgErrorNotifier
|
|
|
23
23
|
return { sent: false, status: :skipped, reason: "ignored_exception" }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
key = nil
|
|
27
|
+
thread_id = nil
|
|
28
|
+
suppressed_count = 0
|
|
29
|
+
|
|
30
|
+
if config.topics_enabled || config.grouping_enabled
|
|
31
|
+
key = grouper.grouping_key(exception)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if config.topics_enabled
|
|
35
|
+
thread_id = topic_manager.thread_id_for(key, exception)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if config.grouping_enabled
|
|
39
|
+
result = grouper.process(key: key, thread_id: thread_id)
|
|
40
|
+
if result[:action] == :suppress
|
|
41
|
+
return { sent: false, status: :suppressed }
|
|
42
|
+
end
|
|
43
|
+
suppressed_count = result[:count]
|
|
44
|
+
thread_id = result[:thread_id] || thread_id
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
payload = build_payload(
|
|
48
|
+
exception: exception,
|
|
49
|
+
source: source,
|
|
50
|
+
context: context,
|
|
51
|
+
thread_id: thread_id,
|
|
52
|
+
suppressed_count: suppressed_count
|
|
53
|
+
)
|
|
27
54
|
send_payload(payload)
|
|
28
55
|
rescue StandardError => e
|
|
29
56
|
log("notify failed: #{e.class}: #{e.message}")
|
|
30
57
|
{ sent: false, status: :failed, reason: e.class.name, error: e.message }
|
|
31
58
|
end
|
|
32
59
|
|
|
33
|
-
def notify_message(message:, level:, source:, context: {})
|
|
60
|
+
def notify_message(message:, level:, source:, context: {}, thread_id: nil)
|
|
34
61
|
enabled_check = enabled_status
|
|
35
62
|
unless enabled_check[:enabled]
|
|
36
63
|
log("skipped: #{enabled_check[:reason]}")
|
|
37
64
|
return { sent: false, status: :skipped, reason: enabled_check[:reason] }
|
|
38
65
|
end
|
|
39
66
|
|
|
40
|
-
payload = build_message_payload(
|
|
67
|
+
payload = build_message_payload(
|
|
68
|
+
message: message,
|
|
69
|
+
level: level,
|
|
70
|
+
source: source,
|
|
71
|
+
context: context,
|
|
72
|
+
thread_id: thread_id
|
|
73
|
+
)
|
|
41
74
|
send_payload(payload)
|
|
42
75
|
rescue StandardError => e
|
|
43
76
|
log("notify_message failed: #{e.class}: #{e.message}")
|
|
@@ -48,6 +81,14 @@ module TgErrorNotifier
|
|
|
48
81
|
|
|
49
82
|
attr_reader :config
|
|
50
83
|
|
|
84
|
+
def grouper
|
|
85
|
+
@grouper ||= Grouper.new(window: config.grouping_window)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def topic_manager
|
|
89
|
+
@topic_manager ||= TopicManager.new(config)
|
|
90
|
+
end
|
|
91
|
+
|
|
51
92
|
def enabled_status
|
|
52
93
|
return { enabled: false, reason: "disabled" } unless resolve(config.enabled)
|
|
53
94
|
if config.ignored_environments.include?(resolve(config.environment).to_s)
|
|
@@ -93,14 +134,21 @@ module TgErrorNotifier
|
|
|
93
134
|
{ sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
|
|
94
135
|
end
|
|
95
136
|
|
|
96
|
-
def build_payload(exception:, source:, context: {})
|
|
97
|
-
|
|
137
|
+
def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0)
|
|
138
|
+
parts = [
|
|
98
139
|
"<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
|
|
99
140
|
"<b>Source:</b> #{escape(source.to_s)}",
|
|
100
141
|
"<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
|
|
101
|
-
"<b>Message:</b> #{escape(exception.message.to_s)}"
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
"<b>Message:</b> #{escape(exception.message.to_s)}"
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
if suppressed_count > 0
|
|
146
|
+
parts << "<b>🔁 +#{suppressed_count} more in last #{config.grouping_window}s</b>"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
parts << context_block(context)
|
|
150
|
+
|
|
151
|
+
text = parts.compact.join("\n")
|
|
104
152
|
|
|
105
153
|
if config.include_backtrace && exception.backtrace
|
|
106
154
|
lines = exception.backtrace.first(config.max_backtrace_lines)
|
|
@@ -108,12 +156,14 @@ module TgErrorNotifier
|
|
|
108
156
|
text = "#{text}\n<b>Backtrace:</b>\n<pre>#{bt}</pre>"
|
|
109
157
|
end
|
|
110
158
|
|
|
111
|
-
{
|
|
159
|
+
payload = {
|
|
112
160
|
chat_id: resolve(config.chat_id),
|
|
113
161
|
text: truncate(text),
|
|
114
162
|
parse_mode: "HTML",
|
|
115
163
|
disable_web_page_preview: true
|
|
116
164
|
}
|
|
165
|
+
payload[:message_thread_id] = thread_id if thread_id
|
|
166
|
+
payload
|
|
117
167
|
end
|
|
118
168
|
|
|
119
169
|
def context_block(context)
|
|
@@ -123,7 +173,7 @@ module TgErrorNotifier
|
|
|
123
173
|
formatted.join("\n")
|
|
124
174
|
end
|
|
125
175
|
|
|
126
|
-
def build_message_payload(message:, level:, source:, context: {})
|
|
176
|
+
def build_message_payload(message:, level:, source:, context: {}, thread_id: nil)
|
|
127
177
|
text = [
|
|
128
178
|
"<b>ℹ️ #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
|
|
129
179
|
"<b>Source:</b> #{escape(source.to_s)}",
|
|
@@ -132,12 +182,14 @@ module TgErrorNotifier
|
|
|
132
182
|
context_block(context)
|
|
133
183
|
].compact.join("\n")
|
|
134
184
|
|
|
135
|
-
{
|
|
185
|
+
payload = {
|
|
136
186
|
chat_id: resolve(config.chat_id),
|
|
137
187
|
text: truncate(text),
|
|
138
188
|
parse_mode: "HTML",
|
|
139
189
|
disable_web_page_preview: true
|
|
140
190
|
}
|
|
191
|
+
payload[:message_thread_id] = thread_id if thread_id
|
|
192
|
+
payload
|
|
141
193
|
end
|
|
142
194
|
|
|
143
195
|
def truncate(text)
|
|
@@ -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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module TgErrorNotifier
|
|
7
|
+
class TopicManager
|
|
8
|
+
ICON_COLOR_RED = 0xFB6F5F
|
|
9
|
+
MAX_TOPIC_NAME = 128
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@topics = {} # grouping_key => message_thread_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def thread_id_for(key, exception)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
return @topics[key] if @topics.key?(key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
thread_id = create_topic(topic_name(exception))
|
|
23
|
+
|
|
24
|
+
if thread_id
|
|
25
|
+
@mutex.synchronize { @topics[key] = thread_id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
thread_id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def topic_name(exception)
|
|
34
|
+
name = "#{exception.class.name}: #{exception.message}"
|
|
35
|
+
name = name.gsub(/\s+/, " ").strip
|
|
36
|
+
name.length > MAX_TOPIC_NAME ? "#{name[0...MAX_TOPIC_NAME - 1]}…" : name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create_topic(name)
|
|
40
|
+
token = resolve(@config.bot_token)
|
|
41
|
+
chat_id = resolve(@config.chat_id)
|
|
42
|
+
uri = URI("#{resolve(@config.api_base)}/bot#{token}/createForumTopic")
|
|
43
|
+
|
|
44
|
+
payload = {
|
|
45
|
+
chat_id: chat_id,
|
|
46
|
+
name: name,
|
|
47
|
+
icon_color: @config.topic_icon_color || ICON_COLOR_RED
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
request = Net::HTTP::Post.new(uri)
|
|
51
|
+
request["Content-Type"] = "application/json"
|
|
52
|
+
request.body = payload.to_json
|
|
53
|
+
|
|
54
|
+
http = build_http(uri)
|
|
55
|
+
response = http.request(request)
|
|
56
|
+
|
|
57
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
58
|
+
data = JSON.parse(response.body)
|
|
59
|
+
data.dig("result", "message_thread_id")
|
|
60
|
+
else
|
|
61
|
+
log("createForumTopic failed: HTTP #{response.code} #{response.body}")
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
log("createForumTopic error: #{e.class}: #{e.message}")
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_http(uri)
|
|
70
|
+
http = if @config.proxy?
|
|
71
|
+
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))
|
|
72
|
+
else
|
|
73
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
74
|
+
end
|
|
75
|
+
http.use_ssl = uri.scheme == "https"
|
|
76
|
+
http.open_timeout = @config.open_timeout
|
|
77
|
+
http.read_timeout = @config.read_timeout
|
|
78
|
+
http
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resolve(value)
|
|
82
|
+
value.respond_to?(:call) ? value.call : value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def log(message)
|
|
86
|
+
return unless @config.logger
|
|
87
|
+
@config.logger.error("[TgErrorNotifier::TopicManager] #{message}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
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"
|
|
@@ -32,6 +34,11 @@ module TgErrorNotifier
|
|
|
32
34
|
notifier.notify_message(message: message, level: level, source: source, context: context)
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
def reset!
|
|
38
|
+
@configuration = nil
|
|
39
|
+
@notifier = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
private
|
|
36
43
|
|
|
37
44
|
def notifier
|
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.2.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:
|