Skip to content
Closed
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
5 changes: 3 additions & 2 deletions .mergify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions app/web/boot/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class << self
def call!
validate_environment!
configure_request_service!
configure_runtime_logging!
end

private
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/web/request/request_context_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 9 additions & 30 deletions app/web/security/security_logger.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# frozen_string_literal: true

require 'logger'
require 'json'
require 'digest'
require 'time'
Expand All @@ -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

##
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions app/web/telemetry/app_logger.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions app/web/telemetry/log_event.rb
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions app/web/telemetry/log_sanitizer.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 1 addition & 19 deletions app/web/telemetry/observability.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# frozen_string_literal: true

require 'json'
require 'logger'
require 'time'

module Html2rss
module Web
##
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ describe('App', () => {
});

it('shows the utility links in a user-focused order', () => {
window.history.replaceState({}, '', 'http://localhost:3000/#result');
render(<App />);

fireEvent.click(screen.getByRole('button', { name: 'More' }));
Expand All @@ -419,7 +420,7 @@ describe('App', () => {
);
expect(screen.getByRole('link', { name: 'Try included feeds' })).toHaveAttribute(
'href',
'https://html2rss.github.io/web-application/how-to/use-included-configs/'
'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F'
);
expect(screen.getByRole('link', { name: 'Install from Docker Hub' })).toHaveAttribute(
'href',
Expand Down
Loading
Loading