Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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),
Comment thread
gildesmarais marked this conversation as resolved.
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'
Comment thread
gildesmarais marked this conversation as resolved.
Outdated
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'
Comment thread
gildesmarais marked this conversation as resolved.
Outdated

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
Loading
Loading