aris 1.4.2 → 1.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +251 -0
- data/README.md +18 -0
- data/docs/ADAPTERS.md +478 -0
- data/docs/ARCHITECTURE.md +222 -0
- data/docs/CONTENT.md +967 -0
- data/docs/PERFORMANCE.md +492 -0
- data/docs/PLUGIN_DEVELOPMENT.md +688 -0
- data/docs/USAGE.md +4998 -0
- data/docs/plugins/API_KEY_AUTH.md +232 -0
- data/docs/plugins/BASIC_AUTH.md +582 -0
- data/docs/plugins/BEARER_AUTH.md +394 -0
- data/docs/plugins/CACHE.md +369 -0
- data/docs/plugins/COMPRESSION.md +216 -0
- data/docs/plugins/COOKIES.md +30 -0
- data/docs/plugins/CORS.md +283 -0
- data/docs/plugins/CSRF.md +751 -0
- data/docs/plugins/ETAG.md +308 -0
- data/docs/plugins/FORM_PARSER.md +193 -0
- data/docs/plugins/HEALTH_CHECK.md +469 -0
- data/docs/plugins/JSON.md +291 -0
- data/docs/plugins/MULTIPART.md +427 -0
- data/docs/plugins/RATE_LIMITER.md +368 -0
- data/docs/plugins/REQUEST_ID.md +369 -0
- data/docs/plugins/REQUEST_LOGGER.md +151 -0
- data/docs/plugins/SECURITY.md +193 -0
- data/docs/plugins/SESSION.md +98 -0
- data/lib/aris/adapters/rack/adapter.rb +17 -2
- data/lib/aris/adapters/rack/request.rb +29 -11
- data/lib/aris/plugins/basic_auth.rb +3 -1
- data/lib/aris/plugins/cookies.rb +4 -32
- data/lib/aris/plugins/cors.rb +8 -1
- data/lib/aris/plugins/csrf.rb +63 -22
- data/lib/aris/plugins/flash.rb +3 -1
- data/lib/aris/plugins/form_parser.rb +52 -31
- data/lib/aris/plugins/multipart.rb +22 -2
- data/lib/aris/plugins/request_logger.rb +8 -1
- data/lib/aris/plugins/security_headers.rb +8 -1
- data/lib/aris/plugins/session.rb +150 -99
- data/lib/aris/response_helpers.rb +41 -0
- data/lib/aris/version.rb +2 -2
- metadata +31 -3
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# Rate Limiter Plugin
|
|
2
|
+
|
|
3
|
+
Throttle requests to prevent abuse, brute force attacks, and API overuse.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# lib/aris.rb already includes this
|
|
9
|
+
require_relative 'aris/plugins/rate_limiter'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## How It Works
|
|
13
|
+
|
|
14
|
+
Tracks request counts per key (API key, IP address, user ID) within a time window. Returns **429 Too Many Requests** when limit exceeded.
|
|
15
|
+
|
|
16
|
+
**Default:** 100 requests per 60 seconds per key.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Basic Usage
|
|
21
|
+
|
|
22
|
+
### Simple API Rate Limiting
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
Aris.routes({
|
|
26
|
+
"api.example.com": {
|
|
27
|
+
use: [:rate_limit], # Default: 100 requests/60s
|
|
28
|
+
"/data": { get: { to: DataHandler } }
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requests include rate limit headers:
|
|
34
|
+
```
|
|
35
|
+
X-RateLimit-Limit: 100
|
|
36
|
+
X-RateLimit-Remaining: 47
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
When limit exceeded:
|
|
40
|
+
```
|
|
41
|
+
HTTP/1.1 429 Too Many Requests
|
|
42
|
+
Retry-After: 60
|
|
43
|
+
Rate limit exceeded. Try again later.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Rate limiting keys **default to API key or host**:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Uses HTTP_X_API_KEY header if present, else falls back to HTTP_HOST
|
|
54
|
+
request.headers['HTTP_X_API_KEY'] || request.host
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**For custom configuration**, you need to build a custom instance. The plugin is currently not configurable via symbol registration.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Advanced Usage
|
|
62
|
+
|
|
63
|
+
### Custom Instance (Not Yet Supported)
|
|
64
|
+
|
|
65
|
+
The current implementation is fixed at 100 requests per 60 seconds. For production use, you'll want to enhance it:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Future enhancement - custom limits
|
|
69
|
+
rate_limit = Aris::Plugins::RateLimiter.build(
|
|
70
|
+
limit: 1000,
|
|
71
|
+
window: 3600, # 1 hour
|
|
72
|
+
key_extractor: ->(request) {
|
|
73
|
+
# Rate limit by authenticated user
|
|
74
|
+
request.instance_variable_get(:@current_user)
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
Aris.routes({
|
|
79
|
+
"api.example.com": {
|
|
80
|
+
use: [rate_limit],
|
|
81
|
+
"/data": { get: { to: DataHandler } }
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Combining with Authentication
|
|
89
|
+
|
|
90
|
+
Always rate limit **after** authentication to prevent brute force:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
bearer_auth = Aris::Plugins::BearerAuth.build(
|
|
94
|
+
validator: ->(token) { ApiKey.valid?(token) }
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
Aris.routes({
|
|
98
|
+
"api.example.com": {
|
|
99
|
+
use: [bearer_auth, :rate_limit], # Auth first, then rate limit
|
|
100
|
+
"/data": { get: { to: DataHandler } }
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Why this order?**
|
|
106
|
+
- Invalid auth fails fast (no rate limit check needed)
|
|
107
|
+
- Valid requests get rate limited per authenticated user
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Production Setup
|
|
112
|
+
|
|
113
|
+
### Use Redis Instead of Memory
|
|
114
|
+
|
|
115
|
+
The current implementation uses in-memory storage. **For production, use Redis:**
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
# lib/aris/plugins/rate_limiter_redis.rb
|
|
119
|
+
require 'redis'
|
|
120
|
+
|
|
121
|
+
class RateLimiterRedis
|
|
122
|
+
REDIS = Redis.new(url: ENV['REDIS_URL'])
|
|
123
|
+
|
|
124
|
+
def initialize(limit: 100, window: 60)
|
|
125
|
+
@limit = limit
|
|
126
|
+
@window = window
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def call(request, response)
|
|
130
|
+
key = rate_limit_key(request)
|
|
131
|
+
redis_key = "rate_limit:#{key}"
|
|
132
|
+
|
|
133
|
+
count = REDIS.multi do |r|
|
|
134
|
+
r.incr(redis_key)
|
|
135
|
+
r.expire(redis_key, @window)
|
|
136
|
+
end.first
|
|
137
|
+
|
|
138
|
+
response.headers['X-RateLimit-Limit'] = @limit.to_s
|
|
139
|
+
response.headers['X-RateLimit-Remaining'] = [@limit - count, 0].max.to_s
|
|
140
|
+
|
|
141
|
+
if count > @limit
|
|
142
|
+
response.status = 429
|
|
143
|
+
response.headers['Retry-After'] = @window.to_s
|
|
144
|
+
response.body = ['Rate limit exceeded. Try again later.']
|
|
145
|
+
return response
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def rate_limit_key(request)
|
|
154
|
+
request.headers['HTTP_X_API_KEY'] || request.headers['REMOTE_ADDR']
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Use it
|
|
159
|
+
redis_limiter = RateLimiterRedis.new(limit: 1000, window: 3600)
|
|
160
|
+
|
|
161
|
+
Aris.routes({
|
|
162
|
+
"api.example.com": {
|
|
163
|
+
use: [redis_limiter],
|
|
164
|
+
"/data": { get: { to: DataHandler } }
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Common Patterns
|
|
172
|
+
|
|
173
|
+
### Different Limits for Different Endpoints
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
strict_limit = RateLimiterRedis.new(limit: 10, window: 60)
|
|
177
|
+
normal_limit = RateLimiterRedis.new(limit: 100, window: 60)
|
|
178
|
+
|
|
179
|
+
Aris.routes({
|
|
180
|
+
"api.example.com": {
|
|
181
|
+
use: [normal_limit], # Default for all routes
|
|
182
|
+
|
|
183
|
+
"/expensive": {
|
|
184
|
+
use: [strict_limit], # Override with stricter limit
|
|
185
|
+
post: { to: ExpensiveHandler }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Per-User Rate Limiting
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
user_limiter = RateLimiterRedis.new(
|
|
195
|
+
limit: 1000,
|
|
196
|
+
window: 3600,
|
|
197
|
+
key_extractor: ->(request) {
|
|
198
|
+
# Rate limit by authenticated user ID
|
|
199
|
+
user = request.instance_variable_get(:@current_user)
|
|
200
|
+
"user:#{user.id}"
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Bypass Rate Limiting for Premium Users
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
class SmartRateLimiter
|
|
209
|
+
def call(request, response)
|
|
210
|
+
user = request.instance_variable_get(:@current_user)
|
|
211
|
+
|
|
212
|
+
# Premium users skip rate limiting
|
|
213
|
+
return nil if user&.premium?
|
|
214
|
+
|
|
215
|
+
# Regular rate limiting logic
|
|
216
|
+
# ...
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Per-IP Brute Force Protection
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
login_limiter = RateLimiterRedis.new(
|
|
225
|
+
limit: 5,
|
|
226
|
+
window: 300, # 5 attempts per 5 minutes
|
|
227
|
+
key_extractor: ->(request) {
|
|
228
|
+
request.headers['REMOTE_ADDR']
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
Aris.routes({
|
|
233
|
+
"example.com": {
|
|
234
|
+
"/login": {
|
|
235
|
+
use: [login_limiter], # Rate limit login attempts
|
|
236
|
+
post: { to: LoginHandler }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Testing
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
class RateLimiterTest < Minitest::Test
|
|
248
|
+
def test_requests_under_limit_pass
|
|
249
|
+
Aris.routes({
|
|
250
|
+
"api.example.com": {
|
|
251
|
+
use: [:rate_limit],
|
|
252
|
+
"/data": { get: { to: DataHandler } }
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
app = Aris::Adapters::RackApp.new
|
|
257
|
+
Aris::Plugins::RateLimiter.reset! # Clear state
|
|
258
|
+
|
|
259
|
+
env = build_env('/data', api_key: 'test-key')
|
|
260
|
+
|
|
261
|
+
# First 100 requests succeed
|
|
262
|
+
100.times do
|
|
263
|
+
status, _, _ = app.call(env)
|
|
264
|
+
assert_equal 200, status
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# 101st fails
|
|
268
|
+
status, headers, _ = app.call(env)
|
|
269
|
+
assert_equal 429, status
|
|
270
|
+
assert_equal '60', headers['Retry-After']
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Production Tips
|
|
278
|
+
|
|
279
|
+
### 1. Monitor Rate Limit Hits
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
class MonitoredRateLimiter < RateLimiterRedis
|
|
283
|
+
def call(request, response)
|
|
284
|
+
result = super
|
|
285
|
+
|
|
286
|
+
if result == response && response.status == 429
|
|
287
|
+
# Log rate limit hit
|
|
288
|
+
key = rate_limit_key(request)
|
|
289
|
+
Rails.logger.warn("Rate limit exceeded: #{key}")
|
|
290
|
+
Metrics.increment('api.rate_limit.exceeded', tags: ["key:#{key}"])
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
result
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### 2. Graceful Degradation
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
def call(request, response)
|
|
302
|
+
begin
|
|
303
|
+
# Rate limiting logic
|
|
304
|
+
rescue Redis::ConnectionError => e
|
|
305
|
+
# If Redis is down, allow requests through
|
|
306
|
+
Rails.logger.error("Rate limiter Redis error: #{e.message}")
|
|
307
|
+
return nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### 3. Different Limits by Tier
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
LIMITS = {
|
|
316
|
+
'free' => { limit: 100, window: 3600 },
|
|
317
|
+
'pro' => { limit: 1000, window: 3600 },
|
|
318
|
+
'enterprise' => { limit: 10000, window: 3600 }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
class TieredRateLimiter
|
|
322
|
+
def call(request, response)
|
|
323
|
+
user = request.instance_variable_get(:@current_user)
|
|
324
|
+
config = LIMITS[user.tier]
|
|
325
|
+
|
|
326
|
+
# Apply tier-specific limit
|
|
327
|
+
# ...
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Security Notes
|
|
335
|
+
|
|
336
|
+
**✅ Use for:**
|
|
337
|
+
- API endpoints
|
|
338
|
+
- Login/authentication endpoints
|
|
339
|
+
- Password reset endpoints
|
|
340
|
+
- Resource-intensive operations
|
|
341
|
+
- Public endpoints
|
|
342
|
+
|
|
343
|
+
**⚠️ Considerations:**
|
|
344
|
+
- Rate limit by user/token (not just IP) to prevent shared IP issues
|
|
345
|
+
- Use Redis for distributed systems (in-memory won't work across servers)
|
|
346
|
+
- Set appropriate limits (too strict = bad UX, too loose = ineffective)
|
|
347
|
+
- Monitor for legitimate users hitting limits
|
|
348
|
+
|
|
349
|
+
**❌ Don't rely solely on:**
|
|
350
|
+
- Rate limiting alone for security (defense in depth)
|
|
351
|
+
- IP-based limiting in cloud environments (shared IPs)
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Current Limitations
|
|
356
|
+
|
|
357
|
+
The built-in rate limiter:
|
|
358
|
+
- ✅ Works for single-server deployments
|
|
359
|
+
- ✅ Thread-safe with mutex
|
|
360
|
+
- ❌ Uses in-memory storage (won't persist across restarts)
|
|
361
|
+
- ❌ Won't work across multiple servers
|
|
362
|
+
- ❌ Fixed at 100 requests per 60 seconds
|
|
363
|
+
|
|
364
|
+
**For production:** Implement Redis-backed rate limiting as shown above.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
Need help? Check out the [full plugin development guide](../docs/plugin-development.md).
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# Request ID Plugin
|
|
2
|
+
|
|
3
|
+
Generates unique request IDs for distributed tracing and log correlation. Essential for debugging in production environments.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require 'aris/plugins/request_id'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Basic Usage
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
request_id = Aris::Plugins::RequestId.build
|
|
15
|
+
|
|
16
|
+
Aris.routes({
|
|
17
|
+
"api.example.com": {
|
|
18
|
+
use: [request_id], # Apply to all routes
|
|
19
|
+
"/users": { get: { to: UsersHandler } }
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
| Option | Type | Default | Description |
|
|
27
|
+
|--------|------|---------|-------------|
|
|
28
|
+
| `header_name` | String | `'X-Request-ID'` | HTTP header name for request ID |
|
|
29
|
+
| `generator` | Proc | `-> { SecureRandom.uuid }` | Custom ID generator function |
|
|
30
|
+
|
|
31
|
+
## How It Works
|
|
32
|
+
|
|
33
|
+
1. **Check for existing ID**: If request has `X-Request-ID` header (from proxy/load balancer), use it
|
|
34
|
+
2. **Generate new ID**: If none provided, generate UUID
|
|
35
|
+
3. **Store on request**: Handlers can access via `@request_id` instance variable
|
|
36
|
+
4. **Return in response**: Set `X-Request-ID` header in response
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
### Default Configuration
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
request_id = Aris::Plugins::RequestId.build
|
|
44
|
+
|
|
45
|
+
# Request without ID:
|
|
46
|
+
# Response: X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
|
|
47
|
+
|
|
48
|
+
# Request with ID:
|
|
49
|
+
# Request: X-Request-ID: existing-id-123
|
|
50
|
+
# Response: X-Request-ID: existing-id-123 (preserved)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Custom Header Name
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
request_id = Aris::Plugins::RequestId.build(
|
|
57
|
+
header_name: 'X-Trace-ID'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Response will have X-Trace-ID instead of X-Request-ID
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Custom Generator
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# Sequential IDs
|
|
67
|
+
counter = 0
|
|
68
|
+
request_id = Aris::Plugins::RequestId.build(
|
|
69
|
+
generator: -> { "REQ-#{counter += 1}" }
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Timestamp-based IDs
|
|
73
|
+
request_id = Aris::Plugins::RequestId.build(
|
|
74
|
+
generator: -> { "#{Time.now.to_i}-#{SecureRandom.hex(4)}" }
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Short IDs
|
|
78
|
+
request_id = Aris::Plugins::RequestId.build(
|
|
79
|
+
generator: -> { SecureRandom.hex(8) } # 16 characters
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Accessing Request ID in Handlers
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class UsersHandler
|
|
87
|
+
def self.call(request, params)
|
|
88
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
89
|
+
|
|
90
|
+
# Use in logging
|
|
91
|
+
logger.info("Processing user request", request_id: request_id)
|
|
92
|
+
|
|
93
|
+
# Return in response
|
|
94
|
+
{
|
|
95
|
+
users: User.all,
|
|
96
|
+
request_id: request_id
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### With Logging Plugin
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
request_id = Aris::Plugins::RequestId.build
|
|
106
|
+
logger = Aris::Plugins::RequestLogger.build(format: :json)
|
|
107
|
+
|
|
108
|
+
Aris.routes({
|
|
109
|
+
"api.example.com": {
|
|
110
|
+
use: [request_id, logger], # Request ID first
|
|
111
|
+
"/users": { get: { to: UsersHandler } }
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# Logs will include request_id for correlation
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Production Tips
|
|
119
|
+
|
|
120
|
+
### 1. Plugin Order (Critical)
|
|
121
|
+
|
|
122
|
+
Place **first** in plugin chain:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Aris.routes({
|
|
126
|
+
"api.example.com": {
|
|
127
|
+
use: [
|
|
128
|
+
request_id, # ← FIRST - generate ID
|
|
129
|
+
bearer_auth, # Use request_id in auth logs
|
|
130
|
+
json_parser, # Use request_id in parser logs
|
|
131
|
+
request_logger # Log request_id
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 2. Structured Logging
|
|
138
|
+
|
|
139
|
+
Combine with logging plugin for correlation:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
class CustomLogger
|
|
143
|
+
def self.call(request, response)
|
|
144
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
145
|
+
|
|
146
|
+
logger.info({
|
|
147
|
+
request_id: request_id,
|
|
148
|
+
method: request.method,
|
|
149
|
+
path: request.path,
|
|
150
|
+
timestamp: Time.now.iso8601
|
|
151
|
+
}.to_json)
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 3. Load Balancer Integration
|
|
159
|
+
|
|
160
|
+
Preserve IDs from upstream:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# AWS ALB sends X-Amzn-Trace-Id
|
|
164
|
+
request_id = Aris::Plugins::RequestId.build(
|
|
165
|
+
header_name: 'X-Amzn-Trace-Id'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Or check multiple headers
|
|
169
|
+
class SmartRequestId
|
|
170
|
+
def self.call(request, response)
|
|
171
|
+
request_id = request.headers['HTTP_X_AMZN_TRACE_ID'] ||
|
|
172
|
+
request.headers['HTTP_X_REQUEST_ID'] ||
|
|
173
|
+
SecureRandom.uuid
|
|
174
|
+
|
|
175
|
+
request.instance_variable_set(:@request_id, request_id)
|
|
176
|
+
response.headers['X-Request-ID'] = request_id
|
|
177
|
+
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### 4. Error Tracking
|
|
184
|
+
|
|
185
|
+
Include request ID in error responses:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class ErrorHandler
|
|
189
|
+
def self.call(request, exception)
|
|
190
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
191
|
+
|
|
192
|
+
[500, { 'content-type' => 'application/json' }, [
|
|
193
|
+
{
|
|
194
|
+
error: 'Internal Server Error',
|
|
195
|
+
request_id: request_id,
|
|
196
|
+
message: exception.message
|
|
197
|
+
}.to_json
|
|
198
|
+
]]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Aris.default(error: ErrorHandler)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 5. Distributed Tracing
|
|
206
|
+
|
|
207
|
+
Propagate to downstream services:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class ServiceClient
|
|
211
|
+
def self.call_api(request, endpoint)
|
|
212
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
213
|
+
|
|
214
|
+
# Pass to downstream service
|
|
215
|
+
HTTParty.get(
|
|
216
|
+
"https://api.example.com#{endpoint}",
|
|
217
|
+
headers: { 'X-Request-ID' => request_id }
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### 6. Database Query Tagging
|
|
224
|
+
|
|
225
|
+
Tag queries with request ID:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class UsersHandler
|
|
229
|
+
def self.call(request, params)
|
|
230
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
231
|
+
|
|
232
|
+
# Tag ActiveRecord queries
|
|
233
|
+
ActiveRecord::Base.connection.execute(
|
|
234
|
+
"SET application_name = 'request_#{request_id}'"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
User.all
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 7. APM Integration
|
|
243
|
+
|
|
244
|
+
Send to monitoring services:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
class APMHandler
|
|
248
|
+
def self.call(request, params)
|
|
249
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
250
|
+
|
|
251
|
+
# NewRelic
|
|
252
|
+
NewRelic::Agent.add_custom_attributes(request_id: request_id)
|
|
253
|
+
|
|
254
|
+
# Datadog
|
|
255
|
+
Datadog::Tracing.active_span&.set_tag('request.id', request_id)
|
|
256
|
+
|
|
257
|
+
# Your logic here
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Common Patterns
|
|
263
|
+
|
|
264
|
+
### Multi-Service Architecture
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
# Service A (Frontend API)
|
|
268
|
+
request_id = Aris::Plugins::RequestId.build
|
|
269
|
+
|
|
270
|
+
# Service B (Backend API)
|
|
271
|
+
# Receives X-Request-ID from Service A
|
|
272
|
+
request_id = Aris::Plugins::RequestId.build # Preserves existing ID
|
|
273
|
+
|
|
274
|
+
# All logs across services have same request_id for correlation
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Request ID in Sidekiq Jobs
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
class ProcessOrderJob
|
|
281
|
+
def perform(order_id, request_id)
|
|
282
|
+
logger.tagged(request_id) do
|
|
283
|
+
logger.info "Processing order #{order_id}"
|
|
284
|
+
# Process order
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
class OrdersHandler
|
|
290
|
+
def self.call(request, params)
|
|
291
|
+
request_id = request.instance_variable_get(:@request_id)
|
|
292
|
+
|
|
293
|
+
# Enqueue with request_id
|
|
294
|
+
ProcessOrderJob.perform_async(params[:id], request_id)
|
|
295
|
+
|
|
296
|
+
{ status: 'processing', request_id: request_id }
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Client Response Headers
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# Clients can use request_id for support tickets
|
|
305
|
+
# Response: X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
|
|
306
|
+
|
|
307
|
+
# User: "Error with request ID: 550e8400-..."
|
|
308
|
+
# Support: grep logs for 550e8400-... → full request trace
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## ID Format Options
|
|
312
|
+
|
|
313
|
+
**UUID (default):**
|
|
314
|
+
```ruby
|
|
315
|
+
# 550e8400-e29b-41d4-a716-446655440000
|
|
316
|
+
generator: -> { SecureRandom.uuid }
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Short hex:**
|
|
320
|
+
```ruby
|
|
321
|
+
# a3f2bc9e
|
|
322
|
+
generator: -> { SecureRandom.hex(4) }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Timestamp + random:**
|
|
326
|
+
```ruby
|
|
327
|
+
# 1634567890-a3f2bc9e
|
|
328
|
+
generator: -> { "#{Time.now.to_i}-#{SecureRandom.hex(4)}" }
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Sequential (testing only):**
|
|
332
|
+
```ruby
|
|
333
|
+
# REQ-1, REQ-2, REQ-3...
|
|
334
|
+
counter = 0
|
|
335
|
+
generator: -> { "REQ-#{counter += 1}" }
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Benchmarks
|
|
339
|
+
|
|
340
|
+
**Performance impact:**
|
|
341
|
+
- UUID generation: ~0.01ms
|
|
342
|
+
- Header setting: ~0.001ms
|
|
343
|
+
- Total overhead: <0.02ms per request
|
|
344
|
+
|
|
345
|
+
Negligible impact, essential value.
|
|
346
|
+
|
|
347
|
+
## Notes
|
|
348
|
+
|
|
349
|
+
- Thread-safe (UUID generation is thread-safe)
|
|
350
|
+
- Preserves IDs from load balancers/proxies
|
|
351
|
+
- Available to all downstream handlers
|
|
352
|
+
- Always returned in response headers
|
|
353
|
+
- Compatible with AWS ALB, Nginx, HAProxy trace IDs
|
|
354
|
+
|
|
355
|
+
## Troubleshooting
|
|
356
|
+
|
|
357
|
+
**Request ID not showing up?**
|
|
358
|
+
- Check plugin is in `use:` array
|
|
359
|
+
- Verify plugin runs before logging/handlers
|
|
360
|
+
- Confirm response headers are visible
|
|
361
|
+
|
|
362
|
+
**Different IDs in logs vs response?**
|
|
363
|
+
- Ensure request_id plugin runs first
|
|
364
|
+
- Check no other plugin overwrites header
|
|
365
|
+
|
|
366
|
+
**Load balancer ID not preserved?**
|
|
367
|
+
- Check header name matches (`X-Request-ID` vs `X-Amzn-Trace-Id`)
|
|
368
|
+
- Verify load balancer is forwarding header
|
|
369
|
+
```
|