tg_error_notifier 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb51823201c7e9fef5a9a3d3f1ddb9ca52390d75cf256c1488f3b3f72ebc6d2b
4
- data.tar.gz: d778ac6a5c07212e617729878ad8dc9d05d338377d9788f57a44577b9d739804
3
+ metadata.gz: 03b6d8e79dd258e7487a99925017dc62d9fd7d66255af5e80c62aecff7b6638b
4
+ data.tar.gz: 062f6f0253004feb248c55e9cf2962244a7683c6d6771dac55f9a762a1c97bec
5
5
  SHA512:
6
- metadata.gz: c943e6433257b3d68fb0d4630156e3073084dfa82d90ef51c38d5db4b518f840abd2e211d0aacf893ed8734d70e893cb8872969ffbe284324893b54946882610
7
- data.tar.gz: 3f05a5989f3d5731eef6114b2e8acb3247ea97af1ee43d7ccc98d163f6496b5f22c55b6fed9fac1e31c5c3431dfe82919bb42c715f342c3b610552a4f6ed7849
6
+ metadata.gz: 8caa97214339f4f0af2683a40f7d3a705a80fd0ecb9dd65f7a242d97efb38b2cab5e284b5b4f2d2cff3bb0121034190b09f8ad87793529937e89a2624bf1bc3c
7
+ data.tar.gz: 8e546d58081bd0e50abae5bf375a7d9adac0ada19032fb44fbfe089fedb62fbaf55e0abe4b35489857716fa21352466c90224d36592faa43926d6fd578330b5b
data/README.md CHANGED
@@ -138,3 +138,50 @@ 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
+ ## 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.
@@ -23,7 +23,10 @@ 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
27
30
 
28
31
  def initialize
29
32
  @enabled = true
@@ -52,6 +55,25 @@ module TgErrorNotifier
52
55
  @grouping_window = 60
53
56
  @topics_enabled = false
54
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
55
77
  end
56
78
 
57
79
  def proxy?
@@ -46,6 +46,12 @@ module TgErrorNotifier
46
46
  end
47
47
  end
48
48
 
49
+ def rollback(key)
50
+ @mutex.synchronize do
51
+ @entries.delete(key)
52
+ end
53
+ end
54
+
49
55
  def grouping_key(exception)
50
56
  "#{exception.class.name}:#{normalize_message(exception.message)}"
51
57
  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,
@@ -108,7 +121,7 @@ module TgErrorNotifier
108
121
  ignored.include?(exception.class.name)
109
122
  end
110
123
 
111
- def send_payload(payload)
124
+ def send_payload(payload, retry_plain: true)
112
125
  token = resolve(config.bot_token)
113
126
  uri = URI("#{resolve(config.api_base)}/bot#{token}/sendMessage")
114
127
 
@@ -131,15 +144,29 @@ module TgErrorNotifier
131
144
  end
132
145
 
133
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
+
134
157
  { sent: false, status: :failed, reason: "telegram_api_error", code: response.code.to_i, body: response.body.to_s }
135
158
  end
136
159
 
137
- def build_payload(exception:, source:, context: {}, thread_id: nil, suppressed_count: 0)
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)
138
165
  parts = [
139
166
  "<b>🚨 #{escape(resolve(config.app_name).to_s)}: #{escape(resolve(config.environment).to_s)}</b>",
140
- "<b>Source:</b> #{escape(source.to_s)}",
167
+ "<b>Source:</b> #{escape(clamp(source, MAX_FIELD_LENGTH))}",
141
168
  "<b>Exception:</b> <code>#{escape(exception.class.name)}</code>",
142
- "<b>Message:</b> #{escape(exception.message.to_s)}"
169
+ "<b>Message:</b> #{escape(clamp(exception.message, MAX_FIELD_LENGTH))}"
143
170
  ]
144
171
 
145
172
  if suppressed_count > 0
@@ -148,12 +175,19 @@ module TgErrorNotifier
148
175
 
149
176
  parts << context_block(context)
150
177
 
151
- text = parts.compact.join("\n")
178
+ text = truncate(parts.compact.join("\n"))
152
179
 
153
180
  if config.include_backtrace && exception.backtrace
154
181
  lines = exception.backtrace.first(config.max_backtrace_lines)
155
- bt = escape(lines.join("\n"))
156
- text = "#{text}\n<b>Backtrace:</b>\n<pre>#{bt}</pre>"
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
157
191
  end
158
192
 
159
193
  payload = {
@@ -163,13 +197,46 @@ module TgErrorNotifier
163
197
  disable_web_page_preview: true
164
198
  }
165
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
166
208
  payload
167
209
  end
168
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
234
+ end
235
+
169
236
  def context_block(context)
170
237
  return nil if context.nil? || context.empty?
171
238
 
172
- formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(v.to_s)}" }
239
+ formatted = context.map { |k, v| "<b>#{escape(k.to_s)}:</b> #{escape(clamp(v, MAX_FIELD_LENGTH))}" }
173
240
  formatted.join("\n")
174
241
  end
175
242
 
@@ -189,13 +256,50 @@ module TgErrorNotifier
189
256
  disable_web_page_preview: true
190
257
  }
191
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
192
267
  payload
193
268
  end
194
269
 
270
+ def clamp(value, limit)
271
+ text = value.to_s
272
+ text.length > limit ? "#{text[0...limit]}…" : text
273
+ end
274
+
275
+ # Обрезка уже экранированного текста: не оставляем хвост от «&amp;»
276
+ def cut_escaped(text, limit)
277
+ return text if text.length <= limit
278
+
279
+ text[0...limit].sub(/&[#a-zA-Z0-9]*\z/, "")
280
+ end
281
+
195
282
  def truncate(text)
196
283
  return text if text.length <= MAX_MESSAGE_LENGTH
197
284
 
198
- text[0...MAX_MESSAGE_LENGTH] + "\n...truncated"
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(/<[^>]*>/, ""))
199
303
  end
200
304
 
201
305
  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(topic_name(exception))
46
+ thread_id = create_topic(name)
23
47
 
24
- if thread_id
25
- @mutex.synchronize { @topics[key] = thread_id }
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 topic_name(exception)
34
- name = "#{exception.class.name}: #{exception.message}"
35
- name = name.gsub(/\s+/, " ").strip
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 create_topic(name)
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TgErrorNotifier
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -30,8 +30,10 @@ module TgErrorNotifier
30
30
  end
31
31
 
32
32
  # API similar to Sentry.capture_message("text")
33
- def capture_message(message, level: :info, source: "manual", context: {})
34
- notifier.notify_message(message: message, level: level, source: source, context: context)
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)
35
37
  end
36
38
 
37
39
  def reset!
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei Ustinov