Skip to content

Commit e546af4

Browse files
authored
Merge pull request #133 from JudahSan/ft/sentry
feat: Sentry Integration for Full Observability
2 parents 58d0299 + 11eb31a commit e546af4

File tree

9 files changed

+285
-6
lines changed

9 files changed

+285
-6
lines changed

Gemfile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ gem 'stimulus-rails', '>= 1.3' # Hotwire's modest JavaScript framework [https://
3838
gem 'turbo-rails', '>= 2.0' # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
3939
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
4040
gem 'rails_cloudflare_turnstile'
41-
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
41+
gem 'tzinfo-data', platforms: %i[windows jruby]
42+
43+
# Error monitoring
44+
# https://docs.sentry.io/platforms/ruby/guides/rails/
45+
# sentry-rails brings Rails integration; sentry-ruby is the core SDK
46+
gem 'sentry-rails'
47+
gem 'sentry-ruby'
4248

4349
# gem "kredis" # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
4450
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
@@ -47,7 +53,7 @@ gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
4753

4854
group :development, :test do
4955
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
50-
gem 'debug', '~> 1.10.0', platforms: %i[mri mingw x64_mingw]
56+
gem 'debug', '~> 1.10.0', platforms: %i[mri windows]
5157
gem 'rubocop', '~> 1.79.2', require: false
5258
gem 'rubocop-performance', '~> 1.25.0', require: false
5359
gem 'rubocop-rails', '~> 2.30.3', require: false

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,12 @@ GEM
382382
sendgrid-ruby (~> 6.4)
383383
sendgrid-ruby (6.7.0)
384384
ruby_http_client (~> 3.4)
385+
sentry-rails (5.27.0)
386+
railties (>= 5.0)
387+
sentry-ruby (~> 5.27.0)
388+
sentry-ruby (5.27.0)
389+
bigdecimal
390+
concurrent-ruby (~> 1.0, >= 1.0.2)
385391
sidekiq (8.0.7)
386392
connection_pool (>= 2.5.0)
387393
json (>= 2.9.0)
@@ -484,6 +490,8 @@ DEPENDENCIES
484490
rubocop-rails (~> 2.30.3)
485491
selenium-webdriver
486492
sendgrid-actionmailer (~> 3.2)
493+
sentry-rails
494+
sentry-ruby
487495
simple_form (~> 5.3)
488496
simplecov
489497
sitemap_generator

app/views/layouts/application.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
1212
<%= javascript_include_tag "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", "data-turbo-track": "reload", defer: true %>
1313
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
14-
</head>
14+
<%= Sentry.get_trace_propagation_meta.html_safe %>
15+
</head>
1516

