Module: Otto::LoggingHelpers
- Defined in:
- lib/otto/logging_helpers.rb
Overview
LoggingHelpers provides utility methods for consistent structured logging across the Otto framework. Centralizes common request context extraction to eliminate duplication while keeping logging calls simple and explicit.
Class Method Summary collapse
-
.detect_project_root ⇒ String
Detect project root directory for path sanitization.
-
.log_backtrace(error, context = {}) ⇒ Object
Log exception backtrace with correlation fields for debugging.
-
.log_timed_operation(level, message, env, **metadata) { ... } ⇒ Object
Log a timed operation with consistent timing and error handling.
-
.privacy_safe_ip(env, fallback_ip = nil) ⇒ String?
A client IP that is safe to write to a log.
-
.redact_ip(ip) ⇒ String?
Reduce a raw address to a log-safe form.
-
.request_context(env) ⇒ Hash
Extract common request context for structured logging.
-
.sanitize_backtrace(backtrace, project_root: nil) ⇒ Array<String>
Sanitize an array of backtrace lines.
-
.sanitize_backtrace_line(line, project_root = nil) ⇒ String
Sanitize a single backtrace line to remove sensitive path information.
Class Method Details
.detect_project_root ⇒ String
Detect project root directory for path sanitization
172 173 174 175 176 177 178 179 180 |
# File 'lib/otto/logging_helpers.rb', line 172 def self.detect_project_root @project_root ||= begin if defined?(Bundler) Bundler.root.to_s else Dir.pwd end end end |
.log_backtrace(error, context = {}) ⇒ Object
Log exception backtrace with correlation fields for debugging. Always logs for unhandled errors at ERROR level with sanitized paths. Limits to first 20 lines for critical errors.
SECURITY: Paths are sanitized to prevent exposing sensitive system information: - Project files: Show relative paths only - Gem files: Show gem name and relative path within gem - Ruby stdlib: Show filename only - External files: Show filename only
Expects caller to provide correlation context (error_id, handler, etc). Does NOT duplicate error/error_class fields - those belong in main error log.
314 315 316 317 318 319 320 |
# File 'lib/otto/logging_helpers.rb', line 314 def self.log_backtrace(error, context = {}) raw_backtrace = error.backtrace&.first(20) || [] sanitized = sanitize_backtrace(raw_backtrace) Otto.structured_log(:error, 'Exception backtrace', context.merge(backtrace: sanitized)) end |
.log_timed_operation(level, message, env, **metadata) { ... } ⇒ Object
Log a timed operation with consistent timing and error handling
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/otto/logging_helpers.rb', line 150 def self.log_timed_operation(level, , env, **) start_time = Otto::Utils.now_in_μs result = yield duration = Otto::Utils.now_in_μs - start_time Otto.structured_log(level, , request_context(env).merge().merge(duration: duration)) result rescue StandardError => e duration = Otto::Utils.now_in_μs - start_time Otto.structured_log(:error, "#{} failed", request_context(env).merge().merge( duration: duration, error: e., error_class: e.class.name )) raise end |
.privacy_safe_ip(env, fallback_ip = nil) ⇒ String?
A client IP that is safe to write to a log.
Prefers the canonical env[‘otto.client_ip’] that IPPrivacyMiddleware resolves once per request: masked when privacy is on, and deliberately the real address when the app has disabled privacy. That key is present for anything running inside Otto, since the middleware is pinned outermost.
It is ABSENT for code that runs outside Otto’s stack — notably Rack::Attack, which the hosting app mounts ahead of Otto (see Otto::Security::Middleware::RateLimitMiddleware), so its ‘rack.attack’ subscriber observes the raw peer no matter how Otto orders its own middleware. There we mask the address ourselves rather than log it whole: a public IP is reduced with the default precision (privacy config is not reachable from a global subscriber), and private/localhost addresses pass through unmasked, matching the middleware’s own exemption so development logs stay readable.
Never raises and never returns a raw public address: an unparseable value (a ported REMOTE_ADDR, a malformed forwarded entry) becomes ‘[redacted]’.
111 112 113 114 115 116 117 |
# File 'lib/otto/logging_helpers.rb', line 111 def self.privacy_safe_ip(env, fallback_ip = nil) canonical = env['otto.client_ip'] return canonical unless canonical.to_s.empty? candidate = fallback_ip.to_s.empty? ? env['REMOTE_ADDR'] : fallback_ip redact_ip(candidate) end |
.redact_ip(ip) ⇒ String?
Reduce a raw address to a log-safe form. See .privacy_safe_ip.
124 125 126 127 128 129 130 131 132 |
# File 'lib/otto/logging_helpers.rb', line 124 def self.redact_ip(ip) address = ip.to_s.strip return nil if address.empty? return address if Otto::Privacy::IPPrivacy.private_or_localhost?(address) Otto::Privacy::IPPrivacy.mask_ip(address) || '[redacted]' rescue ArgumentError '[redacted]' end |
.request_context(env) ⇒ Hash
IP addresses are already masked by IPPrivacyMiddleware (public IPs only)
User agents are truncated to 100 chars to prevent log bloat
Extract common request context for structured logging
Returns a hash containing privacy-aware request metadata suitable for merging with event-specific data in Otto.structured_log calls.
75 76 77 78 79 80 81 82 83 |
# File 'lib/otto/logging_helpers.rb', line 75 def self.request_context(env) { method: env['REQUEST_METHOD'], path: env['PATH_INFO'], ip: privacy_safe_ip(env), # Canonical client IP (masked when privacy on) country: env['otto.privacy.geo_country'], user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware }.compact end |
.sanitize_backtrace(backtrace, project_root: nil) ⇒ Array<String>
Sanitize an array of backtrace lines
278 279 280 281 282 283 |
# File 'lib/otto/logging_helpers.rb', line 278 def self.sanitize_backtrace(backtrace, project_root: nil) return [] if backtrace.nil? || backtrace.empty? project_root ||= detect_project_root backtrace.map { |line| sanitize_backtrace_line(line, project_root) } end |
.sanitize_backtrace_line(line, project_root = nil) ⇒ String
Sanitize a single backtrace line to remove sensitive path information
Transforms absolute paths into relative or categorized paths: - Project files: relative path from project root - Gem files: [GEM] gem-name-version/relative/path - Ruby stdlib: [RUBY] filename - Unknown: [EXTERNAL] filename
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/otto/logging_helpers.rb', line 201 def self.sanitize_backtrace_line(line, project_root = nil) return line if line.nil? || line.empty? project_root ||= detect_project_root = File.(project_root) # Extract file path from backtrace line (format: "path:line:in `method'" or "path:line") if line =~ /^(.+?):\d+(?::in `.+')?$/ file_path = ::Regexp.last_match(1) suffix = line[file_path.length..] begin = File.(file_path) rescue ArgumentError # Handle malformed paths (e.g., containing null bytes) # File.basename also raises ArgumentError for null bytes, so use simple string manipulation basename = file_path.split('/').last || file_path return "[EXTERNAL] #{basename}#{suffix}" end # Try project-relative path first if .start_with?( + File::SEPARATOR) relative_path = .delete_prefix( + File::SEPARATOR) return relative_path + suffix end # Check for bundler gems specifically (e.g., /bundler/gems/otto-abc123/lib/...) # Must check BEFORE regular gems pattern to handle /gems/3.4.0/bundler/gems/... if =~ %r{/bundler/gems/([^/]+)/(.+)$} gem_name = ::Regexp.last_match(1) gem_relative = ::Regexp.last_match(2) # Strip git hash suffix for cleaner output (otto-abc123def456 → otto) gem_name = gem_name.sub(/-[a-f0-9]{7,}$/, '') if gem_name =~ /-[a-f0-9]{7,}$/i return "[GEM] #{gem_name}/#{gem_relative}#{suffix}" end # Check for regular gem path (e.g., /path/to/gems/rack-3.1.8/lib/rack.rb) # Handle version directories: /gems/3.4.0/gems/rack-3.1.8/... by looking for last /gems/ if =~ %r{/gems/([^/]+)/(.+)$} gem_name = ::Regexp.last_match(1) gem_relative = ::Regexp.last_match(2) # Skip version-only directory names (e.g., 3.4.0) # Look deeper if gem_name is just a version number if gem_name =~ /^[\d.]+$/ && gem_relative =~ %r{^(?:bundler/)?gems/([^/]+)/(.+)$} # Found nested gem path, use that instead gem_name = ::Regexp.last_match(1) gem_relative = ::Regexp.last_match(2) end # Strip version suffix for cleaner output (rack-3.1.8 → rack) base_gem_name = gem_name.split('-')[0..-2].join('-') base_gem_name = gem_name if base_gem_name.empty? return "[GEM] #{base_gem_name}/#{gem_relative}#{suffix}" end # Check for Ruby stdlib (e.g., /path/to/ruby/3.4.0/logger.rb) if =~ %r{/ruby/[\d.]+/(.+)$} stdlib_file = ::Regexp.last_match(1) return "[RUBY] #{stdlib_file}#{suffix}" end # Unknown/external path - show filename only filename = File.basename(file_path) return "[EXTERNAL] #{filename}#{suffix}" end # Couldn't parse - return as-is (better than failing) line end |