rails_error_dashboard 0.14.4 → 0.16.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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -8
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  7. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  8. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
  9. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  10. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  11. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  12. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  13. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  14. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  15. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
  16. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  17. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  18. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  19. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  20. data/app/models/rails_error_dashboard/error_log.rb +54 -20
  21. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  22. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  23. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  24. data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
  25. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  26. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
  27. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  28. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  29. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  30. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  31. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  32. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  33. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  34. data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
  35. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  36. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  37. data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
  38. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  39. data/config/locales/de.yml +87 -5
  40. data/config/locales/en.yml +99 -4
  41. data/config/locales/es.yml +88 -5
  42. data/config/locales/fr.yml +88 -5
  43. data/config/locales/it.yml +88 -5
  44. data/config/locales/ja.yml +86 -4
  45. data/config/locales/pl.yml +89 -5
  46. data/config/locales/pt-BR.yml +88 -5
  47. data/config/locales/ru.yml +89 -5
  48. data/config/locales/uk.yml +89 -5
  49. data/config/locales/zh-CN.yml +86 -4
  50. data/config/routes.rb +6 -0
  51. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  52. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  53. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  54. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  55. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  56. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  57. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
  58. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  59. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
  61. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  62. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  63. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
  64. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  65. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  66. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  67. data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
  68. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  69. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  70. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  71. data/lib/rails_error_dashboard/configuration.rb +61 -4
  72. data/lib/rails_error_dashboard/engine.rb +42 -1
  73. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  74. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  75. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  76. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  77. data/lib/rails_error_dashboard/plugin.rb +19 -3
  78. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  79. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  80. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  81. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  82. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  83. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  84. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  85. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  86. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  87. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  88. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  89. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
  90. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  91. data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
  92. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  93. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  94. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
  95. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  96. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  97. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  98. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  99. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  100. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  101. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  102. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  103. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  104. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  105. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  106. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  107. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  108. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  109. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  110. data/lib/rails_error_dashboard/version.rb +1 -1
  111. data/lib/rails_error_dashboard.rb +28 -0
  112. data/lib/tasks/error_dashboard.rake +2 -3
  113. metadata +29 -2
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module RailsErrorDashboard
8
+ module Services
9
+ # One sendMessage call to the Telegram Bot API.
10
+ #
11
+ # WHY ONE PLACE: four senders post to Telegram (per-error notifications,
12
+ # storm and burst-summary messages, baseline alerts). The bot token is part
13
+ # of the URL -- https://api.telegram.org/bot<token>/sendMessage -- so any
14
+ # log line that quotes the URL, or an exception message that carries it,
15
+ # would leak the credential. Building the URL and redacting it live here
16
+ # so no caller has to remember either.
17
+ #
18
+ # Transport errors propagate, as in WebhookDelivery: every caller already
19
+ # rescues around its send and logs in its own words. Callers pass the
20
+ # message through .redact before logging it. A non-2xx answer from
21
+ # Telegram is not an exception; it is logged here with Telegram's own
22
+ # description ("Bad Request: chat not found"), which is the line an
23
+ # operator needs when the chat id is wrong.
24
+ class TelegramDelivery
25
+ OPEN_TIMEOUT = 5
26
+ READ_TIMEOUT = 10
27
+ USER_AGENT = "RailsErrorDashboard/1.0"
28
+ REDACTED = "[FILTERED]"
29
+
30
+ class << self
31
+ # Enabled and credentialled: the condition under which a dispatcher
32
+ # fires this channel.
33
+ def configured?(config = RailsErrorDashboard.configuration)
34
+ config.enable_telegram_notifications && credentials?(config)
35
+ end
36
+
37
+ # Token and chat id both present. The jobs check this rather than
38
+ # configured?, like the Discord job checks its URL and not the flag:
39
+ # the dispatcher decided to enqueue; the job only needs the means.
40
+ def credentials?(config = RailsErrorDashboard.configuration)
41
+ config.telegram_bot_token.present? && config.telegram_chat_id.present?
42
+ end
43
+
44
+ # POST a prepared sendMessage payload ({ text:, parse_mode:, ... }).
45
+ # chat_id is configuration, not content, so it is merged here rather
46
+ # than built into every payload.
47
+ #
48
+ # @return [HTTParty::Response, Net::HTTPResponse, nil] nil when there
49
+ # are no credentials to send with
50
+ def post(payload, config: RailsErrorDashboard.configuration)
51
+ return nil unless credentials?(config)
52
+
53
+ body = payload.merge(chat_id: config.telegram_chat_id).to_json
54
+ headers = { "Content-Type" => "application/json", "User-Agent" => USER_AGENT }
55
+ url = send_message_url(config)
56
+
57
+ response =
58
+ if defined?(HTTParty)
59
+ HTTParty.post(url, body: body, headers: headers, timeout: READ_TIMEOUT)
60
+ else
61
+ uri = URI(url)
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = uri.scheme == "https"
64
+ http.open_timeout = OPEN_TIMEOUT
65
+ http.read_timeout = READ_TIMEOUT
66
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
67
+ request.body = body
68
+ http.request(request)
69
+ end
70
+
71
+ log_rejection(response)
72
+ response
73
+ end
74
+
75
+ # A plain or HTML-formatted message with nothing else to it (storm and
76
+ # burst summaries). parse_mode: nil sends the text verbatim, which is
77
+ # right for a sentence that may contain a "<" from an app name.
78
+ def send_message(text, parse_mode: nil, config: RailsErrorDashboard.configuration)
79
+ payload = { text: text, disable_web_page_preview: true }
80
+ payload[:parse_mode] = parse_mode if parse_mode
81
+
82
+ post(payload, config: config)
83
+ end
84
+
85
+ # The bot token replaced wherever it appears in +string+ (an exception
86
+ # message that quotes the URL, a response body that echoes it).
87
+ def redact(string, config: RailsErrorDashboard.configuration)
88
+ token = config.telegram_bot_token.to_s
89
+ text = string.to_s
90
+ return text if token.empty?
91
+
92
+ text.gsub(token, REDACTED)
93
+ end
94
+
95
+ # The URL with the token replaced, for log lines that need to say
96
+ # where the POST went.
97
+ def redacted_url(config = RailsErrorDashboard.configuration)
98
+ redact(send_message_url(config), config: config)
99
+ end
100
+
101
+ private
102
+
103
+ def send_message_url(config)
104
+ base = config.telegram_api_base_url.to_s.strip
105
+ base = "https://api.telegram.org" if base.empty?
106
+
107
+ "#{base.chomp('/')}/bot#{config.telegram_bot_token}/sendMessage"
108
+ end
109
+
110
+ # Telegram answers 4xx with {"ok":false,"error_code":400,"description":"..."}.
111
+ # The description names the real problem (chat not found, bot was
112
+ # blocked, can't parse entities), so it goes in the log verbatim; the
113
+ # body never contains the token, but it is redacted anyway.
114
+ def log_rejection(response)
115
+ code = response.code.to_i
116
+ return if (200..299).cover?(code)
117
+
118
+ description = begin
119
+ JSON.parse(response.body.to_s)["description"]
120
+ rescue JSON::ParserError, TypeError
121
+ nil
122
+ end
123
+
124
+ Rails.logger.error(
125
+ "[RailsErrorDashboard] Telegram API rejected the message (HTTP #{code}): " \
126
+ "#{redact(description || response.body.to_s[0, 200])}"
127
+ )
128
+ rescue => e
129
+ Rails.logger.error("[RailsErrorDashboard] Telegram API response could not be read: #{e.class}")
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+
6
+ module RailsErrorDashboard
7
+ module Services
8
+ # Pure algorithm: build the Telegram sendMessage payload for an error.
9
+ #
10
+ # No HTTP calls. Returns { text:, parse_mode:, disable_web_page_preview: };
11
+ # TelegramDelivery adds the chat id, which is configuration, not content.
12
+ #
13
+ # WHY HTML AND NOT MARKDOWNV2: MarkdownV2 reserves eighteen characters,
14
+ # every one of which must be escaped in every value, and a single
15
+ # unescaped "_" in a class name like Net::OpenTimeout_Retry fails the
16
+ # whole message with "can't parse entities". HTML mode needs only <, >
17
+ # and & escaped, which ERB::Util.html_escape does. Telegram allows <b>,
18
+ # <i>, <code>, <pre> and <a href>; nothing else is used here.
19
+ #
20
+ # @example
21
+ # TelegramPayloadBuilder.call(error_log)
22
+ # # => { text: "<b>🚨 Error Alert</b>\n...", parse_mode: "HTML", ... }
23
+ class TelegramPayloadBuilder
24
+ # Telegram rejects a text longer than this. The fields below are all
25
+ # bounded (message 500, location 100, the rest short), so a message
26
+ # built here is a few hundred characters; the fallback exists for the
27
+ # one pathological row and sends the same content unformatted rather
28
+ # than losing the notification to a parse error from a cut tag.
29
+ MAX_TEXT_LENGTH = 4096
30
+ MESSAGE_LENGTH = 500
31
+
32
+ # @param error_log [ErrorLog]
33
+ # @param locale [String] resolved by the enqueueing thread (P4-T1)
34
+ # @return [Hash] sendMessage payload without chat_id
35
+ def self.call(error_log, locale: I18nStore::DEFAULT_LOCALE)
36
+ unknown = NotificationHelpers.unknown(locale)
37
+ not_available = NotificationHelpers.not_available(locale)
38
+
39
+ lines = [
40
+ bold(NotificationHelpers.t("red.notifications.error_alert.heading", locale)),
41
+ # error_type is an exception class name -- verbatim, escaped.
42
+ bold(error_log.error_type),
43
+ code(NotificationHelpers.truncate_message(error_log.message, MESSAGE_LENGTH)),
44
+ "",
45
+ field(:application, NotificationHelpers.app_name(error_log), locale),
46
+ *environment_line(error_log, locale),
47
+ field(:platform, error_log.platform || unknown, locale),
48
+ field(:occurrences, error_log.occurrence_count.to_s, locale),
49
+ field(:controller, error_log.controller_name || not_available, locale),
50
+ field(:action, error_log.action_name || not_available, locale),
51
+ field(:first_seen, NotificationHelpers.format_datetime_or_na(error_log.first_seen_at, locale), locale),
52
+ # Backtrace content is diagnostic output -- never translated.
53
+ field(:location, NotificationHelpers.extract_first_backtrace_line(error_log.backtrace, locale: locale), locale, code: true),
54
+ "",
55
+ link(NotificationHelpers.dashboard_url(error_log),
56
+ NotificationHelpers.t("red.notifications.error_alert.view_in_dashboard", locale))
57
+ ]
58
+
59
+ html_payload(lines.join("\n"))
60
+ end
61
+
62
+ # "<b>Label:</b> value". The colon and the bold are Telegram markup, not
63
+ # language, so they stay here rather than in each translation (same
64
+ # reasoning as NotificationHelpers.field for Slack).
65
+ def self.field(label, value, locale, code: false)
66
+ rendered = code ? code(value) : escape(value)
67
+ "#{bold("#{NotificationHelpers.label(label, locale)}:")} #{rendered}"
68
+ end
69
+
70
+ def self.environment_line(error_log, locale)
71
+ return [] unless error_log.respond_to?(:environment) && error_log.environment.present?
72
+
73
+ [ field(:environment, error_log.environment, locale) ]
74
+ end
75
+
76
+ def self.bold(text)
77
+ "<b>#{escape(text)}</b>"
78
+ end
79
+
80
+ def self.code(text)
81
+ "<code>#{escape(text)}</code>"
82
+ end
83
+
84
+ def self.link(url, text)
85
+ %(<a href="#{escape(url)}">#{escape(text)}</a>)
86
+ end
87
+
88
+ # Plain String, not a SafeBuffer: these go into JSON, not a view.
89
+ def self.escape(value)
90
+ ERB::Util.html_escape(value.to_s).to_str
91
+ end
92
+
93
+ # The payload for an already-formatted HTML text, degrading to plain
94
+ # text when it would not fit.
95
+ def self.html_payload(text)
96
+ if text.length > MAX_TEXT_LENGTH
97
+ plain = CGI.unescapeHTML(text.gsub(/<[^>]+>/, ""))
98
+ return { text: plain[0, MAX_TEXT_LENGTH], disable_web_page_preview: true }
99
+ end
100
+
101
+ { text: text, parse_mode: "HTML", disable_web_page_preview: true }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module RailsErrorDashboard
7
+ module Services
8
+ # One POST to a custom webhook URL (config.webhook_urls), signed when
9
+ # config.webhook_signing_secret is set.
10
+ #
11
+ # WHY ONE PLACE: three jobs post to webhook_urls (per-error notifications,
12
+ # storm/burst-summary messages, baseline alerts) and each carried its own
13
+ # copy of the HTTP code. The signature must be computed over the exact
14
+ # bytes on the wire, so the body is serialised once here and the same
15
+ # string is both signed and sent. Slack, Discord and PagerDuty keep their
16
+ # own senders: their payloads are not ours to sign.
17
+ #
18
+ # Errors propagate. Every caller already rescues around its send and logs
19
+ # in its own words (the specs pin those messages), so swallowing here would
20
+ # only hide the failure from them.
21
+ class WebhookDelivery
22
+ OPEN_TIMEOUT = 5
23
+ READ_TIMEOUT = 10
24
+ USER_AGENT = "RailsErrorDashboard/1.0"
25
+
26
+ class << self
27
+ # @param url [String]
28
+ # @param payload [Hash] serialised with #to_json
29
+ # @param headers [Hash{String => String}] merged over the defaults
30
+ # @return [HTTParty::Response, Net::HTTPResponse]
31
+ def post(url, payload, headers: {})
32
+ body = payload.to_json
33
+ all_headers = { "Content-Type" => "application/json", "User-Agent" => USER_AGENT }
34
+ .merge(headers)
35
+ .merge(signature_headers(body))
36
+
37
+ if defined?(HTTParty)
38
+ HTTParty.post(url, body: body, headers: all_headers, timeout: READ_TIMEOUT)
39
+ else
40
+ uri = URI(url)
41
+ http = Net::HTTP.new(uri.host, uri.port)
42
+ http.use_ssl = uri.scheme == "https"
43
+ http.open_timeout = OPEN_TIMEOUT
44
+ http.read_timeout = READ_TIMEOUT
45
+ # request_uri, not path: a webhook URL that carries a query string
46
+ # (a token, a channel id) used to lose it on this branch.
47
+ request = Net::HTTP::Post.new(uri.request_uri, all_headers)
48
+ request.body = body
49
+ http.request(request)
50
+ end
51
+ end
52
+
53
+ # Signature headers for +body+, or {} when no secret is configured.
54
+ def signature_headers(body)
55
+ secret = RailsErrorDashboard.configuration.webhook_signing_secret
56
+ return {} if secret.blank?
57
+
58
+ WebhookSigner.headers_for(body, secret: secret)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module RailsErrorDashboard
6
+ module Services
7
+ # HMAC-SHA256 signatures for OUTBOUND webhooks (the ones RED sends to
8
+ # config.webhook_urls). Inbound verification for GitHub/GitLab/Codeberg/
9
+ # Linear lives in WebhooksController and is unrelated.
10
+ #
11
+ # Scheme, chosen to match what Stripe, Svix and GitHub receivers already
12
+ # know how to check:
13
+ #
14
+ # X-Error-Dashboard-Timestamp: 1718000000 (Unix seconds)
15
+ # X-Error-Dashboard-Signature-256: sha256=<hex>
16
+ #
17
+ # hex = HMAC_SHA256(secret, "#{timestamp}.#{raw_body}")
18
+ #
19
+ # The timestamp is part of the signed string, not just a header, so a
20
+ # captured request cannot be replayed later with a fresh timestamp. The
21
+ # receiver verifies the digest over the RAW body it received (before
22
+ # parsing JSON) and rejects timestamps outside its tolerance window.
23
+ #
24
+ # Pure functions, no I/O, nothing here can raise on well-typed input.
25
+ class WebhookSigner
26
+ TIMESTAMP_HEADER = "X-Error-Dashboard-Timestamp"
27
+ SIGNATURE_HEADER = "X-Error-Dashboard-Signature-256"
28
+ PREFIX = "sha256="
29
+
30
+ # Default replay window for verifiers, in seconds. Five minutes is the
31
+ # value Stripe and Svix document; it absorbs clock skew without leaving
32
+ # a captured request useful for long.
33
+ DEFAULT_TOLERANCE = 300
34
+
35
+ class << self
36
+ # Headers to merge into the outbound request.
37
+ #
38
+ # @param body [String] the exact bytes being sent
39
+ # @param secret [String]
40
+ # @param timestamp [Integer] Unix seconds; defaults to now
41
+ # @return [Hash{String => String}]
42
+ def headers_for(body, secret:, timestamp: Time.now.to_i)
43
+ ts = timestamp.to_i
44
+ {
45
+ TIMESTAMP_HEADER => ts.to_s,
46
+ SIGNATURE_HEADER => PREFIX + digest(body, secret: secret, timestamp: ts)
47
+ }
48
+ end
49
+
50
+ # @return [String] lowercase hex HMAC over "#{timestamp}.#{body}"
51
+ def digest(body, secret:, timestamp:)
52
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, "#{timestamp.to_i}.#{body}")
53
+ end
54
+
55
+ # Receiver-side check, offered so a Ruby receiver (and this gem's own
56
+ # specs and docs) need not re-derive the scheme.
57
+ #
58
+ # @param body [String] raw request body
59
+ # @param timestamp [String, Integer] value of X-Error-Dashboard-Timestamp
60
+ # @param signature [String] value of X-Error-Dashboard-Signature-256
61
+ # @param secret [String]
62
+ # @param tolerance [Integer, nil] max age in seconds; nil skips the window check
63
+ # @param now [Integer] injectable clock for tests
64
+ # @return [Boolean]
65
+ def valid?(body, timestamp:, signature:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now.to_i)
66
+ return false if secret.to_s.empty? || signature.to_s.empty?
67
+
68
+ ts = Integer(timestamp.to_s, 10)
69
+ return false if tolerance && (now - ts).abs > tolerance
70
+
71
+ expected = PREFIX + digest(body, secret: secret, timestamp: ts)
72
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
73
+ rescue ArgumentError, TypeError
74
+ # A timestamp that is not an integer is a bad signature, not an error.
75
+ false
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module ValueObjects
5
+ # The error as it looked at one occurrence: a read-only copy of the error
6
+ # row whose breadcrumbs, system health and variables are that occurrence's
7
+ # stored context. Every other field keeps the row's latest value.
8
+ #
9
+ # A real ErrorLog rather than a delegator, so every way a card reads a
10
+ # piece sees the occurrence's value: the instance-variables card goes
11
+ # through read_attribute and error.class.column_names, because
12
+ # Object#instance_variables shadows the attribute reader.
13
+ module OccurrenceSnapshot
14
+ # @param error [ErrorLog] the error the context belongs to
15
+ # @param context [ErrorOccurrenceContext] one of its occurrences' contexts
16
+ # @return [ErrorLog] read-only; a piece the occurrence did not capture is nil
17
+ def self.build(error, context)
18
+ pieces = Commands::RecordOccurrenceContext::PIECES.to_h { |piece| [ piece.to_s, context.read_attribute(piece) ] }
19
+ error.class.instantiate(error.attributes_before_type_cast.merge(pieces)).tap(&:readonly!)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.14.4"
2
+ VERSION = "0.16.0"
3
3
  end