1617
<body>
1718
<div class="bg-gray-50">
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
8aarx+TyrBlNKpmhCscpazYTzl7FjCAXIymrbctgugXk0GhZgOe8qn7q8CMMqXtIQic/1Go9NrOpPsTarBqwryiFU3E+8Ioau5HJ6QzeFpTEhxwgZbf3QLR+A3Gvn3ocezPCuKSes41NgLyn3+2U7wo97Fsj8nfuZoVp6Yjgu41RLJLsWAt85h9LUZVfiji5BRmmB6lApbKKgN+CltCU3rIddPkzSMjnZTq6--To60MjyBkAuvd5hr--b+wIbJYh80wBZQu3vd0Ztg==
1+
WNTiuJ7BJhj0UN5kdaod5zWlHAFjr+Y1kZVlDTYNKn2clMIkVapQOePwm8Foz4TKGfaNqr2YdciOd9hROYaDmHYJ73kZr+XqR6Dm7qNoYnB1T0NIzzsJuaM7MP8+1tmvrdEhUoLqA3qLY/MDDXTlB2cn2LWQNj85mKoqge2EtnT3cxX6tFSwn6UcnYUjqEeQpcwfXSrAgKSj7izq2YEcuK7/iPWFzaV0Wf4JkZO9uTB07Uf+a8ETsKX507XnjV5uuVoQ8kyRhJ1I9eHu+Y02EcfPcEVBrwXVTY1lANvNIHXt3d6p4P2KeaFXpYYVqkfnIfAaiglVALti9E3+zyz2EwqTWpu47uNYstk3F/st5RLtLxh/Z3t9QO2JX8arnP0+vg==--LicQNhOcd8wHNxFp--mEXqAhZdd75RlFQvCnzMTg==
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
zaAqQiRmJE+d3ZBb/azNbGgDJq4mY/L4d0DXWxh0B6nJbgYZS7MRaJFiSizejGWAJMUPtAK1cWMevV/nBv6iH/pGsPFvLV7yBFLcMKsNvMCfJrsEOsI619A1jFYWAJntiSInsI/kfZGcI0sC7qHSI1LXAWJXcaelRNqzFnsw+9fHZ5wxO9h6mt1f9kQGyg6PI48j0LVuQPuEtkD0MebXjmDC2N4ZEOuqZhx91c0Sw4p/x08yakJssjZLLXHUQVY6i3WzLqYCq/XYWkrIM75Te8C4MPHY9Bo3wJxehRtHWOyz2DxRiaioSnGqwnt/S2jRHWX2f9f2sn74Ftur31K1JWUUMoFeC4pp+SJVd8JUVWRWBbfuWbS9RHMidndWWSvsC6KyfVnpvRloyB0ACMof2eBQMb3+fVrzjO8A7oaGP2HJD/r7kf2SV34B0aWHaqo8R+yqRsIeDL23bvYLK+nhZMP5MR32LIyV1ZU2qnfctJDS+lsS9cYM4P8hSDo8EZo7/EyUIFNuYanp1OrQl+h9xiqe1FwEbAEgkMO/CelwVuYFSnzDaVJAAVt2ccDqZM/w/jF9Jb6KWbM92h8eOu8tQxRQNRldUcW6nbhWnmnbULArNaVMPiyuJIlgf44pt1tg8qxc86O5NmgQIcSbzG9j74Yj51OBgxhEUKNFza/UQIVSjnwoWEJ4zoOrq0SkmuX7tfvdUuRvPg7qWOALS10dNr8jn7y8XbtZYCd5pPR9ueLIZxx1Z0qI--3NPDf8xfi3iQYVYn--5eB/oVA2dOoAP3Q0pm2EVg==
1+
3PIFDjHepI9/J9OqlzaXRwK/ToZfen7reWQqGejC6jHD8FHUp5rBg9TOiWhwxzhYvilW67DDIBSVGDtk1LDJMzYmNldS+N29zhK6flApdwa7Oe1my7+Pmi+LP+AS7bL84d7lNAckskJwUO+/8nes6alK9Uec3d81REAXQ6Ht0Xrgcec1ilUKbmLVLDIY/5kuziD0Kp5RuDwn5lzXje2ok+uomyX0YqQMLuXHJ9nkIiR5fUIV6HKezmJU0D3YZAx6fgRjDO/15kd9x5HY76SSfBu0JvIB3DR+chaDGe7pgSpKUkgyxYVjD2wE/A1NDQ4AyiLfm/qTtU9wet6EPWNh/xZvGAkZeqZn53CwMQx2zea2GI+A2PSnyHrsf/vxkGdxccaYhiXkzqXLKPBqFeN72o7ZgU8J8hLHF/gUCk4KNxS+rjLgQbvlYf70l7EFQKr6P3Izc5+bWbxKF/ej5Djw4phYCFGTygARvx5PwJTqxpst8QUofnzG4KBMzllta7oZTHBWtxaBb1kxPsCjCtNtWfsjik0/jtezQdorXCOAOv/MMhoImUzgzSAjCssvxzalCqGxM7iYjm4m27vk9YD4CD3f3oHleYWVtb/vRO/VS3M2FJgXxgrAA/b5j8KHjsmRvOGl6Dk47E1Ba1O+/saUWljxwWHc7JBZMZTNZ9vV6hR33j06dBf4VnoJLVCKHakFluPwZkb5TNqSOK+RXrwSPsFNe0TYBB+pSu8TYhimTG6vvSlTFKm3d2pdqh19PIfxVC1hfYmap/BSYaL1fcX5hACjPN2mEbUxihcfvP0Qe5wFqnhtKC6GItlykXdtihX9d+YT8kI7TQP+EF8TGH6kqo9Xjcavg2QraweGFkgBYjNKasf9zPS1IrT5+7tp3Z4mTmWeY3AaJfZ97byORcrJUsZlTf4HW+x/tZZXlFd3OLCaXECho1qqjKHf3E+g2RQEuJuX3CyrH7xWmECkzPJB7QFqNB/1TdtK8sLs3XfXoUV+1BXHH81kEj+p7JqVvMwI5cssloJ80Z2N03lR7gaZ8Rq7ZlD0yukah3cOC8+8idT7xOuU2cT5beGfmOMCP7V2GGl8zzDnLxp5ypQGa/ixrP5nCnhTcP5yaMpYoK+yCzxtu3PzJTNHY2MuCrNNwF9DB87szWmnL1TIPLQtRrFAsDsY1rh5Yj7Y7eFX84ohO7lNnyE3ZpsLDpd3UO2lYQ2+GiFAJgKPR3OA9C0M+4rs3f5n6ID1O9npRT1L6KtgPErXI2VqqmFwtHu/BYI1XIRGT8G5vefPSLUgRfDvb5K6GASwpTmXeQrJ5KojX0br40TCoanOjLvI72INpVMCpw==--wK4qt67gkHrRem+6--XZielvX5J4HVbScRxWRqBw==

