Skip to content

Commit 500103f

Browse files
committed
Add DSN-gated Sentry breadcrumbs and triage runbook
1 parent a05c774 commit 500103f

6 files changed

Lines changed: 237 additions & 3 deletions

File tree

app/web/boot/sentry.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def initialize_sentry!
2828
::Sentry.init do |config|
2929
apply_settings(config)
3030
end
31+
apply_scope_tags!
3132
end
3233

3334
# @param config [Object]
@@ -45,6 +46,20 @@ def release_name
4546
"#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}"
4647
end
4748

49+
# @return [void]
50+
def apply_scope_tags!
51+
return unless defined?(::Sentry) && ::Sentry.respond_to?(:configure_scope)
52+
53+
::Sentry.configure_scope do |scope|
54+
scope.set_tags(
55+
release: release_name,
56+
environment: RuntimeEnv.rack_env
57+
)
58+
end
59+
rescue StandardError
60+
nil
61+
end
62+
4863
# @return [Boolean]
4964
def sentry_initialized?
5065
defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized?

app/web/telemetry/app_logger.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
require 'json'
44
require 'logger'
55
require 'time'
6-
76
module Html2rss
87
module Web
9-
##
10-
# Shared structured logger for application and middleware runtime events.
118
module AppLogger
129
class << self
1310
# @return [Logger]
@@ -103,6 +100,7 @@ def normalize_logfmt_value(raw_value)
103100
def emit_to_sentry(payload)
104101
return unless sentry_payload?(payload)
105102

103+
SentryLogs.record_breadcrumb(payload)
106104
SentryLogs.emit(payload)
107105
rescue StandardError
108106
nil

app/web/telemetry/sentry_logs.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require_relative '../security/log_sanitizer'
4+
35
module Html2rss
46
module Web
57
##
@@ -9,8 +11,26 @@ module SentryLogs
911
OMIT = Object.new.freeze
1012
ALLOWED_LEVELS = %i[debug info warn error fatal].freeze
1113
SENSITIVE_ATTRIBUTE_KEYS = %w[actor email ip remote_ip user_agent username x_forwarded_for].freeze
14+
BREADCRUMB_KEYS = %i[event_name security_event outcome request_id route_group strategy component details].freeze
15+
BREADCRUMB_CATEGORY_KEYS = %i[event_name security_event component].freeze
16+
BREADCRUMB_MESSAGE_KEYS = %i[message event_name security_event component].freeze
1217

1318
class << self
19+
# @param payload [Hash{Symbol=>Object}]
20+
# @return [void]
21+
def record_breadcrumb(payload)
22+
return unless breadcrumb_enabled?
23+
24+
::Sentry.add_breadcrumb(
25+
category: breadcrumb_category(payload),
26+
message: breadcrumb_message(payload),
27+
level: breadcrumb_level(payload),
28+
data: breadcrumb_data(payload)
29+
)
30+
rescue StandardError
31+
nil
32+
end
33+
1434
# @param payload [Hash{Symbol=>Object}]
1535
# @return [void]
1636
def emit(payload)
@@ -31,6 +51,13 @@ def enabled?
3151
!logger.nil?
3252
end
3353

54+
# @return [Boolean]
55+
def breadcrumb_enabled?
56+
RuntimeEnv.sentry_enabled? &&
57+
defined?(::Sentry) &&
58+
::Sentry.respond_to?(:add_breadcrumb)
59+
end
60+
3461
# @return [Object, nil]
3562
def logger
3663
return unless defined?(::Sentry) && ::Sentry.respond_to?(:logger)
@@ -54,6 +81,34 @@ def message(payload)
5481
payload[:component] || 'html2rss-web log'
5582
end
5683

