Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ gem 'stimulus-rails', '>= 1.3' # Hotwire's modest JavaScript framework [https://
gem 'turbo-rails', '>= 2.0' # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'rails_cloudflare_turnstile'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
gem 'tzinfo-data', platforms: %i[windows jruby]

# Error monitoring
# https://docs.sentry.io/platforms/ruby/guides/rails/
# sentry-rails brings Rails integration; sentry-ruby is the core SDK
gem 'sentry-rails'
gem 'sentry-ruby'

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

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem 'debug', '~> 1.10.0', platforms: %i[mri mingw x64_mingw]
gem 'debug', '~> 1.10.0', platforms: %i[mri windows]
gem 'rubocop', '~> 1.79.2', require: false
gem 'rubocop-performance', '~> 1.25.0', require: false
gem 'rubocop-rails', '~> 2.30.3', require: false
Expand Down
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ GEM
sendgrid-ruby (~> 6.4)
sendgrid-ruby (6.7.0)
ruby_http_client (~> 3.4)
sentry-rails (5.27.0)
railties (>= 5.0)
sentry-ruby (~> 5.27.0)
sentry-ruby (5.27.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (8.0.7)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
Expand Down Expand Up @@ -484,6 +490,8 @@ DEPENDENCIES
rubocop-rails (~> 2.30.3)
selenium-webdriver
sendgrid-actionmailer (~> 3.2)
sentry-rails
sentry-ruby
simple_form (~> 5.3)
simplecov
sitemap_generator
Expand Down
3 changes: 2 additions & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", "data-turbo-track": "reload", defer: true %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
</head>
<%= Sentry.get_trace_propagation_meta.html_safe %>
</head>

<body>
<div class="bg-gray-50">
Expand Down
2 changes: 1 addition & 1 deletion config/credentials/development.yml.enc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
8aarx+TyrBlNKpmhCscpazYTzl7FjCAXIymrbctgugXk0GhZgOe8qn7q8CMMqXtIQic/1Go9NrOpPsTarBqwryiFU3E+8Ioau5HJ6QzeFpTEhxwgZbf3QLR+A3Gvn3ocezPCuKSes41NgLyn3+2U7wo97Fsj8nfuZoVp6Yjgu41RLJLsWAt85h9LUZVfiji5BRmmB6lApbKKgN+CltCU3rIddPkzSMjnZTq6--To60MjyBkAuvd5hr--b+wIbJYh80wBZQu3vd0Ztg==
WNTiuJ7BJhj0UN5kdaod5zWlHAFjr+Y1kZVlDTYNKn2clMIkVapQOePwm8Foz4TKGfaNqr2YdciOd9hROYaDmHYJ73kZr+XqR6Dm7qNoYnB1T0NIzzsJuaM7MP8+1tmvrdEhUoLqA3qLY/MDDXTlB2cn2LWQNj85mKoqge2EtnT3cxX6tFSwn6UcnYUjqEeQpcwfXSrAgKSj7izq2YEcuK7/iPWFzaV0Wf4JkZO9uTB07Uf+a8ETsKX507XnjV5uuVoQ8kyRhJ1I9eHu+Y02EcfPcEVBrwXVTY1lANvNIHXt3d6p4P2KeaFXpYYVqkfnIfAaiglVALti9E3+zyz2EwqTWpu47uNYstk3F/st5RLtLxh/Z3t9QO2JX8arnP0+vg==--LicQNhOcd8wHNxFp--mEXqAhZdd75RlFQvCnzMTg==
2 changes: 1 addition & 1 deletion config/credentials/production.yml.enc
Original file line number Diff line number Diff line change
@@ -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==
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==
2 changes: 1 addition & 1 deletion config/credentials/test.yml.enc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
jLSHDQZgDcPaREiO8PZS868/YrqNEFgMEfu/tTnv06hidLE3FiN1eh41jbOWskdtlPrfxWdeC56Sc7R2exBE82sQMzq8buP4GtW+UpZzQ8L2CAsni6fIGzyStfkcuDvmbURilGu38gONw0UIDj1CwM7P8vvTrx4bEK/M10W4AFNpoezEz5HJClYMAOx75DeykoJTxxKtTY2doM0kCAxuk4ZcaQU64dvj4nA=--41zIAQnwOwg6KAdR--77ousxvNJxt9P2igxuVc8w==
bW0I+RamDskXcMNZk1GxQ1edWH4CrP1IBuHcCm8CdozPAgi86mklswlp8RDpzs4ieOHtX16+AUmEcO8ovHO5MYFyGlqPJGsAmQOSCw0aXZbpOcc4MIk8AHcY5tWEXr8paGb30yEWgUo6X99vUAGsoMgGyXH5RpsBXtRgrfmCatFLbTB2+YUyuuNOTSQN5CCd1x8Z0WYdXglTWDC808OR9nnhe2SeYi8Til9dUuQjTpzNkpAx5V8DM/ZHA/F/20M19STmv/7+fY8MdNC1BeYS+3KApJV9J6vZ+TerBZ1u5OLsvvZgJLJ/DAnviwecodnCaW2qgRhMf5TO6kMMOLj+y+XCG1Ej1iIHFo3XpRQGKxDQu7vcaCSohB5JuW1KQ8+Hew==--iwqOL1DTjBWJUgvw--VTb2Ecdr76/5BdItgNMHyQ==
45 changes: 45 additions & 0 deletions config/deploy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,48 @@
after :finishing, 'deploy:cleanup'
after :finishing, 'deploy:restart'
end

namespace :sentry do
desc 'Notify Sentry of a new release'
task :notify_release do
on roles(:web) do
within release_path do
begin
# Get Sentry credentials with validation
auth_token = capture(:bundle, :exec, :rails, :runner, "puts Rails.application.credentials.dig(:sentry, :auth_token)")
org = capture(:bundle, :exec, :rails, :runner, "puts Rails.application.credentials.dig(:sentry, :org)")

if auth_token.strip.empty? || org.strip.empty?
warn "Sentry credentials missing - skipping release notification"
next
end

# Create release and upload sourcemaps
release_version = capture(:git, 'rev-parse HEAD').strip

with rails_env: fetch(:rails_env),
SENTRY_AUTH_TOKEN: auth_token.strip,
SENTRY_ORG: org.strip do

# Create release
execute :bundle, :exec, :sentry, "releases new #{release_version}"

# Upload sourcemaps if assets exist
if test("[ -d #{release_path}/public/assets ]")
execute :bundle, :exec, :sentry, "releases files #{release_version} upload-sourcemaps ./public/assets --url-prefix '~/assets'"
end

# Finalize release
execute :bundle, :exec, :sentry, "releases finalize #{release_version}"
end

info "Sentry release #{release_version} created successfully"
rescue => e
warn "Sentry release notification failed: #{e.message}"
end
end
end
end
end

after 'deploy:published', 'sentry:notify_release'
137 changes: 137 additions & 0 deletions config/initializers/sentry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

# Sentry configuration for Rails 8
# Docs: https://docs.sentry.io/platforms/ruby/guides/rails/

sentry_dsn = Rails.application.credentials.dig(:sentry, :dsn)
sentry_enabled = Rails.application.credentials.dig(:sentry, :enabled) != false

if sentry_dsn && !sentry_dsn.empty? && sentry_enabled
Sentry.init do |config|
config.dsn = sentry_dsn
config.environment = Rails.env
config.enabled_environments = %w[production test development]

# Release tracking
config.release = begin
`git rev-parse HEAD 2>/dev/null`.strip.presence || "unknown"
rescue => e
Rails.logger.warn("Failed to get git revision: #{e.message}")
"unknown"
end

# Breadcrumbs and logging
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
config.sdk_logger = Rails.logger

# PII and data collection
config.send_default_pii = Rails.env.development?

# Performance monitoring - environment specific
traces_rate = Rails.application.credentials.dig(:sentry, :traces_sample_rate) ||
(Rails.env.production? ? 0.1 : 1.0)
profiles_rate = Rails.application.credentials.dig(:sentry, :profiles_sample_rate) ||
(Rails.env.production? ? 0.1 : 1.0)

config.traces_sample_rate = traces_rate
config.profiles_sample_rate = profiles_rate

# Enhanced performance sampling
config.traces_sampler = lambda do |sampling_context|
transaction_name = sampling_context[:transaction_context][:name]
case transaction_name
when /health|heartbeat|ping/
0.0 # Skip health checks
when /users\/(sign_in|sign_up|password)/
1.0 # Always trace authentication endpoints
when /projects|chapters|countries/
0.8 # High sampling for core content
when /admin/
0.5 # Sample admin pages at 50%
else
traces_rate
end
end

# Rails 8 specific instrumentation
config.rails.report_rescued_exceptions = true
config.instrumenter = :active_support

# Filter noise - common Rails exceptions
config.excluded_exceptions += %w[
ActionController::RoutingError
ActiveRecord::RecordNotFound
ActionController::InvalidAuthenticityToken
ActionController::UnknownFormat
ActionDispatch::Http::MimeNegotiation::InvalidType
Rack::QueryParser::ParameterTypeError
Rack::QueryParser::InvalidParameterError
]

# Transaction filtering
config.before_send_transaction = lambda do |event, hint|
# Skip asset requests and health checks
return nil if event.transaction&.match?(/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/)
return nil if event.transaction&.match?(/health|assets|favicon|robots\.txt/)
event
end

# Enhanced error context and filtering
config.before_send = lambda do |event, hint|
# Skip letter opener and development tools
return nil if event.request&.url&.match?(/letter_opener|web-console|__better_errors/)

# Add user context (non-PII)
if defined?(Current) && Current.respond_to?(:user) && Current.user
event.user = {
id: Current.user.id,
role: Current.user.role,
created_at: Current.user.created_at
}
end

# Add request context
if event.request
event.tags.merge!({
request_id: event.request.env['action_dispatch.request_id'],
user_agent: event.request.env['HTTP_USER_AGENT']&.truncate(100),
referer: event.request.env['HTTP_REFERER']&.truncate(200)
})
end

# Add Rails context
event.tags.merge!({
rails_version: Rails.version,
ruby_version: RUBY_VERSION,
environment: Rails.env
})

# Scrub sensitive data
if event.request&.data.is_a?(String)
event.request.data = event.request.data
.gsub(/password=[^&]+/i, 'password=[FILTERED]')
.gsub(/token=[^&]+/i, 'token=[FILTERED]')
.gsub(/api_key=[^&]+/i, 'api_key=[FILTERED]')
.gsub(/secret=[^&]+/i, 'secret=[FILTERED]')
end

event
end

# Test environment configuration
if Rails.env.test?
config.transport.transport_class = Sentry::DummyTransport
config.background_worker_threads = 0
end

# Production optimizations
if Rails.env.production?
config.background_worker_threads = 5
config.send_client_reports = true
end
end

Rails.logger.info "Sentry initialized for #{Rails.env} environment"
else
Rails.logger.warn "Sentry not initialized - DSN missing or disabled"
end
82 changes: 82 additions & 0 deletions test/initializers/sentry_initializer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

require 'test_helper'

class SentryInitializerTest < ActiveSupport::TestCase
def with_credentials(hash)
fake_credentials = Object.new
fake_credentials.define_singleton_method(:dig) do |*keys|
hash.dig(*keys)
end

Rails.application.stubs(:credentials).returns(fake_credentials)
yield
ensure
Rails.application.unstub(:credentials)
end

def reload_sentry_initializer
# Reset Sentry state before reloading initializer
begin
Sentry.close
rescue StandardError
# ignore if not initialized
end
# Reset configuration (be robust even if attr_writer isn't available)
if Sentry.respond_to?(:configuration=)
Sentry.configuration = Sentry::Configuration.new
else
Sentry.instance_variable_set(:@configuration, Sentry::Configuration.new)
end

load Rails.root.join('config/initializers/sentry.rb')
end

test 'does not initialize Sentry when credentials[:sentry][:dsn] is absent' do
with_credentials({}) do
reload_sentry_initializer
assert_equal false, Sentry.initialized?, 'Sentry should not initialize without DSN'
end
end

test 'does not initialize Sentry when enabled is false' do
with_credentials({ sentry: { dsn: 'https://[email protected]/1', enabled: false } }) do
reload_sentry_initializer
assert_equal false, Sentry.initialized?, 'Sentry should not initialize when disabled'
end
end

test 'initializes Sentry when credentials[:sentry][:dsn] is present and uses DummyTransport in test env' do
with_credentials({ sentry: { dsn: 'https://[email protected]/1', enabled: true } }) do
reload_sentry_initializer
assert_equal true, Sentry.initialized?, 'Sentry should initialize with DSN'
assert_includes Sentry.configuration.enabled_environments, 'test'
assert_includes Sentry.configuration.enabled_environments, 'production'
assert_includes Sentry.configuration.enabled_environments, 'development'

client = Sentry.get_current_client
assert_not_nil client, 'Sentry client should be present after initialization'
transport = client.transport
assert_instance_of Sentry::DummyTransport, transport, 'Transport should be DummyTransport in test'

# Test Rails 8 specific configuration
assert_equal true, Sentry.configuration.rails.report_rescued_exceptions
assert_equal :sentry, Sentry.configuration.instrumenter
assert_equal 0, Sentry.configuration.background_worker_threads
end
end

test 'configures performance monitoring with custom rates' do
with_credentials({
sentry: {
dsn: 'https://[email protected]/1',
traces_sample_rate: 0.5,
profiles_sample_rate: 0.3
}
}) do
reload_sentry_initializer
assert_equal 0.5, Sentry.configuration.traces_sample_rate
assert_equal 0.3, Sentry.configuration.profiles_sample_rate
end
end
end