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

Class Method Details

.detect_project_rootString

Detect project root directory for path sanitization

Returns:

  • (String)

    Absolute path to project root



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.

Examples:

Basic usage

Otto::LoggingHelpers.log_backtrace(error,
  base_context.merge(error_id: error_id, handler: 'UserController#create')
)

Downstream extensibility

custom_context = Otto::LoggingHelpers.request_context(env).merge(
  error_id: error_id,
  transaction_id: Thread.current[:transaction_id],
  tenant_id: env['tenant_id']
)
Otto::LoggingHelpers.log_backtrace(error, custom_context)

Parameters:

  • error (Exception)

    The exception to log backtrace for

  • context (Hash) (defaults to: {})

    Correlation fields (error_id, method, path, ip, handler, etc)



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

Examples:

Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
  template_type: 'handlebars', cached: false
) do
  compile_template(template)
end

Parameters:

  • level (Symbol)

    The log level (:debug, :info, :warn, :error)

  • message (String)

    The log message

  • env (Hash)

    Rack environment for request context

  • metadata (Hash)

    Additional metadata to include in the log

Yields:

  • The block to execute and time

Returns:

  • The result of the block



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, message, env, **)
  start_time = Otto::Utils.now_in_μs
  result = yield
  duration = Otto::Utils.now_in_μs - start_time

  Otto.structured_log(level, message,
    request_context(env).merge().merge(duration: duration))

  result
rescue StandardError => e
  duration = Otto::Utils.now_in_μs - start_time
  Otto.structured_log(:error, "#{message} failed",
    request_context(env).merge().merge(
      duration: duration,
      error: e.message,
      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]’.

Parameters:

  • env (Hash)

    Rack environment hash

  • fallback_ip (String, nil) (defaults to: nil)

    address to fall back to when the env carries no canonical client IP — e.g. Rack::Request#ip, which factors in forwarded headers. Defaults to REMOTE_ADDR.

Returns:

  • (String, nil)

    log-safe address, or nil when there is none



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.

Parameters:

  • ip (String, nil)

    raw address

Returns:

  • (String, nil)

    masked address, the address itself when private/localhost, ‘[redacted]’ when unparseable, nil when blank



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

Note:

IP addresses are already masked by IPPrivacyMiddleware (public IPs only)

Note:

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.

Examples:

Basic usage

Otto.structured_log(:info, "Route matched",
  Otto::LoggingHelpers.request_context(env).merge(
    type: 'literal',
    handler: 'App#index'
  )
)

Parameters:

  • env (Hash)

    Rack environment hash

Returns:

  • (Hash)

    Request context with method, path, ip, country, user_agent



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

Parameters:

  • backtrace (Array<String>)

    Raw backtrace lines

  • project_root (String) (defaults to: nil)

    Project root path (auto-detected if nil)

Returns:

  • (Array<String>)

    Sanitized 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

Examples:

sanitize_backtrace_line("/Users/admin/app/lib/user.rb:42:in `save'")
# => "lib/user.rb:42:in `save'"

sanitize_backtrace_line("/usr/local/gems/rack-3.1.8/lib/rack.rb:10")
# => "[GEM] rack-3.1.8/lib/rack.rb:10"

Parameters:

  • line (String)

    Raw backtrace line

  • project_root (String) (defaults to: nil)

    Project root path (auto-detected if nil)

Returns:

  • (String)

    Sanitized backtrace line



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
  expanded_root = File.expand_path(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
      expanded_path = File.expand_path(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 expanded_path.start_with?(expanded_root + File::SEPARATOR)
      relative_path = expanded_path.delete_prefix(expanded_root + 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 expanded_path =~ %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 expanded_path =~ %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 expanded_path =~ %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