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



123
124
125
126
127
128
129
130
131
# File 'lib/otto/logging_helpers.rb', line 123

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)



265
266
267
268
269
270
271
# File 'lib/otto/logging_helpers.rb', line 265

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



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/otto/logging_helpers.rb', line 101

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

.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: env['REMOTE_ADDR'], # Already masked by IPPrivacyMiddleware for public IPs
       country: env['otto.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



229
230
231
232
233
234
# File 'lib/otto/logging_helpers.rb', line 229

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



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/otto/logging_helpers.rb', line 152

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