config/credentials/test.yml.enc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
jLSHDQZgDcPaREiO8PZS868/YrqNEFgMEfu/tTnv06hidLE3FiN1eh41jbOWskdtlPrfxWdeC56Sc7R2exBE82sQMzq8buP4GtW+UpZzQ8L2CAsni6fIGzyStfkcuDvmbURilGu38gONw0UIDj1CwM7P8vvTrx4bEK/M10W4AFNpoezEz5HJClYMAOx75DeykoJTxxKtTY2doM0kCAxuk4ZcaQU64dvj4nA=--41zIAQnwOwg6KAdR--77ousxvNJxt9P2igxuVc8w==
1+
bW0I+RamDskXcMNZk1GxQ1edWH4CrP1IBuHcCm8CdozPAgi86mklswlp8RDpzs4ieOHtX16+AUmEcO8ovHO5MYFyGlqPJGsAmQOSCw0aXZbpOcc4MIk8AHcY5tWEXr8paGb30yEWgUo6X99vUAGsoMgGyXH5RpsBXtRgrfmCatFLbTB2+YUyuuNOTSQN5CCd1x8Z0WYdXglTWDC808OR9nnhe2SeYi8Til9dUuQjTpzNkpAx5V8DM/ZHA/F/20M19STmv/7+fY8MdNC1BeYS+3KApJV9J6vZ+TerBZ1u5OLsvvZgJLJ/DAnviwecodnCaW2qgRhMf5TO6kMMOLj+y+XCG1Ej1iIHFo3XpRQGKxDQu7vcaCSohB5JuW1KQ8+Hew==--iwqOL1DTjBWJUgvw--VTb2Ecdr76/5BdItgNMHyQ==

config/deploy.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,48 @@
6060
after :finishing, 'deploy:cleanup'
6161
after :finishing, 'deploy:restart'
6262
end
63+
64+
namespace :sentry do
65+
desc 'Notify Sentry of a new release'
66+
task :notify_release do
67+
on roles(:web) do
68+
within release_path do
69+
begin
70+
# Get Sentry credentials with validation
71+
auth_token = capture(:bundle, :exec, :rails, :runner, "puts Rails.application.credentials.dig(:sentry, :auth_token)")
72+
org = capture(:bundle, :exec, :rails, :runner, "puts Rails.application.credentials.dig(:sentry, :org)")
73+
74+
if auth_token.strip.empty? || org.strip.empty?
75+
warn "Sentry credentials missing - skipping release notification"
76+
next
77+
end
78+
79+
# Create release and upload sourcemaps
80+
release_version = capture(:git, 'rev-parse HEAD').strip
81+
82+
with rails_env: fetch(:rails_env),
83+
SENTRY_AUTH_TOKEN: auth_token.strip,
84+
SENTRY_ORG: org.strip do
85+
86+
# Create release
87+
execute :bundle, :exec, :sentry, "releases new #{release_version}"
88+
89+
# Upload sourcemaps if assets exist
90+
if test("[ -d #{release_path}/public/assets ]")
91+
execute :bundle, :exec, :sentry, "releases files #{release_version} upload-sourcemaps ./public/assets --url-prefix '~/assets'"
92+
end
93+
94+
# Finalize release
95+
execute :bundle, :exec, :sentry, "releases finalize #{release_version}"
96+
end
97+
98+
info "Sentry release #{release_version} created successfully"
99+
rescue => e
100+
warn "Sentry release notification failed: #{e.message}"
101+
end
102+
end
103+
end
104+
end
105+
end
106+
107+
after 'deploy:published', 'sentry:notify_release'

