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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3921be9c6e9f434524b03c140e94ca5de03128bbd9b27c9a9df67477ed79aa7
4
- data.tar.gz: 90feef10dc4d4ada45acaefa06b65ea3cdc5543ba86beb5952f78b3167ec7948
3
+ metadata.gz: 71f2d05d2caf43bbaab7641118f09b86cafb4949d9f4c2be262a4773f6a88eab
4
+ data.tar.gz: 8f3de02212167bfcbbb412d653af259eeed2a0e5907f75b8e1191d724faf112a
5
5
  SHA512:
6
- metadata.gz: caef8697007fb7a2c678f925db8366ebb6dd7d493f489cc87e7a099f57b8852238a5ca7949d133a11120be6dd6a9eda3535d4d62f60bf31a7ea81187d9b004f7
7
- data.tar.gz: bc56bb6ca81812f771196081a70fade906ca1c4a8c4f047f6d63c326c87f67e941bbd05c0cc5c355898726ea6d6591b86fdbed72293bccfbb20dfffca6102c5b
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
- group.update_column(:last_notified_at, Time.current) if failures.empty?
21
-
22
- if failures.any?
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 :api_token,
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
- rails_env: Rails.env.to_s,
55
- ruby_version: RUBY_VERSION,
56
- rails_version: Rails::VERSION::STRING,
57
- hostname: Socket.gethostname
58
- }.freeze
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:,
@@ -1,5 +1,5 @@
1
1
  module RailsInformant
2
2
  class Current < ActiveSupport::CurrentAttributes
3
- attribute :breadcrumbs, :user_context, :custom_context
3
+ attribute :breadcrumbs, :custom_context, :delivering_notification, :user_context
4
4
  end
5
5
  end
@@ -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, error)
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, error)
42
- return if notifier_error?(error)
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
- type: "header",
26
- text: {
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
- { type: "context", elements: elements }
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
@@ -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 :api_token,
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.2.1
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
- ...