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.
- checksums.yaml +4 -4
- data/README.md +19 -8
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
- data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
- data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
- data/app/models/rails_error_dashboard/error_log.rb +54 -20
- data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
- data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
- data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
- data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
- data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
- data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
- data/config/locales/de.yml +87 -5
- data/config/locales/en.yml +99 -4
- data/config/locales/es.yml +88 -5
- data/config/locales/fr.yml +88 -5
- data/config/locales/it.yml +88 -5
- data/config/locales/ja.yml +86 -4
- data/config/locales/pl.yml +89 -5
- data/config/locales/pt-BR.yml +88 -5
- data/config/locales/ru.yml +89 -5
- data/config/locales/uk.yml +89 -5
- data/config/locales/zh-CN.yml +86 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
- data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
- data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
- data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
- data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
- data/lib/rails_error_dashboard/configuration.rb +61 -4
- data/lib/rails_error_dashboard/engine.rb +42 -1
- data/lib/rails_error_dashboard/error_reporter.rb +4 -2
- data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
- data/lib/rails_error_dashboard/plugin.rb +19 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
- data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
- data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
- data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
- data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
- data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
- data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
- data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
- data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
- data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
- data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
- data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
- data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
- data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +28 -0
- data/lib/tasks/error_dashboard.rake +2 -3
- 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
|
|
@@ -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
|
-
|
|
339
|
-
|
|
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"
|