otto 2.9.0 → 2.10.0

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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -0,0 +1,193 @@
1
+ # Privacy-preserving request data
2
+
3
+ Otto's default request posture is to reduce the precision of public client
4
+ information before application code, logging, authentication, rate limiting,
5
+ and other middleware see it. The privacy pipeline is a Rack concern, not only a
6
+ router feature: if an outer Rack middleware logs the request before Otto runs,
7
+ place the privacy middleware in the common stack first.
8
+
9
+ ## Default behavior
10
+
11
+ With the default `:masked` profile:
12
+
13
+ - public IP addresses are masked by the configured octet precision (one octet
14
+ by default: `203.0.113.9` becomes `203.0.113.0`);
15
+ - requests from private and loopback addresses are exempt from the privacy
16
+ fingerprint by default, so their IP, user agent, and referer remain unchanged;
17
+ - for requests that are masked, user-agent version details are anonymized and
18
+ referer query parameters are removed;
19
+ - original public values are not retained in the Rack environment; and
20
+ - country resolution is country-level only and returns `**` when enabled but no
21
+ configured source answers.
22
+
23
+ Downstream code should read `req.ip`, `req.masked_ip`, and the documented privacy
24
+ environment keys rather than re-resolving an address from forwarded headers.
25
+
26
+ These profiles are technical data-minimization controls, not a compliance
27
+ certification. Whether a deployment meets GDPR, CCPA, or another legal regime
28
+ also depends on its purposes, notices, retention, access controls, vendors, and
29
+ jurisdiction.
30
+
31
+ ## Profiles
32
+
33
+ Choose a named profile when the deployment posture should be obvious in review:
34
+
35
+ ```ruby
36
+ otto = Otto.new('routes')
37
+ otto.configure_ip_privacy(profile: :masked) # default
38
+ # or:
39
+ otto.configure_ip_privacy(profile: :anonymous) # mask private/localhost too
40
+ # or:
41
+ otto.configure_ip_privacy(profile: :audit) # disable IP privacy
42
+ ```
43
+
44
+ | Profile | Public IPs | Private/localhost IPs | Use when |
45
+ | --- | --- | --- | --- |
46
+ | `:masked` | Masked | Exempt by default | General privacy-by-default deployments and local development. |
47
+ | `:anonymous` | Masked | Masked | Internal addresses must also be treated as identifying data. |
48
+ | `:audit` | Not masked | Not masked | The operator has deliberately accepted raw-IP retention and controls logs and downstream systems. |
49
+
50
+ The `:audit` profile transfers retention responsibility to the operator. It is
51
+ not a way to obtain precise matching while keeping privacy enabled; use
52
+ `env['otto.ip_match']` for that narrower need.
53
+
54
+ Configuration is boot-time only. The first request freezes configuration in
55
+ normal operation, so set profiles, trusted proxies, database readers, and other
56
+ privacy settings before serving traffic.
57
+
58
+ ## Privacy-safe request values
59
+
60
+ Inside a handler:
61
+
62
+ ```ruby
63
+ class Analytics
64
+ def self.record(req, res)
65
+ event = {
66
+ ip: req.ip,
67
+ country: req.geo_country,
68
+ asn: req.asn,
69
+ anonymizer: req.anonymizer,
70
+ user_agent: req.user_agent,
71
+ }
72
+
73
+ AuditLog.write(event)
74
+ res.status = 204
75
+ res.body = []
76
+ end
77
+ end
78
+ ```
79
+
80
+ Common values are also available in the Rack environment:
81
+
82
+ | Value | Environment key | Contract |
83
+ | --- | --- | --- |
84
+ | Canonical client IP | `otto.client_ip` | Masked IP when masking applies; resolved full IP under `:audit` or for an exempt private/loopback request. |
85
+ | Precise CIDR verdict | `otto.ip_match` | Callable that checks the resolved full IP against CIDRs and returns only `true` or `false`. |
86
+ | Masked IP | `otto.privacy.masked_ip` | Set when the request runs through the privacy fingerprint; absent for exempt or `:audit` requests. |
87
+ | Rotating IP hash | `otto.privacy.hashed_ip` | Correlation value computed with Otto's rotating key; absent for exempt or `:audit` requests. |
88
+ | Stable correlation hash | `otto.privacy.correlation_hash` | HMAC value when `correlation_secret:` is configured; otherwise `nil`. |
89
+ | Country | `otto.privacy.geo_country` | ISO 3166-1 alpha-2 code, `**` when enabled but unresolved, or `nil` when geo/privacy is disabled. |
90
+ | ASN | `otto.privacy.asn` | `nil` when off, `**` when enabled but unresolved, or a value such as `AS15169`. |
91
+ | Anonymizer | `otto.privacy.anonymizer` | `nil` when off, `**` when no database answers, or a classification label. |
92
+
93
+ `req.hashed_ip` is designed for short-lived correlation using a rotating key.
94
+ For long-lived correlation, explicitly configure a stable secret and protect
95
+ that secret as sensitive configuration. Changing the secret changes every
96
+ correlation hash.
97
+
98
+ ## Country, ASN, and anonymizer data
99
+
100
+ These signals have different trust and precision models:
101
+
102
+ - [Geo-country resolution](geo-country.md) documents trusted provider headers,
103
+ CIDR trusted-proxy requirements, masked MMDB lookup, and the unknown sentinel.
104
+ - [ASN and anonymizer enrichment](enrichment.md) documents the opt-in database
105
+ contracts. ASN uses a masked address; anonymizer classification deliberately
106
+ uses the unmasked address internally and emits only a label.
107
+
108
+ Enable optional signals explicitly:
109
+
110
+ ```ruby
111
+ otto.configure_ip_privacy(
112
+ geo: true,
113
+ geo_db_path: 'data/country.mmdb',
114
+ asn: true,
115
+ asn_db_path: 'data/origin-asn.mmdb',
116
+ anonymizer: true,
117
+ anonymizer_db_path: 'data/anonymizer.mmdb'
118
+ )
119
+ ```
120
+
121
+ The `maxmind-db` gem is optional and is required only when a database path is
122
+ configured. A reader object responding to `#get(ip)` can be injected instead.
123
+ Database paths are opened at configuration time; invalid paths fail during boot
124
+ rather than on an arbitrary request.
125
+
126
+ ## Trusted proxy and matching boundaries
127
+
128
+ Configure trusted proxies before relying on forwarded client information:
129
+
130
+ ```ruby
131
+ otto = Otto.new(
132
+ 'routes',
133
+ trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
134
+ )
135
+ ```
136
+
137
+ CIDR-based trust lets Otto verify the proxy peer. Count-based
138
+ `trusted_proxy_depth` is a separate mode and does not make geo headers
139
+ trustworthy. A configured `geo_header` combined with depth mode is rejected at
140
+ configuration time; use a local database in depth-mode deployments instead.
141
+
142
+ The same trust decision also gates the forwarded host, scheme, and port headers
143
+ that `Rack::Request#host` reads. See
144
+ [Forwarded host authority](forwarded-authority.md), including the explicit
145
+ `trusted_proxies: :none` assertion for directly exposed applications.
146
+
147
+ For precise access control without exposing the address to application code,
148
+ call the automatically installed `env['otto.ip_match']` capability. It matches
149
+ the resolved full client IP against application CIDRs and returns only
150
+ `true`/`false`; it fails closed when no client IP resolves. Do not log or persist
151
+ the closure.
152
+
153
+ ```ruby
154
+ allowed = req.env.fetch('otto.ip_match').call(['192.0.2.0/24', '2001:db8::/32'])
155
+ ```
156
+
157
+ ## Middleware placement
158
+
159
+ When building a larger Rack stack, put privacy before components that log or
160
+ inspect the request:
161
+
162
+ ```ruby
163
+ builder.use Otto::Security::Middleware::IPPrivacyMiddleware, otto.security_config
164
+ builder.use Rack::CommonLogger
165
+ builder.use Sentry::Rack::CaptureExceptions
166
+ # Then mount the Otto application.
167
+ ```
168
+
169
+ Otto also installs its privacy middleware internally. The outer common-stack
170
+ placement is needed when middleware outside the Otto app would otherwise see the
171
+ raw peer first.
172
+
173
+ Pass `otto.security_config` as shown. The outer instance resolves `otto.client_ip`
174
+ first and the inner one then short-circuits, so an outer instance constructed
175
+ without the configuration would silently apply defaults instead of the
176
+ application's profile, precision, correlation secret, and enrichment settings.
177
+ Proxy trust is the exception: the inner instance re-applies its own trust
178
+ posture even after an outer pass, and a CIDR configuration that can no longer
179
+ see the connecting peer fails closed. See
180
+ [Forwarded host authority](forwarded-authority.md#place-the-middleware-before-other-request-consumers).
181
+
182
+ ## Operational rules
183
+
184
+ - Treat `nil`, `'**'`, `'none'`, and a real label as different states. In
185
+ particular, anonymizer `'none'` means the database was consulted and did not
186
+ list the address; `'**'` means no database answered.
187
+ - Do not use provider geo headers without a verifiable CIDR trusted proxy. A
188
+ client can otherwise submit its own country.
189
+ - Do not confuse the masked client IP with the raw peer used to authenticate a
190
+ local network service. Integrations such as Caddy TLS have their own trust
191
+ boundary.
192
+ - Test logs and monitoring outside Otto's stack. Privacy is only effective if
193
+ those components receive the masked environment first.
@@ -0,0 +1,181 @@
1
+ # Routing applications with Otto
2
+
3
+ Otto keeps the application boundary small: a plain-text route file maps an HTTP
4
+ verb and path to a Ruby handler. Use this guide to choose a handler style and
5
+ response contract. The exact route grammar is in the [route syntax reference](../reference/route-syntax.md).
6
+
7
+ ## Choose a handler style
8
+
9
+ | Use this when | Route target | Invocation |
10
+ | --- | --- | --- |
11
+ | You need a class method with direct Rack access | `App.index` | `App.index(req, res)` |
12
+ | You need an object with direct Rack access | `App#show` | `App.new(req, res).show` |
13
+ | You want a constrained, testable application operation | `App::Operation` | `App::Operation.new(strategy_result, params, locale)` |
14
+ | You need a small pre-registered endpoint function | `&name` | `call(req, res, captured_path_params)` |
15
+
16
+ ## Controller-style handlers
17
+
18
+ Routes can call a class method or instantiate a class for an instance method:
19
+
20
+ ```text
21
+ GET / App.index
22
+ GET /products/:id App#show
23
+ ```
24
+
25
+ ```ruby
26
+ class App
27
+ def initialize(req, res)
28
+ @req = req
29
+ @res = res
30
+ end
31
+
32
+ def show
33
+ @res.body = "Product: #{@req.params[:id]}"
34
+ end
35
+
36
+ def self.index(req, res)
37
+ res['content-type'] = 'text/plain'
38
+ res.body = 'Hello Otto'
39
+ end
40
+ end
41
+ ```
42
+
43
+ Use this style when the handler needs cookies, request headers, the Rack
44
+ request object, or direct response helpers.
45
+
46
+ ## Logic classes
47
+
48
+ Use a bare class target for an operation with an explicit input context:
49
+
50
+ ```text
51
+ GET /products/:id Products::Show auth=session response=json
52
+ POST /products Products::Create auth=session response=json
53
+ ```
54
+
55
+ ```ruby
56
+ class Products::Show
57
+ def initialize(strategy_result, params, locale)
58
+ @context = strategy_result
59
+ @params = params
60
+ @locale = locale
61
+ end
62
+
63
+ def raise_concerns
64
+ @product = Product.find(@params[:id])
65
+ unless @product.public? || @product.owner_id == @context.user_id
66
+ raise Otto::Security::AuthorizationError, 'Product access denied'
67
+ end
68
+ end
69
+
70
+ def process
71
+ { id: @product.id, name: @product.name, locale: @locale }
72
+ end
73
+ end
74
+ ```
75
+
76
+ Otto runs `raise_concerns` before `process` when those methods exist. Put
77
+ resource loading and resource-level authorization in `raise_concerns`; route
78
+ authentication and broad role checks belong in the route definition.
79
+
80
+ Logic classes do not receive the Rack environment. This keeps their inputs
81
+ explicit and prevents application operations from depending on ambient request
82
+ state. Choose a controller-style handler when direct request access is part of
83
+ the operation.
84
+
85
+ ## Registered lambda handlers
86
+
87
+ Lambda routes are useful for small endpoints that do not need a Ruby constant or
88
+ handler class. Register the callable at boot:
89
+
90
+ ```ruby
91
+ otto = Otto.new('routes', lambda_handlers: {
92
+ health_check: lambda do |_req, res, _extra_params|
93
+ res['content-type'] = 'text/plain'
94
+ res.body = 'ok'
95
+ end,
96
+ })
97
+ ```
98
+
99
+ ```text
100
+ GET /health &health_check
101
+ ```
102
+
103
+ The registry is normalized and frozen during configuration. A lambda must
104
+ accept three positional arguments: request, response, and captured path
105
+ parameters. Query and form parameters remain available through `req.params`.
106
+ The route name is an exact registry key; it is not evaluated as Ruby code.
107
+
108
+ ## Response selection
109
+
110
+ Use `response=` when the handler returns a value that should pass through Otto's
111
+ response handling:
112
+
113
+ ```text
114
+ GET /api/products Products::Index response=json
115
+ GET /dashboard Dashboard#show response=view
116
+ POST /login Sessions#create response=redirect
117
+ GET /data Data#show response=auto
118
+ ```
119
+
120
+ `response=default` is the default. Keep response selection in the route file
121
+ so the HTTP contract is visible beside the endpoint.
122
+
123
+ | Response type | Handler contract |
124
+ | --- | --- |
125
+ | `default` | Mutate `res` directly. The handler's return value is ignored. |
126
+ | `json` | Return a Hash for direct JSON serialization. `nil` becomes `{ "success": true }`; another value is wrapped as `data`. A Logic class may instead provide `response_data`. |
127
+ | `view` | Return a value rendered with `to_s`, or provide `view.render` on a Logic object. |
128
+ | `redirect` | Return a path String, or provide `redirect_path` on a Logic object. The fallback path is `/`. |
129
+ | `auto` | A Hash becomes JSON, a path-like String becomes a redirect, and a Logic object with `view` uses the view handler; other results use default behavior. |
130
+
131
+ An unknown response name currently falls back to `default`. Treat response
132
+ names as a fixed set; a typo otherwise changes the route to direct-response
133
+ behavior.
134
+
135
+ ## Route parameters
136
+
137
+ Named path segments are available in request parameters:
138
+
139
+ ```text
140
+ GET /products/:id Products::Show
141
+ ```
142
+
143
+ A handler can read `req.params[:id]` or a Logic class can read `params[:id]`.
144
+ Request query and body parameters are merged according to the handler's request
145
+ contract. JSON bodies are parsed for Logic-class parameters when the content
146
+ type is JSON and the body is a JSON object. A valid non-object JSON body is
147
+ ignored. Malformed JSON is logged and the Logic class still runs with its other
148
+ parameters; perform application validation when malformed JSON must return a
149
+ client error.
150
+
151
+ ## Security options in routes
152
+
153
+ Authentication, roles, and CSRF exemptions are explicit route options:
154
+
155
+ ```text
156
+ GET /profile Profile#show auth=session
157
+ GET /admin Admin::Dashboard auth=session role=admin
158
+ POST /hook Hooks#receive csrf=exempt
159
+ ```
160
+
161
+ Malformed `auth`, `role`, and `csrf` tokens fail route parsing rather than
162
+ silently weakening the route. Do not use `csrf=exempt` as a general API switch;
163
+ choose an independent request-authentication and replay-protection model for
164
+ webhooks or other non-browser endpoints.
165
+
166
+ ## Configuration timing
167
+
168
+ Construct and configure the Otto instance before the first request:
169
+
170
+ ```ruby
171
+ otto = Otto.new('routes')
172
+ otto.add_auth_strategy(
173
+ 'session',
174
+ Otto::Security::Authentication::Strategies::SessionStrategy.new
175
+ )
176
+ otto.register_request_helpers(MyApp::RequestHelpers)
177
+ # Add middleware and other boot-time options here.
178
+ ```
179
+
180
+ In normal operation, the first request freezes configuration. Runtime route or
181
+ security changes are not part of the application contract.
@@ -0,0 +1,281 @@
1
+ # Structured logging
2
+
3
+ Otto provides explicit logging helpers for request context, microsecond timing,
4
+ and backtrace path reduction. The helpers do not sanitize arbitrary metadata;
5
+ callers remain responsible for excluding secrets and personal data.
6
+
7
+ ## Logging helpers
8
+
9
+ Otto provides several helper methods for consistent logging:
10
+
11
+ ```ruby
12
+ # Request context extraction
13
+ Otto::LoggingHelpers.request_context(env)
14
+ # May include: { method:, path:, ip:, country:, user_agent: }
15
+
16
+ # Timed operation logging
17
+ Otto::LoggingHelpers.log_timed_operation(level, message, env, **metadata) { block }
18
+ ```
19
+
20
+ ## Logging patterns
21
+
22
+ For request-scoped structured logging, use `Otto.structured_log` with
23
+ `LoggingHelpers.request_context(env).merge()`:
24
+
25
+ `request_context(env)` creates a new context hash, and `.merge` creates another
26
+ hash rather than mutating shared state. Treat the request `env` and values inside
27
+ it as request-owned data; the helper does not deep-copy mutable strings.
28
+
29
+ ```ruby
30
+ # Route logging
31
+ Otto.structured_log(:debug, "Route matched",
32
+ Otto::LoggingHelpers.request_context(env).merge(
33
+ type: 'literal',
34
+ handler: route.route_definition.definition,
35
+ auth_strategy: route.route_definition.auth_requirement || 'none'
36
+ )
37
+ )
38
+
39
+ # Authentication logging
40
+ Otto.structured_log(:info, "Auth strategy result",
41
+ Otto::LoggingHelpers.request_context(env).merge(
42
+ strategy: strategy.class.name.split('::').last.downcase.gsub('strategy', ''),
43
+ success: true,
44
+ user_id: result.user_id,
45
+ duration: duration_μs
46
+ )
47
+ )
48
+ ```
49
+
50
+ For operations that need timing, use `log_timed_operation` which wraps `structured_log` with automatic timing:
51
+
52
+ ```ruby
53
+ # Template compilation with timing
54
+ result = Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
55
+ template_type: 'handlebars',
56
+ cached: false
57
+ ) do
58
+ compile_template(template_path)
59
+ end
60
+
61
+ # Database operation with timing
62
+ Otto::LoggingHelpers.log_timed_operation(:debug, "User lookup", env,
63
+ user_id: user_id,
64
+ cache_hit: false
65
+ ) do
66
+ User.find(user_id)
67
+ end
68
+ ```
69
+
70
+ ## Timing conventions
71
+
72
+ Otto uses **microseconds** for all timing measurements via `Otto::Utils.now_in_μs`:
73
+
74
+ ```ruby
75
+ # Manual timing
76
+ start_time = Otto::Utils.now_in_μs
77
+ result = perform_operation()
78
+ duration = Otto::Utils.now_in_μs - start_time
79
+
80
+ Otto.structured_log(:info, "Operation completed",
81
+ Otto::LoggingHelpers.request_context(env).merge(
82
+ operation: 'user_creation',
83
+ duration: duration # Always in microseconds
84
+ )
85
+ )
86
+
87
+ # Automatic timing with error handling
88
+ Otto::LoggingHelpers.log_timed_operation(:info, "Database query", env,
89
+ table: 'users',
90
+ query_type: 'SELECT'
91
+ ) do
92
+ database.execute(query)
93
+ end
94
+ ```
95
+
96
+ ## Request fields
97
+
98
+ Request-scoped events should normally merge `request_context(env)`, which adds
99
+ available values for:
100
+
101
+ - **method** - HTTP method (`GET`, `POST`, etc.)
102
+ - **path** - request path
103
+ - **ip** - canonical client IP under the configured privacy profile
104
+ - **country** - resolved country when available
105
+ - **user_agent** - current request user agent, truncated to 100 characters
106
+
107
+ Unavailable values are omitted. Non-request events, such as configuration or
108
+ startup logs, should include only relevant event-specific fields. Timed events
109
+ also include **duration** in microseconds.
110
+
111
+ Additional fields such as `user_id`, `handler`, `error`, or `error_class` may be
112
+ useful, but Otto does not redact them. Do not log credentials, session tokens,
113
+ raw request parameters, or exception messages that may contain secrets.
114
+
115
+ ## Error handling in timed operations
116
+
117
+ `log_timed_operation` automatically handles exceptions:
118
+
119
+ ```ruby
120
+ # Successful operation
121
+ Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env, template: 'user') do
122
+ compile_template('user')
123
+ end
124
+ # Logs: Template compiled: method=GET path=/users template=user duration=15230
125
+
126
+ # Failed operation (automatic error logging + re-raise)
127
+ Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env, template: 'user') do
128
+ raise StandardError, "Template not found"
129
+ end
130
+ # Logs: Template compiled failed: method=GET path=/users template=user duration=1520 error="Template not found" error_class=StandardError
131
+ # Then re-raises the original exception
132
+ ```
133
+
134
+ ## Privacy behavior
135
+
136
+ `request_context` prefers `env['otto.client_ip']`, the canonical value produced
137
+ by `IPPrivacyMiddleware`:
138
+
139
+ - Under the normal privacy profiles, public addresses are masked.
140
+ - Under the `:audit` profile or after `disable_ip_privacy!`, the canonical public
141
+ address is raw and `request_context` logs it unchanged.
142
+ - Private and localhost addresses remain raw under the default `:masked` profile;
143
+ the `:anonymous` profile masks them too.
144
+ - Outside Otto's middleware, when `otto.client_ip` is absent,
145
+ `privacy_safe_ip` masks a public `REMOTE_ADDR` using the default precision and
146
+ returns `[redacted]` for an unparseable address.
147
+ - `request_context` truncates the current `HTTP_USER_AGENT` to 100 characters.
148
+ It is anonymized only if the privacy middleware has already anonymized it.
149
+
150
+ The helper does not make arbitrary metadata privacy-safe. Review every field
151
+ added by the caller.
152
+
153
+ ## Backtrace path reduction
154
+
155
+ Otto reduces filesystem details in recognized Ruby backtrace lines. This is
156
+ defense in depth, not a confidentiality boundary: custom or unrecognized lines
157
+ are returned unchanged.
158
+
159
+ **Risks of raw backtraces:**
160
+ - Expose absolute paths revealing usernames (`/Users/alice/`, `/home/admin/`)
161
+ - Reveal project structure and internal organization
162
+ - Show gem installation paths and Ruby versions
163
+ - Leak system architecture details
164
+
165
+ **Automatic path reduction in `Otto.structured_log`:**
166
+
167
+ `Otto.structured_log` applies the backtrace sanitizer only when the metadata is
168
+ a hash whose exact `:backtrace` key contains an array. Other fields and arrays
169
+ are passed unchanged.
170
+
171
+ **Path-reduction rules:**
172
+
173
+ ```ruby
174
+ # Project files → relative paths only
175
+ "/Users/alice/myapp/app/controllers/users_controller.rb:42:in `create'"
176
+ # ↓ SANITIZED TO:
177
+ "app/controllers/users_controller.rb:42:in `create'"
178
+
179
+ # Bundler gems → [GEM] tag with gem name only
180
+ "/Users/alice/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/bundler/gems/otto-34f285412a44/lib/otto/route.rb:142"
181
+ # ↓ SANITIZED TO:
182
+ "[GEM] otto/lib/otto/route.rb:142"
183
+
184
+ # Regular gems → [GEM] tag, version stripped
185
+ "/opt/ruby/gems/3.4.0/gems/rack-3.2.4/lib/rack/builder.rb:310"
186
+ # ↓ SANITIZED TO:
187
+ "[GEM] rack/lib/rack/builder.rb:310"
188
+
189
+ # Ruby stdlib → [RUBY] tag with filename only
190
+ "/Users/alice/.rbenv/versions/3.4.7/lib/ruby/3.4.0/logger.rb:310"
191
+ # ↓ SANITIZED TO:
192
+ "[RUBY] logger.rb:310"
193
+
194
+ # Unknown/external → filename only
195
+ "/some/unknown/path/file.rb:50"
196
+ # ↓ SANITIZED TO:
197
+ "[EXTERNAL] file.rb:50"
198
+ ```
199
+
200
+ **Usage:**
201
+
202
+ Path reduction happens automatically when using `log_backtrace`. This helper
203
+ logs at `:error` and limits the backtrace to its first 20 lines:
204
+
205
+ ```ruby
206
+ # In error handlers (Otto does this automatically)
207
+ Otto::LoggingHelpers.log_backtrace(error,
208
+ Otto::LoggingHelpers.request_context(env).merge(
209
+ error_id: error_id,
210
+ handler: 'UserController#create'
211
+ )
212
+ )
213
+
214
+ # Manual usage if needed
215
+ sanitized = Otto::LoggingHelpers.sanitize_backtrace(error.backtrace)
216
+ ```
217
+
218
+ ## Anti-patterns
219
+
220
+ **❌ Don't create event classes:**
221
+ ```ruby
222
+ # NO - Adds unnecessary abstraction
223
+ event = RouteMatchEvent.new(type: :literal, method: http_verb, path: path)
224
+ Otto.structured_log(event.level, event.message, event.to_h)
225
+ ```
226
+
227
+ **❌ Don't create helper wrappers:**
228
+ ```ruby
229
+ # NO - Hides what's being logged
230
+ Otto::Logging.log_route_match(type: :literal, method: http_verb, path: path, env: env)
231
+ ```
232
+
233
+ **❌ Don't mix timing units:**
234
+ ```ruby
235
+ # NO - Inconsistent units
236
+ duration_ms = (Otto::Utils.now_in_μs - start_time) / 1000 # Converting to milliseconds
237
+ Otto.structured_log(:info, "Operation done", { duration_ms: duration_ms })
238
+ ```
239
+
240
+ **✅ Do use explicit inline logging:**
241
+ ```ruby
242
+ # YES - Clear, simple, explicit
243
+ Otto.structured_log(:debug, "Route matched",
244
+ Otto::LoggingHelpers.request_context(env).merge(
245
+ type: 'literal',
246
+ handler: 'App#index'
247
+ )
248
+ )
249
+
250
+ # YES - Consistent microsecond timing
251
+ Otto.structured_log(:info, "Operation completed",
252
+ Otto::LoggingHelpers.request_context(env).merge(
253
+ operation: 'user_lookup',
254
+ duration: Otto::Utils.now_in_μs - start_time
255
+ )
256
+ )
257
+ ```
258
+
259
+ ## Output behavior
260
+
261
+ `Otto.logger` defaults to Ruby's `Logger`. With that logger, metadata is rendered
262
+ inside a formatted string:
263
+
264
+ ```text
265
+ I, [2025-01-21T14:39:39.462833 #82244] INFO -- : [Otto] Template compiled -- {method: "GET", path: "/users", ip: "192.0.2.0", template_type: "handlebars", cached: false, duration: 68}
266
+ ```
267
+
268
+ For a logger whose level method has a fixed arity greater than one, Otto calls
269
+ that method as `logger.info(message, metadata)`. Other logger APIs use the
270
+ formatted-string fallback. Verify the adapter for a third-party structured
271
+ logger before relying on separately indexed fields; Otto does not ship or test
272
+ a SemanticLogger adapter.
273
+
274
+ ## Rationale
275
+
276
+ - **Simplicity**: Direct logging calls are easier to understand than abstraction layers
277
+ - **Explicitness**: You can see exactly what's being logged at the call site
278
+ - **Flexibility**: Easy to add one-off fields without modifying event classes
279
+ - **Performance**: Disabled debug events are not sent to the logger; guard expensive metadata construction with `if Otto.debug` because method arguments are evaluated first
280
+ - **Consistency**: All timing in microseconds, automatic error handling for timed operations
281
+ - **Maintainability**: One helper file vs multiple event classes/helpers