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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 785aa28f5a62476e0f827430090ff0dad300edd8277b54672b88f3b89a7ee730
4
- data.tar.gz: 680dea5d7f8440e07b41e5df415483e35a0aed5b8adbf0b5f9169302c1306422
3
+ metadata.gz: eb51823201c7e9fef5a9a3d3f1ddb9ca52390d75cf256c1488f3b3f72ebc6d2b
4
+ data.tar.gz: d778ac6a5c07212e617729878ad8dc9d05d338377d9788f57a44577b9d739804
5
5
  SHA512:
6
- metadata.gz: 001f1b8fb2fb679ea77acb1d1761f6fe67f3736b08be8be1fa5c9541e45818b137a37b9c39ea4ce7d59ede8cc654bdd9689be76959a4364253add6fccb4b0535
7
- data.tar.gz: 58ccd0356a6699a0fafff07bd90d461a8a3654db0d00813faa5a3fc7b826593f737924756127fa809d7bf1be65eb1420460b7731cb9cf46349d19160f10291cc
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
- payload = build_payload(exception: exception, source: source, context: context)
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(message: message, level: level, source: source, context: context)
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
- text = [
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
- context_block(context)
103
- ].compact.join("\n")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TgErrorNotifier
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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.1.2
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: