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 +4 -4
- data/README.md +36 -0
- data/lib/tg_error_notifier/configuration.rb +21 -1
- data/lib/tg_error_notifier/grouper.rb +68 -0
- data/lib/tg_error_notifier/notifier.rb +68 -12
- 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
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -75,7 +116,11 @@ module TgErrorNotifier
|
|
|
75
116
|
request["Content-Type"] = "application/json"
|
|
76
117
|
request.body = payload.to_json
|
|
77
118
|
|
|
78
|
-
http =
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
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:
|