@@ -23,6 +23,7 @@ begin; require "turbo-rails"; rescue LoadError; end
23
23
  # Core library files
24
24
  require "rails_error_dashboard/value_objects/error_context"
25
25
  require "rails_error_dashboard/value_objects/llm_call_event"
26
+ require "rails_error_dashboard/value_objects/occurrence_snapshot"
26
27
  require "rails_error_dashboard/integrations/o_tel"
27
28
  require "rails_error_dashboard/integrations/tracer"
28
29
  require "rails_error_dashboard/integrations/llm_span_processor"
@@ -44,6 +45,7 @@ require "rails_error_dashboard/services/localized_time_formatter"
44
45
  require "rails_error_dashboard/services/notification_helpers"
45
46
  require "rails_error_dashboard/services/slack_payload_builder"
46
47
  require "rails_error_dashboard/services/discord_payload_builder"
48
+ require "rails_error_dashboard/services/telegram_payload_builder"
47
49
  require "rails_error_dashboard/services/pagerduty_payload_builder"
48
50
  require "rails_error_dashboard/services/webhook_payload_builder"
49
51
  require "rails_error_dashboard/services/baseline_alert_payload_builder"
@@ -58,6 +60,7 @@ require "rails_error_dashboard/services/source_code_reader"
58
60
  require "rails_error_dashboard/services/git_blame_reader"