config/initializers/sentry.rb

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# frozen_string_literal: true
2+
3+
# Sentry configuration for Rails 8
4+
# Docs: https://docs.sentry.io/platforms/ruby/guides/rails/
5+
6+
sentry_dsn = Rails.application.credentials.dig(:sentry, :dsn)
7+
sentry_enabled = Rails.application.credentials.dig(:sentry, :enabled) != false
8+
9+
if sentry_dsn && !sentry_dsn.empty? && sentry_enabled
10+
Sentry.init do |config|
11+
config.dsn = sentry_dsn
12+
config.environment = Rails.env
13+
config.enabled_environments = %w[production test development]
14+
15+
# Release tracking
16+
config.release = begin
17+
`git rev-parse HEAD 2>/dev/null`.strip.presence || "unknown"
18+
rescue => e
19+
Rails.logger.warn("Failed to get git revision: #{e.message}")
20+
"unknown"
21+
end
22+
23+
# Breadcrumbs and logging
24+
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
25+
config.sdk_logger = Rails.logger
26+
27+
# PII and data collection
28+
config.send_default_pii = Rails.env.development?
29+
30+
# Performance monitoring - environment specific
31+
traces_rate = Rails.application.credentials.dig(:sentry, :traces_sample_rate) ||
32+
(Rails.env.production? ? 0.1 : 1.0)
33+
profiles_rate = Rails.application.credentials.dig(:sentry, :profiles_sample_rate) ||
34+
(Rails.env.production? ? 0.1 : 1.0)
35+
36+
config.traces_sample_rate = traces_rate
37+
config.profiles_sample_rate = profiles_rate
38+
39+
# Enhanced performance sampling
40+
config.traces_sampler = lambda do |sampling_context|
41+
transaction_name = sampling_context[:transaction_context][:name]
42+
case transaction_name
43+
when /health|heartbeat|ping/
44+
0.0 # Skip health checks
45+
when /users\/(sign_in|sign_up|password)/
46+
1.0 # Always trace authentication endpoints
47+
when /projects|chapters|countries/
48+
0.8 # High sampling for core content
49+
when /admin/
50+
0.5 # Sample admin pages at 50%
51+
else
52+
traces_rate
53+
end
54+
end
55+
56+
# Rails 8 specific instrumentation
57+
config.rails.report_rescued_exceptions = true
58+
config.instrumenter = :active_support
59+
60+
# Filter noise - common Rails exceptions
61+
config.excluded_exceptions += %w[
62+
ActionController::RoutingError
63+
ActiveRecord::RecordNotFound
64+
ActionController::InvalidAuthenticityToken
65+
ActionController::UnknownFormat
66+
ActionDispatch::Http::MimeNegotiation::InvalidType
67+
Rack::QueryParser::ParameterTypeError
68+
Rack::QueryParser::InvalidParameterError
69+
]
70+
71+
# Transaction filtering
72+
config.before_send_transaction = lambda do |event, hint|
73+
# Skip asset requests and health checks
74+
return nil if event.transaction&.match?(/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/)
75+
return nil if event.transaction&.match?(/health|assets|favicon|robots\.txt/)
76+
event
77+
end
78+
79+
# Enhanced error context and filtering
80+
config.before_send = lambda do |event, hint|
81+
# Skip letter opener and development tools
82+
return nil if event.request&.url&.match?(/letter_opener|web-console|__better_errors/)
83+
84+
# Add user context (non-PII)
85+
if defined?(Current) && Current.respond_to?(:user) && Current.user
86+
event.user = {
87+
id: Current.user.id,
88+
role: Current.user.role,
89+
created_at: Current.user.created_at
90+
}
91+
end
92+
93+
# Add request context
94+
if event.request
95+
event.tags.merge!({
96+
request_id: event.request.env['action_dispatch.request_id'],
97+
user_agent: event.request.env['HTTP_USER_AGENT']&.truncate(100),
98+
referer: event.request.env['HTTP_REFERER']&.truncate(200)
99+
})
100+
end
101+
102+
# Add Rails context
103+
event.tags.merge!({
104+
rails_version: Rails.version,
105+
ruby_version: RUBY_VERSION,
106+
environment: Rails.env
107+
})
108+
109+
# Scrub sensitive data
110+
if event.request&.data.is_a?(String)
111+
event.request.data = event.request.data
112+
.gsub(/password=[^&]+/i, 'password=[FILTERED]')
113+
.gsub(/token=[^&]+/i, 'token=[FILTERED]')
114+
.gsub(/api_key=[^&]+/i, 'api_key=[FILTERED]')
115+
.gsub(/secret=[^&]+/i, 'secret=[FILTERED]')
116+
end
117+
118+
event
119+
end
120+
121+
# Test environment configuration
122+
if Rails.env.test?
123+
config.transport.transport_class = Sentry::DummyTransport
124+
config.background_worker_threads = 0
125+
end
126+
127+
# Production optimizations
128+
if Rails.env.production?
129+
config.background_worker_threads = 5
130+
config.send_client_reports = true
131+
end
132+
end
133+
134+
Rails.logger.info "Sentry initialized for #{Rails.env} environment"
135+
else
136+
Rails.logger.warn "Sentry not initialized - DSN missing or disabled"
137+
end
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class SentryInitializerTest < ActiveSupport::TestCase
6+
def with_credentials(hash)
7+
fake_credentials = Object.new
8+
fake_credentials.define_singleton_method(:dig) do |*keys|
9+
hash.dig(*keys)
10+
end
11+
12+
Rails.application.stubs(:credentials).returns(fake_credentials)
13+
yield
14+
ensure
15+
Rails.application.unstub(:credentials)
16+
end
17+
18+
def reload_sentry_initializer
19+
# Reset Sentry state before reloading initializer
20+
begin
21+
Sentry.close
22+
rescue StandardError
23+
# ignore if not initialized
24+
end
25+
# Reset configuration (be robust even if attr_writer isn't available)
26+
if Sentry.respond_to?(:configuration=)
27+
Sentry.configuration = Sentry::Configuration.new
28+
else
29+
Sentry.instance_variable_set(:@configuration, Sentry::Configuration.new)
30+
end
31+
32+
load Rails.root.join('config/initializers/sentry.rb')
33+
end
34+
35+
test 'does not initialize Sentry when credentials[:sentry][:dsn] is absent' do
36+
with_credentials({}) do
37+
reload_sentry_initializer
38+
assert_equal false, Sentry.initialized?, 'Sentry should not initialize without DSN'
39+
end
40+
end
41+
42+
test 'does not initialize Sentry when enabled is false' do
43+
with_credentials({ sentry: { dsn: 'https://public@example.com/1', enabled: false } }) do
44+
reload_sentry_initializer
45+
assert_equal false, Sentry.initialized?, 'Sentry should not initialize when disabled'
46+
end
47+
end
48+
49+
test 'initializes Sentry when credentials[:sentry][:dsn] is present and uses DummyTransport in test env' do
50+
with_credentials({ sentry: { dsn: 'https://public@example.com/1', enabled: true } }) do
51+
reload_sentry_initializer
52+
assert_equal true, Sentry.initialized?, 'Sentry should initialize with DSN'
53+
assert_includes Sentry.configuration.enabled_environments, 'test'
54+
assert_includes Sentry.configuration.enabled_environments, 'production'
55+
assert_includes Sentry.configuration.enabled_environments, 'development'
56+
57+
client = Sentry.get_current_client
58+
assert_not_nil client, 'Sentry client should be present after initialization'
59+
transport = client.transport
60+
assert_instance_of Sentry::DummyTransport, transport, 'Transport should be DummyTransport in test'
61+
62+
# Test Rails 8 specific configuration
63+
assert_equal true, Sentry.configuration.rails.report_rescued_exceptions
64+
assert_equal :sentry, Sentry.configuration.instrumenter
65+
assert_equal 0, Sentry.configuration.background_worker_threads
66+
end
67+
end
68+
69+
test 'configures performance monitoring with custom rates' do
70+
with_credentials({
71+
sentry: {
72+
dsn: 'https://public@example.com/1',
73+
traces_sample_rate: 0.5,
74+
profiles_sample_rate: 0.3
75+
}
76+
}) do
77+
reload_sentry_initializer
78+
assert_equal 0.5, Sentry.configuration.traces_sample_rate
79+
assert_equal 0.3, Sentry.configuration.profiles_sample_rate
80+
end
81+
end
82+
end

0 commit comments

Comments
 (0)