rails-informant 0.2.1 → 0.3.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/app/jobs/rails_informant/notify_job.rb +15 -4
- data/lib/rails_informant/configuration.rb +13 -2
- data/lib/rails_informant/context_builder.rb +14 -6
- data/lib/rails_informant/current.rb +1 -1
- data/lib/rails_informant/error_recorder.rb +12 -7
- data/lib/rails_informant/notifiers/circuit_breaker.rb +40 -0
- data/lib/rails_informant/notifiers/slack.rb +45 -38
- data/lib/rails_informant.rb +3 -1
- metadata +2 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71f2d05d2caf43bbaab7641118f09b86cafb4949d9f4c2be262a4773f6a88eab
|
|
4
|
+
data.tar.gz: 8f3de02212167bfcbbb412d653af259eeed2a0e5907f75b8e1191d724faf112a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7c2c8599fe28a7adef712a0cc4f989cdae99465a18b2120055c049dbf77136e243dee9f382fc8dee0ed6d6c7d2d6173a18b1e42a750d031a31268fd377758ff
|
|
7
|
+
data.tar.gz: 59729b6bd6f564023810acd041e3d71f7cf930e8bb65dd88833495b6392d3eb979aa920b80650d4ffe6833f91675743b928a1162ecad4bf10c75c894759843ad
|
|
@@ -3,9 +3,16 @@ module RailsInformant
|
|
|
3
3
|
queue_as :default
|
|
4
4
|
|
|
5
5
|
retry_on ::Net::OpenTimeout, ::Net::ReadTimeout, ::SocketError, RailsInformant::NotifierError, attempts: 5, wait: 15.seconds
|
|
6
|
-
discard_on ActiveRecord::RecordNotFound
|
|
6
|
+
discard_on ActiveRecord::RecordNotFound,
|
|
7
|
+
ArgumentError,
|
|
8
|
+
Errno::ECONNREFUSED,
|
|
9
|
+
Errno::ECONNRESET,
|
|
10
|
+
Errno::EHOSTUNREACH,
|
|
11
|
+
OpenSSL::SSL::SSLError
|
|
7
12
|
|
|
8
13
|
def perform(group)
|
|
14
|
+
Current.delivering_notification = true
|
|
15
|
+
|
|
9
16
|
occurrence = group.occurrences.order(created_at: :desc).first
|
|
10
17
|
failures = []
|
|
11
18
|
|
|
@@ -17,12 +24,16 @@ module RailsInformant
|
|
|
17
24
|
failures << e
|
|
18
25
|
end
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
if failures.empty?
|
|
28
|
+
group.update_column(:last_notified_at, Time.current)
|
|
29
|
+
Notifiers::CircuitBreaker.record_success
|
|
30
|
+
else
|
|
31
|
+
Notifiers::CircuitBreaker.record_failure
|
|
23
32
|
failures.drop(1).each { |e| Rails.logger.error "[RailsInformant] Notifier failed: #{e.class}: #{e.message}" }
|
|
24
33
|
raise failures.first
|
|
25
34
|
end
|
|
35
|
+
ensure
|
|
36
|
+
Current.delivering_notification = false
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
private
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
module RailsInformant
|
|
2
2
|
class Configuration
|
|
3
|
-
attr_accessor :
|
|
4
|
-
:capture_errors,
|
|
3
|
+
attr_accessor :capture_errors,
|
|
5
4
|
:capture_user_email,
|
|
5
|
+
:api_token,
|
|
6
6
|
:ignored_exceptions,
|
|
7
7
|
:retention_days,
|
|
8
8
|
:slack_webhook_url,
|
|
9
9
|
:webhook_url
|
|
10
10
|
|
|
11
|
+
attr_writer :app_name
|
|
12
|
+
|
|
11
13
|
def initialize
|
|
12
14
|
@api_token = ENV["INFORMANT_API_TOKEN"]
|
|
15
|
+
@app_name = ENV["INFORMANT_APP_NAME"]
|
|
13
16
|
@capture_errors = ENV.fetch("INFORMANT_CAPTURE_ERRORS", "true") != "false"
|
|
14
17
|
@capture_user_email = false
|
|
15
18
|
@custom_notifiers = []
|
|
@@ -19,6 +22,10 @@ module RailsInformant
|
|
|
19
22
|
@webhook_url = ENV["INFORMANT_WEBHOOK_URL"]
|
|
20
23
|
end
|
|
21
24
|
|
|
25
|
+
def app_name
|
|
26
|
+
@app_name.presence || detect_app_name
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
# Returns all notifiers: built-in (auto-registered from config) + custom.
|
|
23
30
|
def notifiers
|
|
24
31
|
@_notifiers ||= built_in_notifiers + @custom_notifiers
|
|
@@ -36,6 +43,10 @@ module RailsInformant
|
|
|
36
43
|
|
|
37
44
|
private
|
|
38
45
|
|
|
46
|
+
def detect_app_name
|
|
47
|
+
Rails.application&.class&.module_parent_name.presence || "App"
|
|
48
|
+
end
|
|
49
|
+
|
|
39
50
|
def built_in_notifiers
|
|
40
51
|
[
|
|
41
52
|
(Notifiers::Slack.new if slack_webhook_url.present?),
|
|
@@ -50,15 +50,23 @@ module RailsInformant
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def build_environment_context
|
|
53
|
-
@_static_env ||=
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
@_static_env ||= begin
|
|
54
|
+
hostname = Socket.gethostname
|
|
55
|
+
env = {
|
|
56
|
+
rails_env: Rails.env.to_s,
|
|
57
|
+
ruby_version: RUBY_VERSION,
|
|
58
|
+
rails_version: Rails::VERSION::STRING
|
|
59
|
+
}
|
|
60
|
+
env[:hostname] = hostname unless hostname == "localhost"
|
|
61
|
+
env.freeze
|
|
62
|
+
end
|
|
59
63
|
@_static_env.merge(pid: Process.pid)
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
def reset!
|
|
67
|
+
@_static_env = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
def group_attributes(error, severity:, context:, env:, now:)
|
|
63
71
|
{
|
|
64
72
|
error_class: error.class.name, severity:,
|
|
@@ -6,18 +6,27 @@ module RailsInformant
|
|
|
6
6
|
class << self
|
|
7
7
|
def record(error, severity: "error", context: {}, source: nil, env: nil)
|
|
8
8
|
return unless RailsInformant.initialized?
|
|
9
|
+
return if self_caused_error?(error)
|
|
10
|
+
|
|
9
11
|
now = Time.current
|
|
10
12
|
attrs = ContextBuilder.group_attributes(error, severity:, context:, env:, now:)
|
|
11
13
|
group = ErrorGroup.find_or_create_for(Fingerprint.generate(error), attrs)
|
|
12
14
|
group.detect_regression!
|
|
13
15
|
store_occurrence(group, error, env:, context:) if should_store_occurrence?(group)
|
|
14
|
-
notify(group
|
|
16
|
+
notify(group)
|
|
15
17
|
rescue StandardError => e
|
|
16
18
|
Rails.logger.error "[RailsInformant] Capture failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
private
|
|
20
22
|
|
|
23
|
+
# Detect errors caused by RailsInformant itself to prevent feedback loops.
|
|
24
|
+
# Primary: CurrentAttributes flag set during notification delivery.
|
|
25
|
+
# Fallback: backtrace heuristic for cross-execution scenarios (e.g. queue retries).
|
|
26
|
+
def self_caused_error?(error)
|
|
27
|
+
Current.delivering_notification || error.backtrace&.any? { it.include?("rails_informant/notifiers") }
|
|
28
|
+
end
|
|
29
|
+
|
|
21
30
|
def should_store_occurrence?(group)
|
|
22
31
|
return true if group.total_occurrences <= 1
|
|
23
32
|
return true unless group.last_occurrence_stored_at
|
|
@@ -38,15 +47,11 @@ module RailsInformant
|
|
|
38
47
|
keep_ids = group.occurrences.order(created_at: :desc).limit(MAX_OCCURRENCES_PER_GROUP).select(:id)
|
|
39
48
|
Occurrence.where(error_group_id: group.id).where.not(id: keep_ids).delete_all
|
|
40
49
|
end
|
|
41
|
-
def notify(group
|
|
42
|
-
return if
|
|
50
|
+
def notify(group)
|
|
51
|
+
return if Notifiers::CircuitBreaker.open?
|
|
43
52
|
return unless RailsInformant.config.notifiers.any? { it.should_notify?(group) }
|
|
44
53
|
RailsInformant::NotifyJob.perform_later group
|
|
45
54
|
end
|
|
46
|
-
|
|
47
|
-
def notifier_error?(error)
|
|
48
|
-
error.backtrace&.any? { it.include?("rails_informant/notifiers") }
|
|
49
|
-
end
|
|
50
55
|
end
|
|
51
56
|
end
|
|
52
57
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
module Notifiers
|
|
3
|
+
class CircuitBreaker
|
|
4
|
+
FAILURE_THRESHOLD = 5
|
|
5
|
+
RESET_TIMEOUT = 10.minutes
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def open?
|
|
9
|
+
return false if failure_count < FAILURE_THRESHOLD
|
|
10
|
+
|
|
11
|
+
last_failure_at > RESET_TIMEOUT.ago
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record_failure
|
|
15
|
+
@failure_count = failure_count + 1
|
|
16
|
+
@last_failure_at = Time.current
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record_success
|
|
20
|
+
reset!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
@failure_count = 0
|
|
25
|
+
@last_failure_at = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def failure_count
|
|
31
|
+
@failure_count || 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def last_failure_at
|
|
35
|
+
@last_failure_at || Time.at(0)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -17,39 +17,51 @@ module RailsInformant
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def build_payload(error_group, occurrence)
|
|
20
|
-
regression_tag = regression?(error_group) ? " [REGRESSION]" : ""
|
|
21
|
-
|
|
22
20
|
{
|
|
21
|
+
text: "#{error_group.error_class}: #{error_group.message.to_s.truncate(200)}",
|
|
23
22
|
blocks: [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
type: "plain_text",
|
|
28
|
-
text: "#{error_group.error_class}#{regression_tag}",
|
|
29
|
-
emoji: true
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
type: "section",
|
|
34
|
-
fields: [
|
|
35
|
-
{ type: "mrkdwn", text: "*Message:*\n#{error_group.message.to_s.truncate(200)}" },
|
|
36
|
-
{ type: "mrkdwn", text: "*Status:*\n#{error_group.status}" },
|
|
37
|
-
{ type: "mrkdwn", text: "*Occurrences:*\n#{error_group.total_occurrences}" },
|
|
38
|
-
{ type: "mrkdwn", text: "*First seen:*\n#{error_group.first_seen_at&.iso8601}" }
|
|
39
|
-
]
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
type: "section",
|
|
43
|
-
fields: [
|
|
44
|
-
location_field(error_group),
|
|
45
|
-
{ type: "mrkdwn", text: "*Severity:*\n#{error_group.severity}" }
|
|
46
|
-
].compact
|
|
47
|
-
},
|
|
23
|
+
header_block(error_group, occurrence),
|
|
24
|
+
error_class_block(error_group),
|
|
25
|
+
fields_block(error_group),
|
|
48
26
|
context_block(occurrence)
|
|
49
27
|
].compact
|
|
50
28
|
}
|
|
51
29
|
end
|
|
52
30
|
|
|
31
|
+
def header_block(error_group, occurrence)
|
|
32
|
+
env = occurrence&.environment_context&.dig("rails_env") || Rails.env
|
|
33
|
+
regression_tag = regression?(error_group) ? " [REGRESSION]" : ""
|
|
34
|
+
text = "🚨 #{RailsInformant.app_name} · #{env}#{regression_tag}".truncate(150)
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
type: "header",
|
|
38
|
+
text: { type: "plain_text", text:, emoji: true }
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def error_class_block(error_group)
|
|
43
|
+
{
|
|
44
|
+
type: "section",
|
|
45
|
+
text: {
|
|
46
|
+
type: "mrkdwn",
|
|
47
|
+
text: "*#{error_group.error_class}*\n#{error_group.message.to_s.truncate(200)}"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fields_block(error_group)
|
|
53
|
+
{
|
|
54
|
+
type: "section",
|
|
55
|
+
fields: [
|
|
56
|
+
{ type: "mrkdwn", text: "*Status:*\n#{error_group.status}" },
|
|
57
|
+
{ type: "mrkdwn", text: "*Occurrences:*\n#{error_group.total_occurrences}" },
|
|
58
|
+
{ type: "mrkdwn", text: "*First seen:*\n#{error_group.first_seen_at&.iso8601}" },
|
|
59
|
+
{ type: "mrkdwn", text: "*Severity:*\n#{error_group.severity}" },
|
|
60
|
+
location_field(error_group)
|
|
61
|
+
].compact
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
53
65
|
def location_field(error_group)
|
|
54
66
|
location = error_group.controller_action || error_group.job_class || error_group.first_backtrace_line
|
|
55
67
|
return unless location
|
|
@@ -58,19 +70,14 @@ module RailsInformant
|
|
|
58
70
|
end
|
|
59
71
|
|
|
60
72
|
def context_block(occurrence)
|
|
61
|
-
return unless occurrence
|
|
62
|
-
|
|
63
|
-
elements = []
|
|
64
|
-
if occurrence.git_sha
|
|
65
|
-
elements << { type: "mrkdwn", text: "Deploy: `#{occurrence.git_sha[0, 7]}`" }
|
|
66
|
-
end
|
|
67
|
-
if occurrence.environment_context&.dig("hostname")
|
|
68
|
-
elements << { type: "mrkdwn", text: "Host: `#{occurrence.environment_context["hostname"]}`" }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
return if elements.empty?
|
|
73
|
+
return unless occurrence&.git_sha
|
|
72
74
|
|
|
73
|
-
{
|
|
75
|
+
{
|
|
76
|
+
type: "context",
|
|
77
|
+
elements: [
|
|
78
|
+
{ type: "mrkdwn", text: "Deploy: `#{occurrence.git_sha[0, 7]}`" }
|
|
79
|
+
]
|
|
80
|
+
}
|
|
74
81
|
end
|
|
75
82
|
end
|
|
76
83
|
end
|
data/lib/rails_informant.rb
CHANGED
|
@@ -47,6 +47,7 @@ module RailsInformant
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
module Notifiers
|
|
50
|
+
autoload :CircuitBreaker, "rails_informant/notifiers/circuit_breaker"
|
|
50
51
|
autoload :NotificationPolicy, "rails_informant/notifiers/notification_policy"
|
|
51
52
|
autoload :Slack, "rails_informant/notifiers/slack"
|
|
52
53
|
autoload :Webhook, "rails_informant/notifiers/webhook"
|
|
@@ -56,7 +57,8 @@ module RailsInformant
|
|
|
56
57
|
self.config = Configuration.new
|
|
57
58
|
|
|
58
59
|
class << self
|
|
59
|
-
delegate :
|
|
60
|
+
delegate :app_name,
|
|
61
|
+
:api_token,
|
|
60
62
|
:capture_errors,
|
|
61
63
|
:capture_user_email,
|
|
62
64
|
:ignored_exceptions,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-informant
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel López Prat
|
|
@@ -162,6 +162,7 @@ files:
|
|
|
162
162
|
- lib/rails_informant/mcp/tools/verify_pending_fixes.rb
|
|
163
163
|
- lib/rails_informant/middleware/error_capture.rb
|
|
164
164
|
- lib/rails_informant/middleware/rescued_exception_interceptor.rb
|
|
165
|
+
- lib/rails_informant/notifiers/circuit_breaker.rb
|
|
165
166
|
- lib/rails_informant/notifiers/notification_policy.rb
|
|
166
167
|
- lib/rails_informant/notifiers/slack.rb
|
|
167
168
|
- lib/rails_informant/notifiers/webhook.rb
|
|
@@ -176,16 +177,6 @@ metadata:
|
|
|
176
177
|
changelog_uri: https://github.com/6temes/rails-informant/releases
|
|
177
178
|
rubygems_mfa_required: 'true'
|
|
178
179
|
source_code_uri: https://github.com/6temes/rails-informant
|
|
179
|
-
post_install_message: |2+
|
|
180
|
-
|
|
181
|
-
Rails Informant: SKILL.md has been simplified — workflow guidance now
|
|
182
|
-
auto-updates via MCP server instructions. Regenerate your skill file:
|
|
183
|
-
|
|
184
|
-
bin/rails generate rails_informant:skill
|
|
185
|
-
|
|
186
|
-
This is a one-time migration. Future gem updates will not require
|
|
187
|
-
regenerating SKILL.md.
|
|
188
|
-
|
|
189
180
|
rdoc_options: []
|
|
190
181
|
require_paths:
|
|
191
182
|
- lib
|
|
@@ -204,4 +195,3 @@ rubygems_version: 4.0.3
|
|
|
204
195
|
specification_version: 4
|
|
205
196
|
summary: Self-hosted error monitoring for Rails with MCP server for agentic workflows
|
|
206
197
|
test_files: []
|
|
207
|
-
...
|