59
61
  require "rails_error_dashboard/services/git_head_reader"
60
62
  require "rails_error_dashboard/services/solid_queue_config_check"
63
+ require "rails_error_dashboard/services/repository_url"
61
64
  require "rails_error_dashboard/services/github_link_generator"
62
65
  require "rails_error_dashboard/services/cause_chain_extractor"
63
66
  require "rails_error_dashboard/services/environment_snapshot"
@@ -88,6 +91,13 @@ require "rails_error_dashboard/services/variable_serializer"
88
91
  require "rails_error_dashboard/services/local_variable_capturer"
89
92
  require "rails_error_dashboard/services/swallowed_exception_tracker"
90
93
  require "rails_error_dashboard/services/rack_attack_tracker"
94
+ require "rails_error_dashboard/services/webhook_signer"
95
+ require "rails_error_dashboard/services/webhook_delivery"
96
+ require "rails_error_dashboard/services/telegram_delivery"
97
+ require "rails_error_dashboard/services/resolution_callbacks"
98
+ require "rails_error_dashboard/services/missing_translation_tracker"
99
+ require "rails_error_dashboard/services/missing_translation_handler"
100
+ require "rails_error_dashboard/services/active_storage_probe"
91
101
  require "rails_error_dashboard/services/ai_agent_classifier"
