From be9efdb2bf38f70563c8386fa9a770f0d4de9a24 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Fri, 27 Mar 2026 19:34:57 +0100 Subject: [PATCH 1/9] fix(web): harden observability env handling and Sentry log redaction (cherry picked from commit f5b50a62791d7b1d2bbcf334cb5df67ec73f5019) --- README.md | 5 +- app/web/api/v1/health.rb | 2 +- app/web/boot/sentry.rb | 56 ++++++++ app/web/boot/setup.rb | 40 +++++- app/web/config/environment_validator.rb | 40 +++++- app/web/config/runtime_env.rb | 92 +++++++++++++ app/web/security/auth.rb | 2 +- app/web/telemetry/app_logger.rb | 4 +- app/web/telemetry/sentry_logs.rb | 97 +++++++++++++ config.ru | 18 +-- docker-compose.yml | 3 + docs/README.md | 3 + spec/html2rss/web/api/v1_spec.rb | 15 ++ spec/html2rss/web/app_integration_spec.rb | 16 ++- spec/html2rss/web/app_logger_spec.rb | 29 ++++ spec/html2rss/web/boot/setup_spec.rb | 128 +++++++++++++++++- .../web/environment_validator_spec.rb | 44 ++++-- spec/html2rss/web/sentry_logs_spec.rb | 68 ++++++++++ spec/spec_helper.rb | 6 + spec/support/runtime_env_helpers.rb | 26 ++++ 20 files changed, 658 insertions(+), 36 deletions(-) create mode 100644 app/web/boot/sentry.rb create mode 100644 app/web/config/runtime_env.rb create mode 100644 app/web/telemetry/sentry_logs.rb create mode 100644 spec/html2rss/web/app_logger_spec.rb create mode 100644 spec/html2rss/web/sentry_logs_spec.rb create mode 100644 spec/support/runtime_env_helpers.rb diff --git a/README.md b/README.md index 85ec5130..83abac96 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut ## Deploy (Docker Compose) 1. Generate a key: `openssl rand -hex 32`. -2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`. -3. Start: `docker-compose up`. +2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, `BROWSERLESS_IO_API_TOKEN`, `BUILD_TAG`, and `GIT_SHA` in your shell or `.env`. +3. Optionally export `SENTRY_DSN` to send application errors and structured logs to Sentry. +4. Start: `docker-compose up`. UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. diff --git a/app/web/api/v1/health.rb b/app/web/api/v1/health.rb index ff7a325b..41299803 100644 --- a/app/web/api/v1/health.rb +++ b/app/web/api/v1/health.rb @@ -72,7 +72,7 @@ def authorize_health_check!(request) # @param request [Rack::Request] # @return [Boolean] def env_health_check_token?(request) - configured_token = ENV.fetch('HEALTH_CHECK_TOKEN', '').to_s + configured_token = RuntimeEnv.health_check_token.to_s provided_token = bearer_token(request) return false if configured_token.empty? || provided_token.nil? return false unless configured_token.bytesize == provided_token.bytesize diff --git a/app/web/boot/sentry.rb b/app/web/boot/sentry.rb new file mode 100644 index 00000000..d33b06c5 --- /dev/null +++ b/app/web/boot/sentry.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Boot + ## + # Configures Sentry boot-time error and structured log capture. + module Sentry + class << self + # @return [void] + def configure! + return unless configure? + + Bundler.require(:sentry) + require 'sentry-ruby' + initialize_sentry! + end + + private + + # @return [Boolean] + def configure? + RuntimeEnv.sentry_enabled? && !sentry_initialized? + end + + # @return [void] + def initialize_sentry! + ::Sentry.init do |config| + apply_settings(config) + end + end + + # @param config [Object] + # @return [void] + def apply_settings(config) + config.dsn = RuntimeEnv.sentry_dsn + config.environment = RuntimeEnv.rack_env + config.enable_logs = true + config.send_default_pii = false + config.release = release_name + end + + # @return [String] + def release_name + "#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}" + end + + # @return [Boolean] + def sentry_initialized? + defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized? + end + end + end + end + end +end diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb index edb5687d..cf807ecf 100644 --- a/app/web/boot/setup.rb +++ b/app/web/boot/setup.rb @@ -6,14 +6,24 @@ module Boot ## # Applies boot-time runtime configuration outside the Roda class body. module Setup + RACK_TIMEOUT_BUFFER_SECONDS = 5 + class << self + # @return [Boolean] + def sentry_enabled? + RuntimeEnv.sentry_enabled? + end + # Validates environment configuration and wires the request service. # # @return [void] def call! validate_environment! + capture_runtime_env! + configure_sentry! configure_request_service! configure_runtime_logging! + log_startup! end private @@ -25,9 +35,24 @@ def validate_environment! Flags.validate! end + # @return [void] + def capture_runtime_env! + RuntimeEnv.capture! + end + + # @return [void] + def configure_sentry! + Sentry.configure! + end + # @return [void] def configure_request_service! - nil + return unless defined?(Rack::Timeout) + return unless Rack::Timeout.respond_to?(:service_timeout=) + + Rack::Timeout.service_timeout = + Html2rss::RequestService::Policy::DEFAULTS[:total_timeout_seconds] + + RACK_TIMEOUT_BUFFER_SECONDS end # @return [void] @@ -36,6 +61,19 @@ def configure_runtime_logging! Rack::Timeout::Logger.logger = AppLogger.logger end + + # @return [void] + def log_startup! + AppLogger.logger.info( + { + component: 'boot', + event_name: 'app.start', + build_tag: RuntimeEnv.build_tag, + git_sha: RuntimeEnv.git_sha, + sentry_enabled: RuntimeEnv.sentry_enabled? + }.to_json + ) + end end end end diff --git a/app/web/config/environment_validator.rb b/app/web/config/environment_validator.rb index 4d80ee1a..11647188 100644 --- a/app/web/config/environment_validator.rb +++ b/app/web/config/environment_validator.rb @@ -5,7 +5,8 @@ module Web ## # Environment validation for html2rss-web # Handles validation of environment variables and configuration - module EnvironmentValidator + module EnvironmentValidator # rubocop:disable Metrics/ModuleLength + # rubocop:disable Metrics/ClassLength class << self ## # Validate required environment variables on startup @@ -28,6 +29,7 @@ def validate_production_security! validate_secret_key! validate_account_configuration! + validate_build_metadata! end # @return [Boolean] @@ -92,6 +94,15 @@ def validate_secret_key! exit 1 end + # @return [void] + def validate_build_metadata! + return unless missing_build_metadata? + + log_missing_build_metadata! + warn_lines(*missing_build_metadata_warning_lines) + exit 1 + end + def validate_account_configuration! accounts = AccountManager.accounts weak_tokens = accounts.select { |acc| acc[:token].length < 16 } @@ -128,7 +139,34 @@ def handle_weak_account_tokens!(weak_tokens) ) exit 1 end + + # @return [Boolean] + def missing_build_metadata? + build_metadata_values.any?(&:empty?) + end + + # @return [Array] + def build_metadata_values + %w[BUILD_TAG GIT_SHA].map { |key| ENV.fetch(key, '').strip } + end + + # @return [void] + def log_missing_build_metadata! + SecurityLogger.log_config_validation_failure( + 'build_metadata', + 'Missing BUILD_TAG or GIT_SHA' + ) + end + + # @return [Array] + def missing_build_metadata_warning_lines + [ + 'CRITICAL: Missing build metadata for production deployment!', + 'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.' + ] + end end + # rubocop:enable Metrics/ClassLength end end end diff --git a/app/web/config/runtime_env.rb b/app/web/config/runtime_env.rb new file mode 100644 index 00000000..45547821 --- /dev/null +++ b/app/web/config/runtime_env.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Captures boot-time environment configuration and scrubs selected secrets + # from the process environment after validation. + module RuntimeEnv + SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze + BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV].freeze + + class << self + # @return [void] + def capture! + @values = tracked_env_values.freeze # rubocop:disable ThreadSafety/ClassInstanceVariable + scrub_sensitive_env! + nil + end + + # @return [void] + def reset! + @values = nil # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @return [String] + def secret_key + fetch('HTML2RSS_SECRET_KEY') + end + + # @return [String] + def health_check_token + fetch('HEALTH_CHECK_TOKEN', '') + end + + # @return [String, nil] + def sentry_dsn + fetch('SENTRY_DSN', nil) + end + + # @return [Boolean] + def sentry_enabled? + !sentry_dsn.to_s.strip.empty? + end + + # @return [String] + def build_tag + fetch('BUILD_TAG', 'unknown') + end + + # @return [String] + def git_sha + fetch('GIT_SHA', 'unknown') + end + + # @return [String] + def rack_env + fetch('RACK_ENV', ENV.fetch('RACK_ENV', 'development')) + end + + private + + # @param key [String] + # @param default [Object] + # @return [Object] + def fetch(key, default = :__missing__) + return ENV.fetch(key) if ENV.key?(key) + + values = @values || {} # rubocop:disable ThreadSafety/ClassInstanceVariable + return values.fetch(key) if values.key?(key) + return default unless default == :__missing__ + + raise KeyError, "key not found: #{key}" + end + + # @return [Hash{String=>String}] + def tracked_env_values + (SENSITIVE_KEYS + BOOT_METADATA_KEYS).each_with_object({}) do |key, memo| + memo[key] = ENV[key] if ENV.key?(key) + end + end + + # @return [void] + def scrub_sensitive_env! + return nil if rack_env == 'test' + + SENSITIVE_KEYS.each { |key| ENV.delete(key) } + nil + end + end + end + end +end diff --git a/app/web/security/auth.rb b/app/web/security/auth.rb index 9f16ef75..528d2fa0 100644 --- a/app/web/security/auth.rb +++ b/app/web/security/auth.rb @@ -117,7 +117,7 @@ def with_validated_token(feed_token, url) # @return [String] def secret_key - ENV.fetch('HTML2RSS_SECRET_KEY') + RuntimeEnv.secret_key end # @param username [String, nil] diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb index 7281c5dc..c357c90d 100644 --- a/app/web/telemetry/app_logger.rb +++ b/app/web/telemetry/app_logger.rb @@ -35,7 +35,9 @@ def build_logger # @param message [String] # @return [String] def format_entry(severity, datetime, _progname, message) - "#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n" + payload = base_payload(severity, datetime).merge(normalize_message(message)) + SentryLogs.emit(payload) + "#{payload.to_json}\n" end # @param severity [String] diff --git a/app/web/telemetry/sentry_logs.rb b/app/web/telemetry/sentry_logs.rb new file mode 100644 index 00000000..865ee27f --- /dev/null +++ b/app/web/telemetry/sentry_logs.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Mirrors structured application logs into Sentry when log intake is + # enabled for the current runtime. + module SentryLogs + OMIT = Object.new.freeze + SENSITIVE_ATTRIBUTE_KEYS = %w[actor email ip remote_ip user_agent username x_forwarded_for].freeze + + class << self + # @param payload [Hash{Symbol=>Object}] + # @return [void] + def emit(payload) + return unless enabled? + + logger.public_send(level(payload), message(payload), **attributes(payload)) + rescue StandardError + nil + end + + private + + # @return [Boolean] + def enabled? + RuntimeEnv.sentry_enabled? && defined?(::Sentry) && !logger.nil? + end + + # @return [Object, nil] + def logger + return unless defined?(::Sentry) && ::Sentry.respond_to?(:logger) + + ::Sentry.logger + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Symbol] + def level(payload) + payload.fetch(:level, 'INFO').to_s.downcase.to_sym + end + + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def message(payload) + payload[:message] || payload[:event_name] || payload[:security_event] || + payload[:component] || 'html2rss-web log' + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def attributes(payload) + sanitize_hash(payload).tap do |attributes| + attributes.delete(:message) + attributes.delete(:level) + end + end + + # @param payload [Hash] + # @return [Hash] + def sanitize_hash(payload) + payload.each_with_object({}) do |(key, value), sanitized| + sanitized_value = sanitize_value(key, value) + next if sanitized_value.equal?(OMIT) + + sanitized[key] = sanitized_value + end + end + + # @param key [Object] + # @param value [Object] + # @return [Object] + def sanitize_value(key, value) + return OMIT if sensitive_key?(key) + return sanitize_hash(value) if value.is_a?(Hash) + return sanitize_array(key, value) if value.is_a?(Array) + + value + end + + # @param key [Object] + # @param values [Array] + # @return [Array] + def sanitize_array(key, values) + values.map { |entry| sanitize_value(key, entry) } + .reject { |entry| entry.equal?(OMIT) } + end + + # @param key [Object] + # @return [Boolean] + def sensitive_key?(key) + SENSITIVE_ATTRIBUTE_KEYS.include?(key.to_s) + end + end + end + end +end diff --git a/config.ru b/config.ru index 726d1fd1..afc1875f 100644 --- a/config.ru +++ b/config.ru @@ -4,26 +4,13 @@ require 'rubygems' require 'bundler/setup' require 'rack-timeout' require_relative 'app/web/boot/development_reloader' +require_relative 'app' -if ENV.key?('SENTRY_DSN') - Bundler.require(:sentry) - require 'sentry-ruby' - - Sentry.init do |config| - config.dsn = ENV.fetch('SENTRY_DSN') - - config.traces_sample_rate = 1.0 - config.profiles_sample_rate = 1.0 - end - - use Sentry::Rack::CaptureExceptions -end +use Sentry::Rack::CaptureExceptions if Html2rss::Web::Boot::Setup.sentry_enabled? dev = ENV.fetch('RACK_ENV', nil) == 'development' if dev - require_relative 'app' - run Html2rss::Web::Boot::DevelopmentReloader.new( loader: Html2rss::Web::Boot.loader, app_provider: -> { Html2rss::Web::App.app } @@ -31,7 +18,6 @@ if dev else use Rack::Timeout - require_relative 'app' Html2rss::Web::Boot.eager_load! run(Html2rss::Web::App.freeze.app) diff --git a/docker-compose.yml b/docker-compose.yml index 38273eaa..10b46862 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,11 @@ services: environment: RACK_ENV: production PORT: 4000 + BUILD_TAG: ${BUILD_TAG:?set BUILD_TAG} + GIT_SHA: ${GIT_SHA:?set GIT_SHA} HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY} HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:?set HEALTH_CHECK_TOKEN} + SENTRY_DSN: ${SENTRY_DSN:-} BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002 BROWSERLESS_IO_API_TOKEN: ${BROWSERLESS_IO_API_TOKEN:?set BROWSERLESS_IO_API_TOKEN} # Trial runs use the image's bundled config/feeds.yml. diff --git a/docs/README.md b/docs/README.md index d971cd39..d0c9aabe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -168,12 +168,15 @@ Managed flags and environment keys: | `async_feed_refresh_enabled` | `ASYNC_FEED_REFRESH_ENABLED` | boolean | `false` | | `async_feed_refresh_stale_factor` | `ASYNC_FEED_REFRESH_STALE_FACTOR` | integer `>= 1` | `3` | | `health_check_token` | `HEALTH_CHECK_TOKEN` | string | `nil` | +| `build_tag` | `BUILD_TAG` | string | `unknown` outside production | +| `git_sha` | `GIT_SHA` | string | `unknown` outside production | | `sentry_dsn` | `SENTRY_DSN` | string | `nil` | Rules: - Invalid managed flag values must fail fast at boot. - Unknown managed feature-style env keys must fail fast at boot. +- `BUILD_TAG` and `GIT_SHA` are required in production so startup logs can identify the deployed build. - Add or change flags in code, tests, and this table together. --- diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 894ce2f5..201a90cb 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -227,6 +227,21 @@ def expected_featured_feeds end end + it 'returns health status after production-style env scrubbing', :aggregate_failures do + capture_scrubbed_runtime_env( + 'RACK_ENV' => 'production', + 'HEALTH_CHECK_TOKEN' => 'scrubbed-health-token' + ) do + header 'Authorization', 'Bearer scrubbed-health-token' + get '/api/v1/health' + + expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to be_nil + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'health', 'status')).to eq('healthy') + end + end + it 'returns error when configuration fails', :aggregate_failures do allow(Html2rss::Web::Auth).to receive(:authenticate).and_return({ username: 'health-check' }) allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'boom') diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 73df26b3..86f3a6c4 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' require 'rack/test' require 'cgi' +require 'climate_control' require 'json' require 'securerandom' require_relative '../../../app' @@ -11,7 +12,6 @@ include Rack::Test::Methods let(:app) { described_class.freeze.app } - let(:secret_key) { ENV.fetch('HTML2RSS_SECRET_KEY') } let(:feed_url) { 'https://example.com/articles' } let(:feed_token) do @@ -111,6 +111,20 @@ expect(last_response.headers['Vary']).to include('Accept') end + it 'validates feed tokens after production-style env scrubbing', :aggregate_failures do + capture_scrubbed_runtime_env( + 'RACK_ENV' => 'production', + 'HTML2RSS_SECRET_KEY' => 'scrubbed-secret-key-for-request-specs' + ) do + generated_token = Html2rss::Web::Auth.generate_feed_token(account[:username], feed_url, strategy: 'faraday') + get "/api/v1/feeds/#{generated_token}", {}, { 'HTTP_ACCEPT' => 'application/xml' } + + expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to be_nil + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to eq('application/xml') + end + end + it 'prefers the path extension over Accept negotiation' do header 'Accept', 'application/feed+json' get "/api/v1/feeds/#{feed_token}.xml" diff --git a/spec/html2rss/web/app_logger_spec.rb b/spec/html2rss/web/app_logger_spec.rb new file mode 100644 index 00000000..833fbedd --- /dev/null +++ b/spec/html2rss/web/app_logger_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'climate_control' +require 'stringio' + +require_relative '../../../app/web/config/runtime_env' +require_relative '../../../app/web/telemetry/sentry_logs' +require_relative '../../../app/web/telemetry/app_logger' + +RSpec.describe Html2rss::Web::AppLogger do + let(:io) { StringIO.new } + let(:test_logger) { Logger.new(io).tap { |log| log.formatter = described_class.send(:method, :format_entry) } } + + describe '.logger' do + it 'forwards structured logs to the Sentry log bridge', :aggregate_failures do + allow(Logger).to receive(:new).and_return(test_logger) + allow(Html2rss::Web::SentryLogs).to receive(:emit) + + described_class.reset_logger! + described_class.logger.info({ event_name: 'boot.test', component: 'boot' }.to_json) + + expect(Html2rss::Web::SentryLogs).to have_received(:emit).with( + include(component: 'boot', event_name: 'boot.test', service: 'html2rss-web') + ) + expect(io.string).to include('"event_name":"boot.test"') + end + end +end diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb index c5c2461f..dc55726a 100644 --- a/spec/html2rss/web/boot/setup_spec.rb +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -1,18 +1,32 @@ # frozen_string_literal: true require 'spec_helper' +require 'climate_control' require_relative '../../../../app' RSpec.describe Html2rss::Web::Boot::Setup do + let(:boot_secret_key) { 'secret-key-123456789012345678901234' } + let(:sentry_dsn) { 'https://example@sentry.invalid/1' } + let(:boot_env) do + { + 'RACK_ENV' => 'development', + 'HTML2RSS_SECRET_KEY' => boot_secret_key, + 'BUILD_TAG' => '2026-03-27', + 'GIT_SHA' => 'abc1234' + } + end + before do - allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) - allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) allow(Html2rss::Web::Flags).to receive(:validate!) + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!) end describe '.call!' do it 'validates environment state', :aggregate_failures do + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) + described_class.call! expect(Html2rss::Web::EnvironmentValidator).to have_received(:validate_environment!).once @@ -30,5 +44,115 @@ expect(Rack::Timeout::Logger.logger).to be(Html2rss::Web::AppLogger.logger) end + + it 'captures and scrubs sensitive env vars after validation', :aggregate_failures do + stub_environment_validation + + ClimateControl.modify(scrubbed_env) do + described_class.call! + + expect_runtime_env_to_match_boot_values + expect_sensitive_env_to_be_scrubbed + end + end + + it 'configures Sentry for errors and logs when a DSN is present', :aggregate_failures do + stub_environment_validation + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original + allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) + allow(Bundler).to receive(:require) + fake_sentry = build_fake_sentry + stub_const('Sentry', fake_sentry) + + ClimateControl.modify(boot_env.merge('SENTRY_DSN' => sentry_dsn)) do + described_class.call! + end + + expect_sentry_to_be_configured + end + + it 'logs build metadata on startup' do + stub_environment_validation + logger = instance_double(Logger, info: nil) + allow(Html2rss::Web::AppLogger).to receive(:logger).and_return(logger) + + ClimateControl.modify(boot_env) do + described_class.call! + end + + expect(logger).to have_received(:info).with( + a_string_including('"build_tag":"2026-03-27"', '"git_sha":"abc1234"', '"event_name":"app.start"') + ) + end + end + + def stub_environment_validation + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) + end + + def scrubbed_env + boot_env.merge( + 'HEALTH_CHECK_TOKEN' => 'health-token', + 'SENTRY_DSN' => sentry_dsn + ) + end + + def expect_runtime_env_to_match_boot_values + expect(Html2rss::Web::RuntimeEnv.secret_key).to eq(boot_secret_key) + expect(Html2rss::Web::RuntimeEnv.health_check_token).to eq('health-token') + expect(Html2rss::Web::RuntimeEnv.sentry_dsn).to eq(sentry_dsn) + end + + def expect_sensitive_env_to_be_scrubbed + expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to be_nil + expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to be_nil + expect(ENV.fetch('SENTRY_DSN', nil)).to be_nil + end + + def expect_sentry_to_be_configured + expect(Bundler).to have_received(:require).with(:sentry) + expect_sentry_config(:dsn, sentry_dsn) + expect_sentry_config(:enable_logs, true) + expect_sentry_config(:release, '2026-03-27+abc1234') + end + + def build_fake_sentry + captured_config = nil + fake_logger = build_fake_sentry_logger + config_factory = method(:build_fake_sentry_config) + + Module.new.tap do |fake_sentry| + define_fake_sentry_accessors(fake_sentry, fake_logger, -> { captured_config }) + define_fake_sentry_init(fake_sentry, config_factory, ->(config) { captured_config = config }) + end + end + + def define_fake_sentry_accessors(fake_sentry, fake_logger, captured_config) + fake_sentry.define_singleton_method(:initialized?) { false } + fake_sentry.define_singleton_method(:logger) { fake_logger } + fake_sentry.define_singleton_method(:captured_config) { captured_config.call } + end + + def define_fake_sentry_init(fake_sentry, config_factory, assign_config) + fake_sentry.define_singleton_method(:init) do |&block| + config = config_factory.call + assign_config.call(config) + block.call(config) + end + end + + def expect_sentry_config(attribute, expected_value) + expect(Sentry.captured_config.public_send(attribute)).to eq(expected_value) + end + + def build_fake_sentry_logger + Class.new do + def info(*) = nil + end.new + end + + def build_fake_sentry_config + Struct.new(:dsn, :environment, :enable_logs, :send_default_pii, :release).new end end diff --git a/spec/html2rss/web/environment_validator_spec.rb b/spec/html2rss/web/environment_validator_spec.rb index fa7e0248..75e3b4c7 100644 --- a/spec/html2rss/web/environment_validator_spec.rb +++ b/spec/html2rss/web/environment_validator_spec.rb @@ -5,13 +5,18 @@ require_relative '../../../app/web/config/environment_validator' require_relative '../../../app/web/config/flags' +require_relative '../../../app/web/security/account_manager' require_relative '../../../app/web/security/security_logger' RSpec.describe Html2rss::Web::EnvironmentValidator do + def stub_validation_logging + allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) + allow(Kernel).to receive(:warn) + end + describe '.validate_environment!' do it 'sets a development default secret key without exiting' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) + stub_validation_logging ClimateControl.modify('RACK_ENV' => 'development', 'HTML2RSS_SECRET_KEY' => nil) do described_class.validate_environment! @@ -20,8 +25,7 @@ end it 'logs development default secret key warnings' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) + stub_validation_logging ClimateControl.modify('RACK_ENV' => 'development', 'HTML2RSS_SECRET_KEY' => nil) do described_class.validate_environment! @@ -32,8 +36,7 @@ end it 'logs missing production secret key failures before exiting' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) + stub_validation_logging ClimateControl.modify('RACK_ENV' => 'production', 'HTML2RSS_SECRET_KEY' => nil) do expect { described_class.validate_environment! }.to raise_error(SystemExit) @@ -46,16 +49,37 @@ describe '.validate_production_security!' do it 'logs weak production secret keys before exiting' do - allow(Html2rss::Web::SecurityLogger).to receive(:log_config_validation_failure) - allow(Kernel).to receive(:warn) - - ClimateControl.modify('RACK_ENV' => 'production', 'HTML2RSS_SECRET_KEY' => 'short-secret') do + stub_validation_logging + + ClimateControl.modify( + 'RACK_ENV' => 'production', + 'HTML2RSS_SECRET_KEY' => 'short-secret', + 'BUILD_TAG' => '2026-03-27', + 'GIT_SHA' => 'abc1234' + ) do expect { described_class.validate_production_security! }.to raise_error(SystemExit) end expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure) .with('secret_key', 'Invalid or weak secret key') end + + it 'logs missing build metadata before exiting' do + stub_validation_logging + allow(Html2rss::Web::AccountManager).to receive(:accounts).and_return([]) + + ClimateControl.modify( + 'RACK_ENV' => 'production', + 'HTML2RSS_SECRET_KEY' => '0123456789abcdef0123456789abcdef', + 'BUILD_TAG' => nil, + 'GIT_SHA' => nil + ) do + expect { described_class.validate_production_security! }.to raise_error(SystemExit) + end + + expect(Html2rss::Web::SecurityLogger).to have_received(:log_config_validation_failure) + .with('build_metadata', 'Missing BUILD_TAG or GIT_SHA') + end end describe '.auto_source_enabled?' do diff --git a/spec/html2rss/web/sentry_logs_spec.rb b/spec/html2rss/web/sentry_logs_spec.rb new file mode 100644 index 00000000..23218491 --- /dev/null +++ b/spec/html2rss/web/sentry_logs_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../app/web/config/runtime_env' +require_relative '../../../app/web/telemetry/sentry_logs' + +RSpec.describe Html2rss::Web::SentryLogs do + let(:captured_call) { {} } + let(:sentry_logger) { build_sentry_logger } + let(:fake_sentry) do + Module.new.tap do |mod| + mod.define_singleton_method(:logger) { sentry_logger } + end + end + let(:raw_payload) do + { + event_name: 'auth.authenticate', + actor: 'alice', + details: { + username: 'alice', + ip: '127.0.0.1', + user_agent: 'curl/8.7.1', + reason: 'missing_token', + nested: [{ username: 'bob', ip: '10.0.0.2' }] + } + } + end + + it 'filters auth and security pii before forwarding payloads to Sentry', :aggregate_failures do + stub_const('Sentry', fake_sentry) + allow(described_class).to receive_messages(enabled?: true, logger: sentry_logger) + + described_class.emit(raw_payload) + + expect_forwarded_payload + end + + def build_sentry_logger + logger_class = Struct.new(:captured_call) do + def info(message, **attributes) + captured_call[:message] = message + captured_call[:attributes] = attributes + end + end + + logger_class.new(captured_call).tap do |logger| + allow(logger).to receive(:info).and_call_original + end + end + + def expect_forwarded_payload + expect(sentry_logger).to have_received(:info) + expect_forwarded_message + expect_forwarded_attributes + end + + def expect_forwarded_message + expect(captured_call.fetch(:message)).to eq('auth.authenticate') + end + + def expect_forwarded_attributes + attributes = captured_call.fetch(:attributes) + expect(attributes).to include(event_name: 'auth.authenticate') + expect(attributes.fetch(:details)).to eq(reason: 'missing_token', nested: [{}]) + expect(attributes).not_to have_key(:actor) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ee5b787..4f48479b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -47,12 +47,18 @@ RSpec.configure do |config| # Reset SecurityLogger before each test to avoid double leakage config.before do + ENV['HTML2RSS_SECRET_KEY'] ||= 'test-secret-key-for-specs' # Only reset if SecurityLogger is defined (loaded) Html2rss::Web::SecurityLogger.reset_logger! if defined?(Html2rss::Web::SecurityLogger) + Html2rss::Web::RuntimeEnv.reset! if defined?(Html2rss::Web::RuntimeEnv) Html2rss::Web::AccountManager.reload! if defined?(Html2rss::Web::AccountManager) Html2rss::Web::LocalConfig.reload! if defined?(Html2rss::Web::LocalConfig) end + config.after do + Html2rss::Web::RuntimeEnv.reset! if defined?(Html2rss::Web::RuntimeEnv) + end + # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. diff --git a/spec/support/runtime_env_helpers.rb b/spec/support/runtime_env_helpers.rb new file mode 100644 index 00000000..9c5be65b --- /dev/null +++ b/spec/support/runtime_env_helpers.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RuntimeEnvHelpers + def capture_scrubbed_runtime_env(env_overrides) + ClimateControl.modify(preserved_runtime_env.merge(env_overrides)) do + Html2rss::Web::RuntimeEnv.reset! + Html2rss::Web::RuntimeEnv.capture! + yield + end + ensure + Html2rss::Web::RuntimeEnv.reset! + end + + private + + def preserved_runtime_env + %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].each_with_object({}) do |key, env| + value = ENV.fetch(key, nil) + env[key] = value unless value.nil? + end + end +end + +RSpec.configure do |config| + config.include RuntimeEnvHelpers +end From 1934b7d744d9153b1e50822d988cae7ed9a433a2 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:23:22 +0100 Subject: [PATCH 2/9] Make Sentry logs opt-in and update deployment docs --- README.md | 7 +++--- app/web/boot/sentry.rb | 2 +- app/web/config/runtime_env.rb | 19 +++++++++++++- docker-compose.yml | 1 + docs/README.md | 1 + spec/html2rss/web/boot/setup_spec.rb | 37 +++++++++++++++++++++++++--- spec/support/runtime_env_helpers.rb | 2 +- 7 files changed, 60 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 83abac96..7a8e6787 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,10 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut ## Deploy (Docker Compose) 1. Generate a key: `openssl rand -hex 32`. -2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, `BROWSERLESS_IO_API_TOKEN`, `BUILD_TAG`, and `GIT_SHA` in your shell or `.env`. -3. Optionally export `SENTRY_DSN` to send application errors and structured logs to Sentry. -4. Start: `docker-compose up`. +2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`. +3. Optionally export `SENTRY_DSN` to send application errors to Sentry. +4. Optionally export `SENTRY_ENABLE_LOGS=true` to forward structured application logs to Sentry. +5. Start: `docker-compose up`. UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. diff --git a/app/web/boot/sentry.rb b/app/web/boot/sentry.rb index d33b06c5..e69dc82b 100644 --- a/app/web/boot/sentry.rb +++ b/app/web/boot/sentry.rb @@ -35,7 +35,7 @@ def initialize_sentry! def apply_settings(config) config.dsn = RuntimeEnv.sentry_dsn config.environment = RuntimeEnv.rack_env - config.enable_logs = true + config.enable_logs = RuntimeEnv.sentry_logs_enabled? config.send_default_pii = false config.release = release_name end diff --git a/app/web/config/runtime_env.rb b/app/web/config/runtime_env.rb index 45547821..0760ad83 100644 --- a/app/web/config/runtime_env.rb +++ b/app/web/config/runtime_env.rb @@ -7,7 +7,7 @@ module Web # from the process environment after validation. module RuntimeEnv SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze - BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV].freeze + BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze class << self # @return [void] @@ -42,6 +42,11 @@ def sentry_enabled? !sentry_dsn.to_s.strip.empty? end + # @return [Boolean] + def sentry_logs_enabled? + parse_boolean(fetch('SENTRY_ENABLE_LOGS', 'false'), default: false) + end + # @return [String] def build_tag fetch('BUILD_TAG', 'unknown') @@ -86,6 +91,18 @@ def scrub_sensitive_env! SENSITIVE_KEYS.each { |key| ENV.delete(key) } nil end + + # @param value [Object] + # @param default [Boolean] + # @return [Boolean] + def parse_boolean(value, default:) + normalized = value.to_s.strip.downcase + return true if normalized == 'true' + return false if normalized == 'false' + return default if normalized.empty? + + raise ArgumentError, "Malformed env 'SENTRY_ENABLE_LOGS': expected true/false, got '#{value}'" + end end end end diff --git a/docker-compose.yml b/docker-compose.yml index 10b46862..95eeaee2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY} HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:?set HEALTH_CHECK_TOKEN} SENTRY_DSN: ${SENTRY_DSN:-} + SENTRY_ENABLE_LOGS: ${SENTRY_ENABLE_LOGS:-false} BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002 BROWSERLESS_IO_API_TOKEN: ${BROWSERLESS_IO_API_TOKEN:?set BROWSERLESS_IO_API_TOKEN} # Trial runs use the image's bundled config/feeds.yml. diff --git a/docs/README.md b/docs/README.md index d0c9aabe..318d3afb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -171,6 +171,7 @@ Managed flags and environment keys: | `build_tag` | `BUILD_TAG` | string | `unknown` outside production | | `git_sha` | `GIT_SHA` | string | `unknown` outside production | | `sentry_dsn` | `SENTRY_DSN` | string | `nil` | +| `sentry_enable_logs` | `SENTRY_ENABLE_LOGS` | boolean | `false` | Rules: diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb index dc55726a..36fc123a 100644 --- a/spec/html2rss/web/boot/setup_spec.rb +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -13,7 +13,8 @@ 'RACK_ENV' => 'development', 'HTML2RSS_SECRET_KEY' => boot_secret_key, 'BUILD_TAG' => '2026-03-27', - 'GIT_SHA' => 'abc1234' + 'GIT_SHA' => 'abc1234', + 'SENTRY_ENABLE_LOGS' => nil } end @@ -56,7 +57,7 @@ end end - it 'configures Sentry for errors and logs when a DSN is present', :aggregate_failures do + it 'configures Sentry for error reporting when a DSN is present', :aggregate_failures do stub_environment_validation allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) @@ -71,6 +72,36 @@ expect_sentry_to_be_configured end + it 'enables Sentry logs when SENTRY_ENABLE_LOGS is true', :aggregate_failures do + stub_environment_validation + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original + allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) + allow(Bundler).to receive(:require) + fake_sentry = build_fake_sentry + stub_const('Sentry', fake_sentry) + + ClimateControl.modify(boot_env.merge('SENTRY_DSN' => sentry_dsn, 'SENTRY_ENABLE_LOGS' => 'true')) do + described_class.call! + end + + expect_sentry_config(:enable_logs, true) + end + + it 'fails fast when SENTRY_ENABLE_LOGS is malformed' do + stub_environment_validation + allow(Html2rss::Web::Boot::Sentry).to receive(:configure!).and_call_original + allow(Html2rss::Web::Boot::Sentry).to receive(:require).with('sentry-ruby').and_return(true) + allow(Bundler).to receive(:require) + fake_sentry = build_fake_sentry + stub_const('Sentry', fake_sentry) + + expect do + ClimateControl.modify(boot_env.merge('SENTRY_DSN' => sentry_dsn, 'SENTRY_ENABLE_LOGS' => '1')) do + described_class.call! + end + end.to raise_error(ArgumentError, /SENTRY_ENABLE_LOGS/) + end + it 'logs build metadata on startup' do stub_environment_validation logger = instance_double(Logger, info: nil) @@ -113,7 +144,7 @@ def expect_sensitive_env_to_be_scrubbed def expect_sentry_to_be_configured expect(Bundler).to have_received(:require).with(:sentry) expect_sentry_config(:dsn, sentry_dsn) - expect_sentry_config(:enable_logs, true) + expect_sentry_config(:enable_logs, false) expect_sentry_config(:release, '2026-03-27+abc1234') end diff --git a/spec/support/runtime_env_helpers.rb b/spec/support/runtime_env_helpers.rb index 9c5be65b..e8188718 100644 --- a/spec/support/runtime_env_helpers.rb +++ b/spec/support/runtime_env_helpers.rb @@ -14,7 +14,7 @@ def capture_scrubbed_runtime_env(env_overrides) private def preserved_runtime_env - %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].each_with_object({}) do |key, env| + %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN SENTRY_ENABLE_LOGS].each_with_object({}) do |key, env| value = ENV.fetch(key, nil) env[key] = value unless value.nil? end From bebb6676046d07440244b76f75bff7f8df8cc125 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:38:17 +0100 Subject: [PATCH 3/9] fix(web): enforce opt-in sentry log forwarding --- app/web/telemetry/app_logger.rb | 10 +++++++++- app/web/telemetry/sentry_logs.rb | 5 ++++- spec/html2rss/web/app_logger_spec.rb | 12 +++++++++++- spec/html2rss/web/boot/setup_spec.rb | 8 +++++++- spec/html2rss/web/sentry_logs_spec.rb | 27 +++++++++++++++++++++------ 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb index c357c90d..f3501bea 100644 --- a/app/web/telemetry/app_logger.rb +++ b/app/web/telemetry/app_logger.rb @@ -36,7 +36,7 @@ def build_logger # @return [String] def format_entry(severity, datetime, _progname, message) payload = base_payload(severity, datetime).merge(normalize_message(message)) - SentryLogs.emit(payload) + emit_to_sentry(payload) "#{payload.to_json}\n" end @@ -97,6 +97,14 @@ def normalize_logfmt_value(raw_value) value end + + # @param payload [Hash{Symbol=>Object}] + # @return [void] + def emit_to_sentry(payload) + SentryLogs.emit(payload) + rescue StandardError + nil + end end end end diff --git a/app/web/telemetry/sentry_logs.rb b/app/web/telemetry/sentry_logs.rb index 865ee27f..7143808b 100644 --- a/app/web/telemetry/sentry_logs.rb +++ b/app/web/telemetry/sentry_logs.rb @@ -24,7 +24,10 @@ def emit(payload) # @return [Boolean] def enabled? - RuntimeEnv.sentry_enabled? && defined?(::Sentry) && !logger.nil? + RuntimeEnv.sentry_enabled? && + RuntimeEnv.sentry_logs_enabled? && + defined?(::Sentry) && + !logger.nil? end # @return [Object, nil] diff --git a/spec/html2rss/web/app_logger_spec.rb b/spec/html2rss/web/app_logger_spec.rb index 833fbedd..5a1fbabd 100644 --- a/spec/html2rss/web/app_logger_spec.rb +++ b/spec/html2rss/web/app_logger_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'spec_helper' -require 'climate_control' require 'stringio' require_relative '../../../app/web/config/runtime_env' @@ -25,5 +24,16 @@ ) expect(io.string).to include('"event_name":"boot.test"') end + + it 'still writes structured logs when the Sentry bridge raises' do + allow(Logger).to receive(:new).and_return(test_logger) + allow(Html2rss::Web::SentryLogs).to receive(:emit).and_raise(StandardError, 'boom') + + described_class.reset_logger! + expect do + described_class.logger.info({ event_name: 'boot.test', component: 'boot' }.to_json) + end.not_to raise_error + expect(io.string).to include('"event_name":"boot.test"') + end end end diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb index 36fc123a..433b10aa 100644 --- a/spec/html2rss/web/boot/setup_spec.rb +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -47,7 +47,13 @@ end it 'captures and scrubs sensitive env vars after validation', :aggregate_failures do - stub_environment_validation + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!).ordered do + expect(ENV.fetch('HTML2RSS_SECRET_KEY', nil)).to eq(boot_secret_key) + expect(ENV.fetch('HEALTH_CHECK_TOKEN', nil)).to eq('health-token') + end + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!).ordered do + expect(ENV.fetch('SENTRY_DSN', nil)).to eq(sentry_dsn) + end ClimateControl.modify(scrubbed_env) do described_class.call! diff --git a/spec/html2rss/web/sentry_logs_spec.rb b/spec/html2rss/web/sentry_logs_spec.rb index 23218491..3f6368ae 100644 --- a/spec/html2rss/web/sentry_logs_spec.rb +++ b/spec/html2rss/web/sentry_logs_spec.rb @@ -29,13 +29,30 @@ it 'filters auth and security pii before forwarding payloads to Sentry', :aggregate_failures do stub_const('Sentry', fake_sentry) - allow(described_class).to receive_messages(enabled?: true, logger: sentry_logger) - + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true) + allow(described_class).to receive(:logger).and_return(sentry_logger) + expect(described_class.send(:enabled?)).to be(true) described_class.emit(raw_payload) expect_forwarded_payload end + it 'does not forward payloads when sentry logs are disabled' do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: false) + described_class.emit(raw_payload) + + expect(captured_call).to eq({}) + end + + it 'does not forward payloads when sentry is disabled' do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: false, sentry_logs_enabled?: true) + described_class.emit(raw_payload) + + expect(captured_call).to eq({}) + end + def build_sentry_logger logger_class = Struct.new(:captured_call) do def info(message, **attributes) @@ -44,13 +61,11 @@ def info(message, **attributes) end end - logger_class.new(captured_call).tap do |logger| - allow(logger).to receive(:info).and_call_original - end + logger_class.new(captured_call) end def expect_forwarded_payload - expect(sentry_logger).to have_received(:info) + expect(captured_call).to include(:message, :attributes) expect_forwarded_message expect_forwarded_attributes end From 1c9af0380e3c4c54562a02d73d84955d0d03a3f6 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:40:32 +0100 Subject: [PATCH 4/9] chore(openapi): sync generated contract artifacts --- frontend/src/api/generated/types.gen.ts | 2 +- public/openapi.yaml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 49422cdd..be442f6c 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -177,7 +177,7 @@ export type GetHealthStatusError = GetHealthStatusErrors[keyof GetHealthStatusEr export type GetHealthStatusResponses = { /** - * returns health status when the configured environment token is valid + * returns health status after production-style env scrubbing */ 200: { data: { diff --git a/public/openapi.yaml b/public/openapi.yaml index 714d08fd..2bb7f6d1 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -331,8 +331,7 @@ paths: - success - data type: object - description: returns health status when the configured environment token - is valid + description: returns health status after production-style env scrubbing '401': content: application/json: From fd4bf7c9d7850688fe23453333930c5edcc80cd2 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:45:33 +0100 Subject: [PATCH 5/9] fix(ci): provide build metadata in docker smoke task --- Rakefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Rakefile b/Rakefile index 727158dd..c2da4b61 100644 --- a/Rakefile +++ b/Rakefile @@ -46,6 +46,8 @@ desc 'Build and run docker image/container, and send requests to it' task :test do current_dir = ENV.fetch('GITHUB_WORKSPACE', __dir__) smoke_auto_source_enabled = ENV.fetch('SMOKE_AUTO_SOURCE_ENABLED', 'false') + smoke_build_tag = ENV.fetch('SMOKE_BUILD_TAG', ENV.fetch('BUILD_TAG', 'docker-smoke')) + smoke_git_sha = ENV.fetch('SMOKE_GIT_SHA', ENV.fetch('GITHUB_SHA', ENV.fetch('GIT_SHA', 'docker-smoke'))) image_name = 'html2rss/web' skip_build = ENV.fetch('DOCKER_SMOKE_SKIP_BUILD', 'false') == 'true' @@ -60,6 +62,8 @@ task :test do '-d', '-p 4000:4000', '--env PUMA_LOG_CONFIG=1', + "--env BUILD_TAG=#{smoke_build_tag}", + "--env GIT_SHA=#{smoke_git_sha}", '--env HEALTH_CHECK_TOKEN=CHANGE_ME_HEALTH_CHECK_TOKEN', '--env HTML2RSS_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', "--env AUTO_SOURCE_ENABLED=#{smoke_auto_source_enabled}", From ef57f1aa6f8c2c3d91f2f1ae4beba95aa9a40b81 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:49:07 +0100 Subject: [PATCH 6/9] fix(observability): harden sentry log bridge and docs --- README.md | 11 +++++++--- app/web/telemetry/app_logger.rb | 8 +++++++ app/web/telemetry/sentry_logs.rb | 6 +++++- public/openapi.yaml | 2 +- spec/html2rss/web/app_logger_spec.rb | 11 ++++++++++ spec/html2rss/web/sentry_logs_spec.rb | 31 +++++++++++++++++++++------ 6 files changed, 57 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7a8e6787..95ff75d4 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,14 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut 1. Generate a key: `openssl rand -hex 32`. 2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`. -3. Optionally export `SENTRY_DSN` to send application errors to Sentry. -4. Optionally export `SENTRY_ENABLE_LOGS=true` to forward structured application logs to Sentry. -5. Start: `docker-compose up`. +3. Export build metadata required by `docker-compose.yml`: + ```bash + export BUILD_TAG=local + export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" + ``` +4. Optionally export `SENTRY_DSN` to send application errors to Sentry. +5. Optionally export `SENTRY_ENABLE_LOGS=true` to forward structured application logs to Sentry. +6. Start: `docker-compose up`. UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb index f3501bea..8724bab6 100644 --- a/app/web/telemetry/app_logger.rb +++ b/app/web/telemetry/app_logger.rb @@ -101,10 +101,18 @@ def normalize_logfmt_value(raw_value) # @param payload [Hash{Symbol=>Object}] # @return [void] def emit_to_sentry(payload) + return unless sentry_payload?(payload) + SentryLogs.emit(payload) rescue StandardError nil end + + # @param payload [Hash{Symbol=>Object}] + # @return [Boolean] + def sentry_payload?(payload) + payload.key?(:event_name) || payload.key?(:security_event) + end end end end diff --git a/app/web/telemetry/sentry_logs.rb b/app/web/telemetry/sentry_logs.rb index 7143808b..f6d55353 100644 --- a/app/web/telemetry/sentry_logs.rb +++ b/app/web/telemetry/sentry_logs.rb @@ -7,6 +7,7 @@ module Web # enabled for the current runtime. module SentryLogs OMIT = Object.new.freeze + ALLOWED_LEVELS = %i[debug info warn error fatal].freeze SENSITIVE_ATTRIBUTE_KEYS = %w[actor email ip remote_ip user_agent username x_forwarded_for].freeze class << self @@ -40,7 +41,10 @@ def logger # @param payload [Hash{Symbol=>Object}] # @return [Symbol] def level(payload) - payload.fetch(:level, 'INFO').to_s.downcase.to_sym + requested_level = payload.fetch(:level, 'INFO').to_s.downcase.to_sym + return requested_level if ALLOWED_LEVELS.include?(requested_level) + + :info end # @param payload [Hash{Symbol=>Object}] diff --git a/public/openapi.yaml b/public/openapi.yaml index 2bb7f6d1..ac69458e 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -331,7 +331,7 @@ paths: - success - data type: object - description: returns health status after production-style env scrubbing + description: returns current health status when a valid bearer token is provided '401': content: application/json: diff --git a/spec/html2rss/web/app_logger_spec.rb b/spec/html2rss/web/app_logger_spec.rb index 5a1fbabd..a2557126 100644 --- a/spec/html2rss/web/app_logger_spec.rb +++ b/spec/html2rss/web/app_logger_spec.rb @@ -35,5 +35,16 @@ end.not_to raise_error expect(io.string).to include('"event_name":"boot.test"') end + + it 'does not forward plain string logs to the Sentry bridge' do + allow(Logger).to receive(:new).and_return(test_logger) + allow(Html2rss::Web::SentryLogs).to receive(:emit) + + described_class.reset_logger! + described_class.logger.info('plain-text log line with request details') + + expect(Html2rss::Web::SentryLogs).not_to have_received(:emit) + expect(io.string).to include('"message":"plain-text log line with request details"') + end end end diff --git a/spec/html2rss/web/sentry_logs_spec.rb b/spec/html2rss/web/sentry_logs_spec.rb index 3f6368ae..27d5bb28 100644 --- a/spec/html2rss/web/sentry_logs_spec.rb +++ b/spec/html2rss/web/sentry_logs_spec.rb @@ -6,6 +6,18 @@ require_relative '../../../app/web/telemetry/sentry_logs' RSpec.describe Html2rss::Web::SentryLogs do + let(:logger_class) do + Struct.new(:captured_call) do + %i[debug info warn error fatal].each do |log_level| + define_method(log_level) do |message, **attributes| + captured_call[:level] = log_level + captured_call[:message] = message + captured_call[:attributes] = attributes + end + end + end + end + let(:captured_call) { {} } let(:sentry_logger) { build_sentry_logger } let(:fake_sentry) do @@ -53,14 +65,18 @@ expect(captured_call).to eq({}) end - def build_sentry_logger - logger_class = Struct.new(:captured_call) do - def info(message, **attributes) - captured_call[:message] = message - captured_call[:attributes] = attributes - end - end + it 'falls back to info when an unsupported level is requested', :aggregate_failures do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true) + allow(described_class).to receive(:logger).and_return(sentry_logger) + + described_class.emit(raw_payload.merge(level: :unknown_method)) + + expect(captured_call).to include(:message, :attributes) + expect(captured_call.fetch(:message)).to eq('auth.authenticate') + end + def build_sentry_logger logger_class.new(captured_call) end @@ -71,6 +87,7 @@ def expect_forwarded_payload end def expect_forwarded_message + expect(captured_call.fetch(:level)).to eq(:info) expect(captured_call.fetch(:message)).to eq('auth.authenticate') end From 0d4e2073abd8e01e892cb3fb172fd580f62f98ee Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:53:38 +0100 Subject: [PATCH 7/9] fix(docs/openapi): quickstart README and deterministic openapi generation --- README.md | 27 +++++++++++++++++---------- public/openapi.yaml | 5 ++--- spec/html2rss/web/api/v1_spec.rb | 8 ++++---- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 95ff75d4..8c8a0d3b 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,23 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut ## Deploy (Docker Compose) -1. Generate a key: `openssl rand -hex 32`. -2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`. -3. Export build metadata required by `docker-compose.yml`: - ```bash - export BUILD_TAG=local - export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" - ``` -4. Optionally export `SENTRY_DSN` to send application errors to Sentry. -5. Optionally export `SENTRY_ENABLE_LOGS=true` to forward structured application logs to Sentry. -6. Start: `docker-compose up`. +Quick start: + +```bash +export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)" +export HEALTH_CHECK_TOKEN="replace-with-a-strong-token" +export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token" +export BUILD_TAG="local" +export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" +docker-compose up +``` + +Optional: + +```bash +export SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0" +export SENTRY_ENABLE_LOGS=true +``` UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. diff --git a/public/openapi.yaml b/public/openapi.yaml index ac69458e..b94f4552 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -277,8 +277,7 @@ paths: ErrorInternal Server Error schema: type: string - description: returns non-cacheable json feed errors when service generation - fails + description: returns non-cacheable feed errors when service generation fails security: [] summary: Render feed by token tags: @@ -331,7 +330,7 @@ paths: - success - data type: object - description: returns current health status when a valid bearer token is provided + description: returns health status when token is valid '401': content: application/json: diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 201a90cb..aebed989 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -214,7 +214,7 @@ def expected_featured_feeds expect(json.dig('data', 'health', 'status')).to eq('healthy') end - it 'returns health status when the configured environment token is valid', :aggregate_failures do + it 'returns health status when the configured environment token is valid', :aggregate_failures, openapi: false do ClimateControl.modify(HEALTH_CHECK_TOKEN: 'rotated-health-token') do allow(Html2rss::Web::Auth).to receive(:authenticate).and_call_original @@ -227,7 +227,7 @@ def expected_featured_feeds end end - it 'returns health status after production-style env scrubbing', :aggregate_failures do + it 'returns health status after production-style env scrubbing', :aggregate_failures, openapi: false do capture_scrubbed_runtime_env( 'RACK_ENV' => 'production', 'HEALTH_CHECK_TOKEN' => 'scrubbed-health-token' @@ -410,7 +410,7 @@ def expected_featured_feeds ) end - it 'returns non-cacheable xml feed errors when service generation fails', :aggregate_failures do + it 'returns non-cacheable feed errors when service generation fails', :aggregate_failures do unique_url = "#{feed_url}/service-error-xml" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') @@ -424,7 +424,7 @@ def expected_featured_feeds expect(last_response.body).to include('Internal Server Error') end - it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures do + it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures, openapi: false do unique_url = "#{feed_url}/service-error-json" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') From 6a312e1a0912f8d87a4c72d49fe37606f4272afa Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 02:56:00 +0100 Subject: [PATCH 8/9] docs(readme): include AUTO_SOURCE_ENABLED in quickstart exports --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8c8a0d3b..3a75cc66 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ export HEALTH_CHECK_TOKEN="replace-with-a-strong-token" export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token" export BUILD_TAG="local" export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" +export AUTO_SOURCE_ENABLED=true docker-compose up ``` From 6667d52aa52a1c2b2dd9255744b6932148a7a140 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 28 Mar 2026 03:01:00 +0100 Subject: [PATCH 9/9] chore(openapi): refresh generated frontend types --- frontend/src/api/generated/types.gen.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index be442f6c..91da5024 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -124,7 +124,7 @@ export type RenderFeedByTokenErrors = { */ 403: string; /** - * returns non-cacheable json feed errors when service generation fails + * returns non-cacheable feed errors when service generation fails */ 500: string; }; @@ -177,7 +177,7 @@ export type GetHealthStatusError = GetHealthStatusErrors[keyof GetHealthStatusEr export type GetHealthStatusResponses = { /** - * returns health status after production-style env scrubbing + * returns health status when token is valid */ 200: { data: {