84+
# @param payload [Hash{Symbol=>Object}]
85+
# @return [String]
86+
def breadcrumb_category(payload)
87+
breadcrumb_label(payload, 'html2rss-web', BREADCRUMB_CATEGORY_KEYS)
88+
end
89+
90+
# @param payload [Hash{Symbol=>Object}]
91+
# @return [String]
92+
def breadcrumb_message(payload)
93+
breadcrumb_label(payload, 'html2rss-web log', BREADCRUMB_MESSAGE_KEYS)
94+
end
95+
96+
# @param payload [Hash{Symbol=>Object}]
97+
# @return [String]
98+
def breadcrumb_level(payload)
99+
requested_level = payload.fetch(:level, 'info').to_s.downcase
100+
return 'warning' if requested_level == 'warn'
101+
return requested_level if ALLOWED_LEVELS.map(&:to_s).include?(requested_level)
102+
103+
'info'
104+
end
105+
106+
# @param payload [Hash{Symbol=>Object}]
107+
# @return [Hash{Symbol=>Object}]
108+
def breadcrumb_data(payload)
109+
LogSanitizer.sanitize_details(payload).slice(*BREADCRUMB_KEYS)
110+
end
111+
57112
# @param payload [Hash{Symbol=>Object}]
58113
# @return [Hash{Symbol=>Object}]
59114
def attributes(payload)
@@ -98,6 +153,14 @@ def sanitize_array(key, values)
98153
def sensitive_key?(key)
99154
SENSITIVE_ATTRIBUTE_KEYS.include?(key.to_s)
100155
end
156+
157+
# @param payload [Hash{Symbol=>Object}]
158+
# @param fallback [String]
159+
# @param keys [Array<Symbol>]
160+
# @return [String]
161+
def breadcrumb_label(payload, fallback, keys)
162+
keys.lazy.map { |key| payload[key] }.find(&:itself) || fallback
163+
end
101164
end
102165
end
103166
end