92
102
  require "rails_error_dashboard/services/crash_capture"
93
103
  require "rails_error_dashboard/services/diagnostic_dump_generator"
@@ -108,6 +118,7 @@ require "rails_error_dashboard/queries/error_cascades"
108
118
  require "rails_error_dashboard/queries/baseline_stats"
109
119
  require "rails_error_dashboard/queries/platform_comparison"
110
120
  require "rails_error_dashboard/queries/error_correlation"
121
+ require "rails_error_dashboard/commands/record_occurrence_context"
111
122
  require "rails_error_dashboard/commands/log_error"
112
123
  require "rails_error_dashboard/commands/resolve_error"
113
124
  require "rails_error_dashboard/commands/create_issue"
@@ -136,6 +147,7 @@ require "rails_error_dashboard/commands/upsert_cascade_pattern"
136
147
  require "rails_error_dashboard/commands/upsert_baseline"
137
148
  require "rails_error_dashboard/commands/flush_swallowed_exceptions"
138
149
  require "rails_error_dashboard/commands/flush_rack_attack_events"
150
+ require "rails_error_dashboard/commands/flush_missing_translations"
139
151
  require "rails_error_dashboard/commands/scrub_invalid_encoding"
140
152
  require "rails_error_dashboard/commands/backfill_resolved_at"
