tg_error_notifier 0.1.1 → 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: 990692433d408163d933c9efad9d4ddfc2db0993635f61070acc0d5435df309b
4
- data.tar.gz: 45d8c5f03c584670bc215eb0b683f460787069a8cde044375aa4120d33ba19d9
3
+ metadata.gz: eb51823201c7e9fef5a9a3d3f1ddb9ca52390d75cf256c1488f3b3f72ebc6d2b
4
+ data.tar.gz: d778ac6a5c07212e617729878ad8dc9d05d338377d9788f57a44577b9d739804
5
5
  SHA512:
6
- metadata.gz: 73993633c8eb9a88aeccb9d53b70c423f9dafd85eb36a72fcece8b31e5b3ce968b81d41303c10355da9c5a6997c298126fe105c65a39e721546b487629c13f33
7
- data.tar.gz: 450edb567628c5559b69eacc55c504fbced183b643312583937075a39d99298bd64a10f64c534e8470b5057e02c8b0c8486c1d62bbf1f0a51aa319aaaaed3e1c
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
@@ -15,7 +15,15 @@ module TgErrorNotifier
15
15
  :read_timeout,
16
16
  :logger,
17
17
  :include_backtrace,
18
- :active_job_enabled
18
+ :active_job_enabled,
19
+ :proxy_addr,
20
+ :proxy_port,
21
+ :proxy_user,
22
+ :proxy_pass,
23
+ :grouping_enabled,
24
+ :grouping_window,
25
+ :topics_enabled,
26
+ :topic_icon_color
19
27
 
20
28
  def initialize
21
29
  @enabled = true
@@ -36,6 +44,18 @@ module TgErrorNotifier
36
44
  @logger = nil
37
45
  @include_backtrace = true
38
46
  @active_job_enabled = true
47
+ @proxy_addr = nil
48
+ @proxy_port = nil
49
+ @proxy_user = nil
50
+ @proxy_pass = nil
51
+ @grouping_enabled = false
52
+ @grouping_window = 60
53
+ @topics_enabled = false
54
+ @topic_icon_color = nil
55
+ end
56
+
57
+ def proxy?
58
+ proxy_addr.present? && proxy_port.present?
39
59
  end
40
60
  end
41
61
  end
@@ -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)
@@ -75,7 +116,11 @@ module TgErrorNotifier
75
116
  request["Content-Type"] = "application/json"
76
117
  request.body = payload.to_json
77
118
 
78
- http = Net::HTTP.new(uri.host, uri.port)
119
+ http = if config.proxy?
120
+ 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))
121
+ else
122
+ Net::HTTP.new(uri.host, uri.port)
123
+ end
79
124
  http.use_ssl = uri.scheme == "https"
80
125
  http.open_timeout = config.open_timeout
81
126
  http.read_timeout = config.read_timeout
@@ -89,14 +134,21 @@ module TgErrorNotifier
89
134
  { sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
90
135
  end
91
136
 
92
- def build_payload(exception:, source:, context: {})
93
- text = [
137
+ def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0)
138
+ parts = [
94
139
  "<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
95
140
  "<b>Source:</b> #{escape(source.to_s)}",
96
141
  "<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
97
- "<b>Message:</b> #{escape(exception.message.to_s)}",
98
- context_block(context)
99
- ].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")
100
152
 
101
153
  if config.include_backtrace && exception.backtrace
102
154
  lines = exception.backtrace.first(config.max_backtrace_lines)
@@ -104,12 +156,14 @@ module TgErrorNotifier
104
156
  text = "#{text}\n<b>Backtrace:</b>\n<pre>#{bt}</pre>"
105
157
  end
106
158
 
107
- {
159
+ payload = {
108
160
  chat_id: resolve(config.chat_id),
109
161
  text: truncate(text),
110
162
  parse_mode: "HTML",
111
163
  disable_web_page_preview: true
112
164
  }
165
+ payload[:message_thread_id] = thread_id if thread_id
166
+ payload
113
167
  end
114
168
 
115
169
  def context_block(context)
@@ -119,7 +173,7 @@ module TgErrorNotifier
119
173
  formatted.join("\n")
120
174
  end
121
175
 
122
- def build_message_payload(message:, level:, source:, context: {})
176
+ def build_message_payload(message:, level:, source:, context: {}, thread_id: nil)
123
177
  text = [
124
178
  "<b>ℹ️ #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
125
179
  "<b>Source:</b> #{escape(source.to_s)}",
@@ -128,12 +182,14 @@ module TgErrorNotifier
128
182
  context_block(context)
129
183
  ].compact.join("\n")
130
184
 
131
- {
185
+ payload = {
132
186
  chat_id: resolve(config.chat_id),
133
187
  text: truncate(text),
134
188
  parse_mode: "HTML",
135
189
  disable_web_page_preview: true
136
190
  }
191
+ payload[:message_thread_id] = thread_id if thread_id
192
+ payload
137
193
  end
138
194
 
139
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.1"
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.1
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: