otto 2.0.0.pre3 → 2.0.0.pre7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +15 -20
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +2 -2
- data/lib/otto/core/router.rb +61 -8
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +2 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +61 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +8 -3
- data/lib/otto/helpers/response.rb +2 -2
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +2 -0
- data/lib/otto/locale/config.rb +2 -2
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +2 -0
- data/lib/otto/privacy/config.rb +2 -0
- data/lib/otto/privacy/geo_resolver.rb +199 -29
- data/lib/otto/privacy/ip_privacy.rb +2 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
- data/lib/otto/privacy.rb +2 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -0
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +23 -6
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +2 -2
- data/lib/otto/security/authentication/auth_strategy.rb +11 -4
- data/lib/otto/security/authentication/route_auth_wrapper.rb +230 -78
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +2 -2
- data/lib/otto/security/configurator.rb +17 -2
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +3 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +174 -14
- data/otto.gemspec +7 -3
- metadata +24 -15
- data/benchmark_middleware_wrap.rb +0 -163
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# lib/otto/locale/middleware.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Locale
|
|
7
|
+
# Locale detection and resolution middleware
|
|
8
|
+
#
|
|
9
|
+
# Sets env['otto.locale'] based on:
|
|
10
|
+
# 1. URL parameter (?locale=es)
|
|
11
|
+
# 2. Session preference (session['locale'])
|
|
12
|
+
# 3. HTTP Accept-Language header
|
|
13
|
+
# 4. Default locale
|
|
14
|
+
#
|
|
15
|
+
# Configuration:
|
|
16
|
+
# use Otto::Locale::Middleware,
|
|
17
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish' },
|
|
18
|
+
# default_locale: 'en',
|
|
19
|
+
# debug: false
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage
|
|
22
|
+
# use Otto::Locale::Middleware,
|
|
23
|
+
# available_locales: { 'en' => 'English', 'es' => 'Español', 'fr' => 'Français' },
|
|
24
|
+
# default_locale: 'en'
|
|
25
|
+
#
|
|
26
|
+
# @example With session persistence
|
|
27
|
+
# use Rack::Session::Cookie, secret: 'secret'
|
|
28
|
+
# use Otto::Locale::Middleware,
|
|
29
|
+
# available_locales: { 'en' => 'English', 'es' => 'Español' },
|
|
30
|
+
# default_locale: 'en'
|
|
31
|
+
#
|
|
32
|
+
class Middleware
|
|
33
|
+
attr_reader :available_locales, :default_locale
|
|
34
|
+
|
|
35
|
+
# Initialize locale middleware
|
|
36
|
+
#
|
|
37
|
+
# @param app [#call] Rack application
|
|
38
|
+
# @param available_locales [Hash<String, String>] Hash of locale codes to language names
|
|
39
|
+
# @param default_locale [String] Default locale code
|
|
40
|
+
# @param debug [Boolean] Enable debug logging
|
|
41
|
+
def initialize(app, available_locales:, default_locale:, debug: false)
|
|
42
|
+
@app = app
|
|
43
|
+
@available_locales = available_locales
|
|
44
|
+
@default_locale = default_locale
|
|
45
|
+
@debug = debug
|
|
46
|
+
|
|
47
|
+
validate_config!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Process request and set locale
|
|
51
|
+
#
|
|
52
|
+
# @param env [Hash] Rack environment
|
|
53
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
54
|
+
def call(env)
|
|
55
|
+
locale = detect_locale(env)
|
|
56
|
+
env['otto.locale'] = locale
|
|
57
|
+
|
|
58
|
+
debug_log(env, locale) if @debug
|
|
59
|
+
|
|
60
|
+
@app.call(env)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Detect locale using priority chain
|
|
66
|
+
#
|
|
67
|
+
# @param env [Hash] Rack environment
|
|
68
|
+
# @return [String] Resolved locale code
|
|
69
|
+
def detect_locale(env)
|
|
70
|
+
# 1. Check URL parameter
|
|
71
|
+
req = Rack::Request.new(env)
|
|
72
|
+
locale = req.params['locale']
|
|
73
|
+
return locale if valid_locale?(locale)
|
|
74
|
+
|
|
75
|
+
# 2. Check session
|
|
76
|
+
session = env['rack.session']
|
|
77
|
+
locale = session['locale'] if session
|
|
78
|
+
return locale if valid_locale?(locale)
|
|
79
|
+
|
|
80
|
+
# 3. Parse Accept-Language header
|
|
81
|
+
locale = parse_accept_language(env['HTTP_ACCEPT_LANGUAGE'])
|
|
82
|
+
return locale if valid_locale?(locale)
|
|
83
|
+
|
|
84
|
+
# 4. Default
|
|
85
|
+
@default_locale
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Parse Accept-Language header with RFC 2616 quality value support
|
|
89
|
+
#
|
|
90
|
+
# Handles formats like:
|
|
91
|
+
# - "en-US,en;q=0.9,fr;q=0.8" → finds first available from [en, en, fr]
|
|
92
|
+
# - "es,en;q=0.9" → returns "en" if "es" unavailable but "en" is
|
|
93
|
+
# - "fr-CA" → "fr"
|
|
94
|
+
#
|
|
95
|
+
# Respects q-values (quality factors) and returns the highest-priority
|
|
96
|
+
# available locale instead of just the first language tag.
|
|
97
|
+
#
|
|
98
|
+
# @param header [String, nil] Accept-Language header value
|
|
99
|
+
# @return [String, nil] Best matching available locale code or nil
|
|
100
|
+
def parse_accept_language(header)
|
|
101
|
+
return nil unless header
|
|
102
|
+
|
|
103
|
+
# Parse all language tags with their q-values
|
|
104
|
+
# Format: "en-US,en;q=0.9,fr;q=0.8" → [[en-US, 1.0], [en, 0.9], [fr, 0.8]]
|
|
105
|
+
languages = header.split(',').map do |tag|
|
|
106
|
+
# Split on semicolon and extract q-value
|
|
107
|
+
parts = tag.strip.split(/\s*;\s*q\s*=\s*/)
|
|
108
|
+
locale_str = parts[0]
|
|
109
|
+
q_value = parts[1] ? parts[1].to_f : 1.0
|
|
110
|
+
[locale_str, q_value]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Sort by q-value descending (highest preference first)
|
|
114
|
+
# and find the first locale that matches available_locales
|
|
115
|
+
languages.sort_by { |_, q| -q }.each do |lang_tag, _|
|
|
116
|
+
# Extract primary language code: "en-US" → "en", "fr" → "fr"
|
|
117
|
+
locale_code = lang_tag.split('-').first.downcase
|
|
118
|
+
return locale_code if valid_locale?(locale_code)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
nil # No matching locale found
|
|
122
|
+
rescue StandardError => ex
|
|
123
|
+
Otto.logger&.warn "[Otto::Locale] Failed to parse Accept-Language: #{ex.message}"
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check if locale is valid
|
|
128
|
+
#
|
|
129
|
+
# @param locale [String, nil] Locale code to validate
|
|
130
|
+
# @return [Boolean] true if locale is in available_locales
|
|
131
|
+
def valid_locale?(locale)
|
|
132
|
+
return false unless locale
|
|
133
|
+
@available_locales.key?(locale.to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Validate middleware configuration
|
|
137
|
+
#
|
|
138
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
139
|
+
def validate_config!
|
|
140
|
+
raise ArgumentError, 'available_locales must be a Hash' unless @available_locales.is_a?(Hash)
|
|
141
|
+
raise ArgumentError, 'available_locales cannot be empty' if @available_locales.empty?
|
|
142
|
+
raise ArgumentError, 'default_locale must be in available_locales' unless @available_locales.key?(@default_locale)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Log debug information about locale detection
|
|
146
|
+
#
|
|
147
|
+
# @param env [Hash] Rack environment
|
|
148
|
+
# @param locale [String] Resolved locale
|
|
149
|
+
def debug_log(env, locale)
|
|
150
|
+
Otto.logger&.debug format(
|
|
151
|
+
'[Otto::Locale] Selected locale=%s (param=%s session=%s header=%s)',
|
|
152
|
+
locale,
|
|
153
|
+
Rack::Request.new(env).params['locale'] || 'nil',
|
|
154
|
+
env['rack.session']&.dig('locale') || 'nil',
|
|
155
|
+
env['HTTP_ACCEPT_LANGUAGE']&.split(',')&.first || 'nil'
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/otto/locale.rb
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# lib/otto/logging_helpers.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
# LoggingHelpers provides utility methods for consistent structured logging
|
|
7
|
+
# across the Otto framework. Centralizes common request context extraction
|
|
8
|
+
# to eliminate duplication while keeping logging calls simple and explicit.
|
|
9
|
+
module LoggingHelpers
|
|
10
|
+
# Structured logging helpers for Otto framework.
|
|
11
|
+
#
|
|
12
|
+
# BASE CONTEXT PATTERN (recommended for downstream projects):
|
|
13
|
+
#
|
|
14
|
+
# Create base context once per error/event, then merge event-specific fields.
|
|
15
|
+
#
|
|
16
|
+
# THREAD SAFETY: This pattern is thread-safe for concurrent requests. Each
|
|
17
|
+
# request has its own `env` hash, so `request_context(env)` creates isolated
|
|
18
|
+
# context hashes per request. The pattern extracts immutable values (strings,
|
|
19
|
+
# symbols) from `env`, and `.merge()` creates new hashes rather than mutating
|
|
20
|
+
# shared state. Safe for use in multi-threaded Rack servers (Puma, Falcon).
|
|
21
|
+
#
|
|
22
|
+
# base_context = Otto::LoggingHelpers.request_context(env)
|
|
23
|
+
#
|
|
24
|
+
# Otto.structured_log(:error, "Handler failed",
|
|
25
|
+
# base_context.merge(
|
|
26
|
+
# error: error.message,
|
|
27
|
+
# error_class: error.class.name,
|
|
28
|
+
# error_id: error_id,
|
|
29
|
+
# duration: duration
|
|
30
|
+
# )
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# Otto::LoggingHelpers.log_backtrace(error,
|
|
34
|
+
# base_context.merge(error_id: error_id, handler: 'Controller#action')
|
|
35
|
+
# )
|
|
36
|
+
#
|
|
37
|
+
# DOWNSTREAM EXTENSIBILITY:
|
|
38
|
+
#
|
|
39
|
+
# Large projects can inject custom shared fields:
|
|
40
|
+
#
|
|
41
|
+
# custom_base = Otto::LoggingHelpers.request_context(env).merge(
|
|
42
|
+
# transaction_id: Thread.current[:transaction_id],
|
|
43
|
+
# user_id: env['otto.user']&.id,
|
|
44
|
+
# tenant_id: env['tenant_id']
|
|
45
|
+
# )
|
|
46
|
+
#
|
|
47
|
+
# Otto.structured_log(:error, "Business operation failed",
|
|
48
|
+
# custom_base.merge(
|
|
49
|
+
# error: error.message,
|
|
50
|
+
# error_class: error.class.name,
|
|
51
|
+
# account_id: account.id,
|
|
52
|
+
# operation: :withdrawal
|
|
53
|
+
# )
|
|
54
|
+
# )
|
|
55
|
+
#
|
|
56
|
+
|
|
57
|
+
# Extract common request context for structured logging
|
|
58
|
+
#
|
|
59
|
+
# Returns a hash containing privacy-aware request metadata suitable
|
|
60
|
+
# for merging with event-specific data in Otto.structured_log calls.
|
|
61
|
+
#
|
|
62
|
+
# @param env [Hash] Rack environment hash
|
|
63
|
+
# @return [Hash] Request context with method, path, ip, country, user_agent
|
|
64
|
+
#
|
|
65
|
+
# @example Basic usage
|
|
66
|
+
# Otto.structured_log(:info, "Route matched",
|
|
67
|
+
# Otto::LoggingHelpers.request_context(env).merge(
|
|
68
|
+
# type: 'literal',
|
|
69
|
+
# handler: 'App#index'
|
|
70
|
+
# )
|
|
71
|
+
# )
|
|
72
|
+
#
|
|
73
|
+
# @note IP addresses are already masked by IPPrivacyMiddleware (public IPs only)
|
|
74
|
+
# @note User agents are truncated to 100 chars to prevent log bloat
|
|
75
|
+
def self.request_context(env)
|
|
76
|
+
{
|
|
77
|
+
method: env['REQUEST_METHOD'],
|
|
78
|
+
path: env['PATH_INFO'],
|
|
79
|
+
ip: env['REMOTE_ADDR'], # Already masked by IPPrivacyMiddleware for public IPs
|
|
80
|
+
country: env['otto.geo_country'],
|
|
81
|
+
user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
|
|
82
|
+
}.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Log a timed operation with consistent timing and error handling
|
|
86
|
+
#
|
|
87
|
+
# @param level [Symbol] The log level (:debug, :info, :warn, :error)
|
|
88
|
+
# @param message [String] The log message
|
|
89
|
+
# @param env [Hash] Rack environment for request context
|
|
90
|
+
# @param metadata [Hash] Additional metadata to include in the log
|
|
91
|
+
# @yield The block to execute and time
|
|
92
|
+
# @return The result of the block
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
|
|
96
|
+
# template_type: 'handlebars', cached: false
|
|
97
|
+
# ) do
|
|
98
|
+
# compile_template(template)
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
def self.log_timed_operation(level, message, env, **metadata)
|
|
102
|
+
start_time = Otto::Utils.now_in_μs
|
|
103
|
+
result = yield
|
|
104
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
105
|
+
|
|
106
|
+
Otto.structured_log(level, message,
|
|
107
|
+
request_context(env).merge(metadata).merge(duration: duration))
|
|
108
|
+
|
|
109
|
+
result
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
112
|
+
Otto.structured_log(:error, "#{message} failed",
|
|
113
|
+
request_context(env).merge(metadata).merge(
|
|
114
|
+
duration: duration,
|
|
115
|
+
error: e.message,
|
|
116
|
+
error_class: e.class.name
|
|
117
|
+
))
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Detect project root directory for path sanitization
|
|
122
|
+
# @return [String] Absolute path to project root
|
|
123
|
+
def self.detect_project_root
|
|
124
|
+
@project_root ||= begin
|
|
125
|
+
if defined?(Bundler)
|
|
126
|
+
Bundler.root.to_s
|
|
127
|
+
else
|
|
128
|
+
Dir.pwd
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Sanitize a single backtrace line to remove sensitive path information
|
|
134
|
+
#
|
|
135
|
+
# Transforms absolute paths into relative or categorized paths:
|
|
136
|
+
# - Project files: relative path from project root
|
|
137
|
+
# - Gem files: [GEM] gem-name-version/relative/path
|
|
138
|
+
# - Ruby stdlib: [RUBY] filename
|
|
139
|
+
# - Unknown: [EXTERNAL] filename
|
|
140
|
+
#
|
|
141
|
+
# @param line [String] Raw backtrace line
|
|
142
|
+
# @param project_root [String] Project root path (auto-detected if nil)
|
|
143
|
+
# @return [String] Sanitized backtrace line
|
|
144
|
+
#
|
|
145
|
+
# @example
|
|
146
|
+
# sanitize_backtrace_line("/Users/admin/app/lib/user.rb:42:in `save'")
|
|
147
|
+
# # => "lib/user.rb:42:in `save'"
|
|
148
|
+
#
|
|
149
|
+
# sanitize_backtrace_line("/usr/local/gems/rack-3.1.8/lib/rack.rb:10")
|
|
150
|
+
# # => "[GEM] rack-3.1.8/lib/rack.rb:10"
|
|
151
|
+
#
|
|
152
|
+
def self.sanitize_backtrace_line(line, project_root = nil)
|
|
153
|
+
return line if line.nil? || line.empty?
|
|
154
|
+
|
|
155
|
+
project_root ||= detect_project_root
|
|
156
|
+
expanded_root = File.expand_path(project_root)
|
|
157
|
+
|
|
158
|
+
# Extract file path from backtrace line (format: "path:line:in `method'" or "path:line")
|
|
159
|
+
if line =~ /^(.+?):\d+(?::in `.+')?$/
|
|
160
|
+
file_path = ::Regexp.last_match(1)
|
|
161
|
+
suffix = line[file_path.length..]
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
expanded_path = File.expand_path(file_path)
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
# Handle malformed paths (e.g., containing null bytes)
|
|
167
|
+
# File.basename also raises ArgumentError for null bytes, so use simple string manipulation
|
|
168
|
+
basename = file_path.split('/').last || file_path
|
|
169
|
+
return "[EXTERNAL] #{basename}#{suffix}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Try project-relative path first
|
|
173
|
+
if expanded_path.start_with?(expanded_root + File::SEPARATOR)
|
|
174
|
+
relative_path = expanded_path.delete_prefix(expanded_root + File::SEPARATOR)
|
|
175
|
+
return relative_path + suffix
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check for bundler gems specifically (e.g., /bundler/gems/otto-abc123/lib/...)
|
|
179
|
+
# Must check BEFORE regular gems pattern to handle /gems/3.4.0/bundler/gems/...
|
|
180
|
+
if expanded_path =~ %r{/bundler/gems/([^/]+)/(.+)$}
|
|
181
|
+
gem_name = ::Regexp.last_match(1)
|
|
182
|
+
gem_relative = ::Regexp.last_match(2)
|
|
183
|
+
# Strip git hash suffix for cleaner output (otto-abc123def456 → otto)
|
|
184
|
+
gem_name = gem_name.sub(/-[a-f0-9]{7,}$/, '') if gem_name =~ /-[a-f0-9]{7,}$/i
|
|
185
|
+
return "[GEM] #{gem_name}/#{gem_relative}#{suffix}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Check for regular gem path (e.g., /path/to/gems/rack-3.1.8/lib/rack.rb)
|
|
189
|
+
# Handle version directories: /gems/3.4.0/gems/rack-3.1.8/... by looking for last /gems/
|
|
190
|
+
if expanded_path =~ %r{/gems/([^/]+)/(.+)$}
|
|
191
|
+
gem_name = ::Regexp.last_match(1)
|
|
192
|
+
gem_relative = ::Regexp.last_match(2)
|
|
193
|
+
|
|
194
|
+
# Skip version-only directory names (e.g., 3.4.0)
|
|
195
|
+
# Look deeper if gem_name is just a version number
|
|
196
|
+
if gem_name =~ /^[\d.]+$/ && gem_relative =~ %r{^(?:bundler/)?gems/([^/]+)/(.+)$}
|
|
197
|
+
# Found nested gem path, use that instead
|
|
198
|
+
gem_name = ::Regexp.last_match(1)
|
|
199
|
+
gem_relative = ::Regexp.last_match(2)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Strip version suffix for cleaner output (rack-3.1.8 → rack)
|
|
203
|
+
base_gem_name = gem_name.split('-')[0..-2].join('-')
|
|
204
|
+
base_gem_name = gem_name if base_gem_name.empty?
|
|
205
|
+
|
|
206
|
+
return "[GEM] #{base_gem_name}/#{gem_relative}#{suffix}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Check for Ruby stdlib (e.g., /path/to/ruby/3.4.0/logger.rb)
|
|
210
|
+
if expanded_path =~ %r{/ruby/[\d.]+/(.+)$}
|
|
211
|
+
stdlib_file = ::Regexp.last_match(1)
|
|
212
|
+
return "[RUBY] #{stdlib_file}#{suffix}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Unknown/external path - show filename only
|
|
216
|
+
filename = File.basename(file_path)
|
|
217
|
+
return "[EXTERNAL] #{filename}#{suffix}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Couldn't parse - return as-is (better than failing)
|
|
221
|
+
line
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Sanitize an array of backtrace lines
|
|
225
|
+
#
|
|
226
|
+
# @param backtrace [Array<String>] Raw backtrace lines
|
|
227
|
+
# @param project_root [String] Project root path (auto-detected if nil)
|
|
228
|
+
# @return [Array<String>] Sanitized backtrace lines
|
|
229
|
+
def self.sanitize_backtrace(backtrace, project_root: nil)
|
|
230
|
+
return [] if backtrace.nil? || backtrace.empty?
|
|
231
|
+
|
|
232
|
+
project_root ||= detect_project_root
|
|
233
|
+
backtrace.map { |line| sanitize_backtrace_line(line, project_root) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Log exception backtrace with correlation fields for debugging.
|
|
237
|
+
# Always logs for unhandled errors at ERROR level with sanitized paths.
|
|
238
|
+
# Limits to first 20 lines for critical errors.
|
|
239
|
+
#
|
|
240
|
+
# SECURITY: Paths are sanitized to prevent exposing sensitive system information:
|
|
241
|
+
# - Project files: Show relative paths only
|
|
242
|
+
# - Gem files: Show gem name and relative path within gem
|
|
243
|
+
# - Ruby stdlib: Show filename only
|
|
244
|
+
# - External files: Show filename only
|
|
245
|
+
#
|
|
246
|
+
# Expects caller to provide correlation context (error_id, handler, etc).
|
|
247
|
+
# Does NOT duplicate error/error_class fields - those belong in main error log.
|
|
248
|
+
#
|
|
249
|
+
# @param error [Exception] The exception to log backtrace for
|
|
250
|
+
# @param context [Hash] Correlation fields (error_id, method, path, ip, handler, etc)
|
|
251
|
+
#
|
|
252
|
+
# @example Basic usage
|
|
253
|
+
# Otto::LoggingHelpers.log_backtrace(error,
|
|
254
|
+
# base_context.merge(error_id: error_id, handler: 'UserController#create')
|
|
255
|
+
# )
|
|
256
|
+
#
|
|
257
|
+
# @example Downstream extensibility
|
|
258
|
+
# custom_context = Otto::LoggingHelpers.request_context(env).merge(
|
|
259
|
+
# error_id: error_id,
|
|
260
|
+
# transaction_id: Thread.current[:transaction_id],
|
|
261
|
+
# tenant_id: env['tenant_id']
|
|
262
|
+
# )
|
|
263
|
+
# Otto::LoggingHelpers.log_backtrace(error, custom_context)
|
|
264
|
+
#
|
|
265
|
+
def self.log_backtrace(error, context = {})
|
|
266
|
+
raw_backtrace = error.backtrace&.first(20) || []
|
|
267
|
+
sanitized = sanitize_backtrace(raw_backtrace)
|
|
268
|
+
|
|
269
|
+
Otto.structured_log(:error, 'Exception backtrace',
|
|
270
|
+
context.merge(backtrace: sanitized))
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/lib/otto/mcp/auth/token.rb
CHANGED
data/lib/otto/mcp/protocol.rb
CHANGED
data/lib/otto/mcp/registry.rb
CHANGED
data/lib/otto/mcp/server.rb
CHANGED
data/lib/otto/mcp.rb
CHANGED