docs/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,25 @@ Canonical event fields: `event_name`, `schema_version`, `request_id`, `route_gro
197197

198198
Critical-path event families: auth, feed create, feed render, request errors.
199199

200+
## Sentry Runbook
201+
202+
When `SENTRY_DSN` is present, Sentry is enabled. `BUILD_TAG` and `GIT_SHA` become the release identifier, and
203+
`RACK_ENV` becomes the environment tag.
204+
205+
First-15-minute triage checklist:
206+
207+
1. Open the newest `feed.create`, `feed.render`, and `request.error` events.
208+
2. Confirm the release tag matches the deployed build.
209+
3. Check the breadcrumb trail for the failing path, strategy, and outcome.
210+
4. Decide whether the failure is retryable or terminal before paging the user-facing incident path.
211+
212+
Alert baseline:
213+
214+
1. Page on sustained `request.error` spikes in production.
215+
2. Page on a repeated `feed.render` failure burst, especially when success drops to zero for a route or strategy.
216+
3. Track the first recovery signal after fallback or retry succeeds so the incident can be closed quickly.
217+
4. Keep the initial threshold simple; tune after a few real incidents instead of pre-optimizing for every edge case.
218+
200219
---
201220

202221
## Documentation Policy
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
require_relative '../../../../app'
6+
7+
RSpec.describe Html2rss::Web::Boot::Sentry do
8+
let(:sentry_dsn) { 'https://example@sentry.invalid/1' }
9+
let(:captured_config) { Struct.new(:dsn, :environment, :enable_logs, :send_default_pii, :release).new }
10+
let(:captured_scope) do
11+
Class.new do
12+
attr_reader :tags
13+
14+
def initialize
15+
@tags = {}
16+
end
17+
18+
def set_tags(**tags)
19+
@tags = tags
20+
end
21+
end.new
22+
end
23+
let(:fake_sentry) do
24+
config = captured_config
25+
scope = captured_scope
26+
27+
Module.new.tap do |mod|
28+
mod.define_singleton_method(:initialized?) { false }
29+
mod.define_singleton_method(:init) do |&block|
30+
block.call(config)
31+
end
32+
mod.define_singleton_method(:configure_scope) do |&block|
33+
block.call(scope)
34+
end
35+
end
36+
end
37+
38+
before do
39+
stub_const('Sentry', fake_sentry)
40+
end
41+
42+
it 'configures release, environment, and scope tags when a dsn is present', :aggregate_failures do
43+
stub_runtime_env_for_sentry('production')
44+
described_class.send(:initialize_sentry!)
45+
46+
expect_sentry_configuration
47+
end
48+
49+
it 'does nothing when a dsn is not present' do
50+
allow(Html2rss::Web::RuntimeEnv).to receive(:sentry_enabled?).and_return(false)
51+
52+
expect(described_class.send(:configure?)).to be(false)
53+
end
54+
55+
def stub_runtime_env_for_sentry(rack_env)
56+
allow(Html2rss::Web::RuntimeEnv).to receive_messages(
57+
sentry_enabled?: true,
58+
sentry_dsn: sentry_dsn,
59+
rack_env: rack_env,
60+
build_tag: '2026-03-27',
61+
git_sha: 'abc1234',
62+
sentry_logs_enabled?: false
63+
)
64+
end
65+
66+
def expect_sentry_configuration
67+
expect(captured_config).to have_attributes(
68+
dsn: sentry_dsn,
69+
environment: 'production',
70+
enable_logs: false,
71+
send_default_pii: false,
72+
release: '2026-03-27+abc1234'
73+
)
74+
expect_sentry_scope_tags
75+
end
76+
77+
def expect_sentry_scope_tags
78+
expect(captured_scope.tags).to eq(
79+
release: '2026-03-27+abc1234',
80+
environment: 'production'
81+
)
82+
end
83+
end

spec/html2rss/web/sentry_logs_spec.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'spec_helper'
44

55
require_relative '../../../app/web/config/runtime_env'
6+
require_relative '../../../app/web/telemetry/app_logger'
67
require_relative '../../../app/web/telemetry/sentry_logs'
78

89
RSpec.describe Html2rss::Web::SentryLogs do
@@ -23,6 +24,7 @@
2324
let(:fake_sentry) do
2425
Module.new.tap do |mod|
2526
mod.define_singleton_method(:logger) { sentry_logger }
27+
mod.define_singleton_method(:add_breadcrumb) { |**| nil }
2628
end
2729
end
2830
let(:raw_payload) do
@@ -65,6 +67,22 @@
6567
expect(captured_call).to eq({})
6668
end
6769

70+
it 'adds breadcrumbs for request-critical structured logs even when sentry logs are disabled', :aggregate_failures do
71+
stub_const('Sentry', fake_sentry)
72+
allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: false)
73+
allow(Sentry).to receive(:add_breadcrumb)
74+
75+
Html2rss::Web::AppLogger.send(
76+
:format_entry,
77+
'INFO',
78+
Time.now.utc,
79+
nil,
80+
breadcrumb_payload.to_json
81+
)
82+
83+
expect(Sentry).to have_received(:add_breadcrumb).with(expected_breadcrumb)
84+
end
85+
6886
it 'falls back to info when an unsupported level is requested', :aggregate_failures do
6987
stub_const('Sentry', fake_sentry)
7088
allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true)
@@ -80,6 +98,44 @@ def build_sentry_logger
8098
logger_class.new(captured_call)
8199
end
82100

101+
def breadcrumb_payload
102+
{
103+
event_name: 'feed.create',
104+
outcome: 'failure',
105+
request_id: 'req-123',
106+
route_group: 'api_v1',
107+
strategy: 'faraday',
108+
details: { url: 'https://example.com/articles', fallback: 'browserless' }
109+
}
110+
end
111+
112+
def expected_breadcrumb
113+
include(
114+
category: 'feed.create',
115+
message: 'feed.create',
116+
level: 'info',
117+
data: breadcrumb_data_matcher
118+
)
119+
end
120+
121+
def breadcrumb_data_matcher
122+
include(
123+
event_name: 'feed.create',
124+
outcome: 'failure',
125+
request_id: 'req-123',
126+
route_group: 'api_v1',
127+
strategy: 'faraday',
128+
details: breadcrumb_details_matcher
129+
)
130+
end
131+
132+
def breadcrumb_details_matcher
133+
include(
134+
url: include(host: 'example.com', scheme: 'https'),
135+
fallback: 'browserless'
136+
)
137+
end
138+
83139
def expect_forwarded_payload
84140
expect(captured_call).to include(:message, :attributes)
85141
expect_forwarded_message

0 commit comments

Comments
 (0)