141
153
  require "rails_error_dashboard/queries/uninstall_plan"
@@ -158,6 +170,9 @@ require "rails_error_dashboard/queries/job_health_summary"
158
170
  require "rails_error_dashboard/queries/database_health_summary"
159
171
  require "rails_error_dashboard/queries/swallowed_exception_summary"
160
172
  require "rails_error_dashboard/queries/rack_attack_summary"
173
+ require "rails_error_dashboard/queries/health_status"
174
+ require "rails_error_dashboard/queries/missing_translation_summary"
175
+ require "rails_error_dashboard/queries/boot_errors"
161
176
  require "rails_error_dashboard/queries/llm_health_summary"
162
177
  require "rails_error_dashboard/queries/release_timeline"
163
178
  require "rails_error_dashboard/error_reporter"
@@ -214,6 +229,19 @@ module RailsErrorDashboard
214
229
  remove_instance_variable(:@detected_git_sha) if defined?(@detected_git_sha)
215
230
  end
216
231
 
232
+ # The name errors are filed under: config.application_name, then the
233
+ # APPLICATION_NAME variable, then the Rails app's module name. Capture, the
234
+ # storm and event flushers, and the backfill all use this one chain, or the
235
+ # same app's rows end up under two Applications, or under none.
236
+ #
237
+ # @return [String]
238
+ def self.resolved_application_name
239
+ configuration.application_name.presence ||
240
+ ENV["APPLICATION_NAME"].presence ||
241
+ (defined?(Rails) && Rails.application&.class&.module_parent_name) ||
242
+ "Rails Application"
243
+ end
244
+
217
245
  # Register a plugin
218
246
  # @param plugin [Plugin] The plugin instance to register
219
247
  # @return [Boolean] True if registered successfully, false otherwise
@@ -335,9 +335,8 @@ namespace :error_dashboard do
335
335
 
336
336
  desc "Backfill application_id for existing errors"
337
337
  task backfill_application: :environment do
338
- app_name = ENV["APP_NAME"] || ENV["APPLICATION_NAME"] ||
339
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
340
- "Legacy Application"
338
+ # APP_NAME overrides; otherwise the name capture files new errors under
339
+ app_name = ENV["APP_NAME"].presence || RailsErrorDashboard.resolved_application_name
341
340
 
342
341
  puts "\n" + "=" * 80
343
342
  puts "RAILS ERROR DASHBOARD - BACKFILL APPLICATION"