diff --git a/.mergify.yml b/.mergify.yml
index 1ac275dc..ccbd53f6 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -4,10 +4,11 @@ queue_rules:
- author=dependabot[bot]
merge_conditions:
- author=dependabot[bot]
- - status-success=docker-test
+ - check-success=docker-test (false)
+ - check-success=docker-test (true)
- status-success=hadolint
- status-success=ruby
- - base=master
+ - base=main
merge_method: squash
pull_request_rules:
diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb
index fd69faa8..edb5687d 100644
--- a/app/web/boot/setup.rb
+++ b/app/web/boot/setup.rb
@@ -13,6 +13,7 @@ class << self
def call!
validate_environment!
configure_request_service!
+ configure_runtime_logging!
end
private
@@ -28,6 +29,13 @@ def validate_environment!
def configure_request_service!
nil
end
+
+ # @return [void]
+ def configure_runtime_logging!
+ return unless defined?(Rack::Timeout::Logger)
+
+ Rack::Timeout::Logger.logger = AppLogger.logger
+ end
end
end
end
diff --git a/app/web/request/request_context_middleware.rb b/app/web/request/request_context_middleware.rb
index ad49ad75..745eecb8 100644
--- a/app/web/request/request_context_middleware.rb
+++ b/app/web/request/request_context_middleware.rb
@@ -57,7 +57,7 @@ def build_context(request)
path = request.path_info.to_s
RequestContext::Context.new(
request_id: request_id_for(request),
- path: path,
+ path: LogSanitizer.sanitize_path(path),
http_method: request.request_method.to_s.upcase,
route_group: route_group_for(path),
actor: nil,
diff --git a/app/web/security/security_logger.rb b/app/web/security/security_logger.rb
index 1d15d495..639c1da3 100644
--- a/app/web/security/security_logger.rb
+++ b/app/web/security/security_logger.rb
@@ -1,6 +1,5 @@
# frozen_string_literal: true
-require 'logger'
require 'json'
require 'digest'
require 'time'
@@ -11,16 +10,10 @@ module Web
# Provides structured logging for security events to stdout
module SecurityLogger
class << self
- # Initialize logger to stdout with structured JSON output
- # @return [Logger]
- def logger
- Thread.current[:security_logger] ||= create_logger
- end
-
- # Reset logger (for testing)
+ # Reset shared logger state for tests.
# @return [void]
def reset_logger!
- Thread.current[:security_logger] = nil
+ AppLogger.reset_logger!
end
##
@@ -134,32 +127,18 @@ def log_cache_lifecycle(component, event, details = {})
private
- def create_logger
- Logger.new($stdout).tap do |log|
- log.formatter = proc do |severity, datetime, _progname, msg|
- "#{{
- timestamp: datetime.iso8601,
- level: severity,
- service: 'html2rss-web',
- **JSON.parse(msg, symbolize_names: true)
- }.to_json}\n"
- end
- end
- end
-
##
# Log a security event
# @param event_type [String] type of security event
# @param data [Hash] event data
def log_event(event_type, data, severity: :warn)
- context_data = RequestContext.current_h
- payload = {
- security_event: event_type,
- **context_data,
- **data
- }.to_json
-
- logger.public_send(severity, payload)
+ LogEvent.emit(
+ level: severity,
+ payload: {
+ security_event: event_type,
+ **data
+ }
+ )
rescue StandardError => error
handle_logging_error(error, event_type, data)
end
diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb
new file mode 100644
index 00000000..f267fb5b
--- /dev/null
+++ b/app/web/telemetry/app_logger.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'logger'
+require 'time'
+require 'uri'
+
+module Html2rss
+ module Web
+ ##
+ # Shared structured logger for application and middleware runtime events.
+ module AppLogger
+ class << self
+ # @return [Logger]
+ def logger
+ Thread.current[:app_logger] ||= build_logger
+ end
+
+ # @return [void]
+ def reset_logger!
+ Thread.current[:app_logger] = nil
+ end
+
+ private
+
+ # @return [Logger]
+ def build_logger
+ Logger.new($stdout).tap do |log|
+ log.formatter = method(:format_entry)
+ end
+ end
+
+ # @param severity [String]
+ # @param datetime [Time]
+ # @param _progname [String, nil]
+ # @param message [String]
+ # @return [String]
+ def format_entry(severity, datetime, _progname, message)
+ "#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n"
+ end
+
+ # @param severity [String]
+ # @param datetime [Time]
+ # @return [Hash{Symbol=>Object}]
+ def base_payload(severity, datetime)
+ {
+ timestamp: datetime.iso8601,
+ level: severity,
+ service: 'html2rss-web'
+ }
+ end
+
+ # @param message [Object]
+ # @return [Hash{Symbol=>Object}]
+ def normalize_message(message)
+ parsed_json(message) || parse_logfmt(message.to_s) || { message: message.to_s }
+ end
+
+ # @param message [Object]
+ # @return [Hash{Symbol=>Object}, nil]
+ def parsed_json(message)
+ JSON.parse(message.to_s, symbolize_names: true)
+ rescue JSON::ParserError, TypeError
+ nil
+ end
+
+ # @param message [String]
+ # @return [Hash{Symbol=>Object}, nil]
+ def parse_logfmt(message)
+ pairs = message.scan(/([a-zA-Z0-9_.-]+)=("[^"]*"|\S+)/)
+ return nil if pairs.empty?
+
+ pairs.to_h do |key, raw_value|
+ [key.to_sym, normalize_logfmt_value(raw_value)]
+ end
+ end
+
+ # @param raw_value [String]
+ # @return [String, Integer, Float, TrueClass, FalseClass]
+ def normalize_logfmt_value(raw_value)
+ value = raw_value.delete_prefix('"').delete_suffix('"')
+ return true if value == 'true'
+ return false if value == 'false'
+ return value.to_i if value.match?(/\A-?\d+\z/)
+ return value.to_f if value.match?(/\A-?\d+\.\d+\z/)
+
+ value
+ end
+ end
+ end
+ end
+end
diff --git a/app/web/telemetry/log_event.rb b/app/web/telemetry/log_event.rb
new file mode 100644
index 00000000..0b85a561
--- /dev/null
+++ b/app/web/telemetry/log_event.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Html2rss
+ module Web
+ ##
+ # Shared structured log emitter for request-scoped application events.
+ module LogEvent
+ class << self
+ # @param payload [Hash{Symbol=>Object}]
+ # @param level [Symbol]
+ # @return [void]
+ def emit(payload:, level: :info)
+ logger.public_send(level, build_payload(payload).to_json)
+ end
+
+ private
+
+ # @return [Logger]
+ def logger
+ AppLogger.logger
+ end
+
+ # @param payload [Hash{Symbol=>Object}]
+ # @return [Hash{Symbol=>Object}]
+ def build_payload(payload)
+ RequestContext.current_h.merge(LogSanitizer.sanitize_details(payload))
+ end
+ end
+ end
+ end
+end
diff --git a/app/web/telemetry/log_sanitizer.rb b/app/web/telemetry/log_sanitizer.rb
new file mode 100644
index 00000000..67c25b75
--- /dev/null
+++ b/app/web/telemetry/log_sanitizer.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'digest'
+require 'uri'
+
+module Html2rss
+ module Web
+ ##
+ # Sanitizes request and detail payloads before structured logging.
+ module LogSanitizer
+ FEED_TOKEN_ROUTE = %r{\A(/api/v1/feeds/)([^/.?]+)(\.(?:json|xml|rss))?\z}
+
+ class << self
+ # @param path [String, nil]
+ # @return [String, nil]
+ def sanitize_path(path)
+ return if path.nil?
+
+ path.to_s.gsub(FEED_TOKEN_ROUTE, '\1[REDACTED]\3')
+ end
+
+ # @param details [Hash]
+ # @return [Hash]
+ def sanitize_details(details)
+ details.each_with_object({}) do |(key, value), sanitized|
+ sanitized[key] = sanitize_value(key, value)
+ end
+ end
+
+ private
+
+ # @param key [Object]
+ # @param value [Object]
+ # @return [Object]
+ def sanitize_value(key, value)
+ return sanitize_url(value) if key.to_sym == :url
+ return sanitize_details(value) if value.is_a?(Hash)
+ return value.map { |entry| sanitize_value(key, entry) } if value.is_a?(Array)
+
+ value
+ end
+
+ # @param value [Object]
+ # @return [Hash{Symbol=>Object}, Object]
+ def sanitize_url(value)
+ url = value.to_s
+ return value if url.empty?
+
+ uri = URI.parse(url)
+ {
+ host: uri.host,
+ scheme: uri.scheme,
+ hash: Digest::SHA256.hexdigest(url)[0..11]
+ }.compact
+ rescue URI::InvalidURIError
+ { hash: Digest::SHA256.hexdigest(url)[0..11] }
+ end
+ end
+ end
+ end
+end
diff --git a/app/web/telemetry/observability.rb b/app/web/telemetry/observability.rb
index 496a0c9b..73c38bc6 100644
--- a/app/web/telemetry/observability.rb
+++ b/app/web/telemetry/observability.rb
@@ -1,9 +1,5 @@
# frozen_string_literal: true
-require 'json'
-require 'logger'
-require 'time'
-
module Html2rss
module Web
##
@@ -18,27 +14,13 @@ class << self
# @param level [Symbol]
# @return [void]
def emit(event_name:, outcome:, details: {}, level: :info)
- logger.public_send(level, build_payload(event_name, outcome, details).to_json)
+ LogEvent.emit(payload: build_payload(event_name, outcome, details), level: level)
rescue StandardError => error
handle_emit_error(error, event_name, outcome)
end
private
- # @return [Logger]
- def logger
- Thread.current[:observability_logger] ||= Logger.new($stdout).tap do |log|
- log.formatter = proc do |severity, datetime, _progname, msg|
- "#{{
- timestamp: datetime.iso8601,
- level: severity,
- service: 'html2rss-web',
- **JSON.parse(msg, symbolize_names: true)
- }.to_json}\n"
- end
- end
- end
-
# @param error [StandardError]
# @param event_name [String]
# @param outcome [String]
diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx
index af6bf619..87703b90 100644
--- a/frontend/src/__tests__/App.test.tsx
+++ b/frontend/src/__tests__/App.test.tsx
@@ -400,6 +400,7 @@ describe('App', () => {
});
it('shows the utility links in a user-focused order', () => {
+ window.history.replaceState({}, '', 'http://localhost:3000/#result');
render(
Included feeds
Start with one of the embedded configs from this instance:
- {featuredFeeds.map((feed) => ( -- {feed.title} - {' - '} - {feed.description} -
- ))} -+
Start with a ready-made feed from this instance.
+