yabeda-rack-queue 0.1.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 +7 -0
- data/.gitignore +3 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +120 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +15 -0
- data/SPEC.md +156 -0
- data/lib/yabeda/rack/queue/header_timestamp_parser.rb +32 -0
- data/lib/yabeda/rack/queue/metric.rb +12 -0
- data/lib/yabeda/rack/queue/middleware.rb +53 -0
- data/lib/yabeda/rack/queue/version.rb +9 -0
- data/lib/yabeda/rack/queue.rb +6 -0
- data/lib/yabeda-rack-queue.rb +3 -0
- data/test/e2e/puma_integration_test.rb +75 -0
- data/test/test_helper.rb +16 -0
- data/test/yabeda/rack/queue/gemspec_test.rb +13 -0
- data/test/yabeda/rack/queue/header_timestamp_parser_test.rb +61 -0
- data/test/yabeda/rack/queue/metric_test.rb +16 -0
- data/test/yabeda/rack/queue/middleware_performance_test.rb +23 -0
- data/test/yabeda/rack/queue/middleware_test.rb +199 -0
- data/yabeda-rack-queue.gemspec +43 -0
- metadata +196 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 21cab04c6429f2f935c1f25dd635a57bef7b6f619775a06ee15c678cad907bc8
|
|
4
|
+
data.tar.gz: 2b0aedeb8ab33fe5c2380942c71221b64a743c2eb7999ba2ecd153ca4703820f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 177604e2d5e1fc330c66a074dcae18c33ed42a49afca78755e91f75675b67b04451e1d27813ec3040375f2040e221a1aa20e8348b2b17e3e9e8d70cc71b12acc
|
|
7
|
+
data.tar.gz: 6a90ef452a54f42dd2e916ca6ef528b24cbe97993bc2dc6f0d19f767aeb1fc54bf942c37a82017d559656c57035c85ca6929107d91898f7759a3a67d6e6b9650
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
yabeda-rack-queue (0.1.0)
|
|
5
|
+
yabeda (>= 0.14, < 1.0)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
anyway_config (2.8.0)
|
|
11
|
+
ruby-next-core (~> 1.0)
|
|
12
|
+
ast (2.4.3)
|
|
13
|
+
benchmark (0.5.0)
|
|
14
|
+
benchmark-ips (2.14.0)
|
|
15
|
+
concurrent-ruby (1.3.6)
|
|
16
|
+
dry-initializer (3.2.0)
|
|
17
|
+
json (2.18.1)
|
|
18
|
+
language_server-protocol (3.17.0.5)
|
|
19
|
+
lint_roller (1.1.0)
|
|
20
|
+
minitest (5.27.0)
|
|
21
|
+
nio4r (2.7.5)
|
|
22
|
+
parallel (1.27.0)
|
|
23
|
+
parser (3.3.10.2)
|
|
24
|
+
ast (~> 2.4.1)
|
|
25
|
+
racc
|
|
26
|
+
prism (1.9.0)
|
|
27
|
+
puma (7.2.0)
|
|
28
|
+
nio4r (~> 2.0)
|
|
29
|
+
racc (1.8.1)
|
|
30
|
+
rainbow (3.1.1)
|
|
31
|
+
rake (13.3.1)
|
|
32
|
+
regexp_parser (2.11.3)
|
|
33
|
+
rubocop (1.84.2)
|
|
34
|
+
json (~> 2.3)
|
|
35
|
+
language_server-protocol (~> 3.17.0.2)
|
|
36
|
+
lint_roller (~> 1.1.0)
|
|
37
|
+
parallel (~> 1.10)
|
|
38
|
+
parser (>= 3.3.0.2)
|
|
39
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
40
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
41
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
42
|
+
ruby-progressbar (~> 1.7)
|
|
43
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
44
|
+
rubocop-ast (1.49.0)
|
|
45
|
+
parser (>= 3.3.7.2)
|
|
46
|
+
prism (~> 1.7)
|
|
47
|
+
rubocop-performance (1.26.1)
|
|
48
|
+
lint_roller (~> 1.1)
|
|
49
|
+
rubocop (>= 1.75.0, < 2.0)
|
|
50
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
51
|
+
ruby-next-core (1.2.0)
|
|
52
|
+
ruby-progressbar (1.13.0)
|
|
53
|
+
standard (1.54.0)
|
|
54
|
+
language_server-protocol (~> 3.17.0.2)
|
|
55
|
+
lint_roller (~> 1.0)
|
|
56
|
+
rubocop (~> 1.84.0)
|
|
57
|
+
standard-custom (~> 1.0.0)
|
|
58
|
+
standard-performance (~> 1.8)
|
|
59
|
+
standard-custom (1.0.2)
|
|
60
|
+
lint_roller (~> 1.0)
|
|
61
|
+
rubocop (~> 1.50)
|
|
62
|
+
standard-performance (1.9.0)
|
|
63
|
+
lint_roller (~> 1.1)
|
|
64
|
+
rubocop-performance (~> 1.26.0)
|
|
65
|
+
unicode-display_width (3.2.0)
|
|
66
|
+
unicode-emoji (~> 4.1)
|
|
67
|
+
unicode-emoji (4.2.0)
|
|
68
|
+
yabeda (0.14.0)
|
|
69
|
+
anyway_config (>= 1.0, < 3)
|
|
70
|
+
concurrent-ruby
|
|
71
|
+
dry-initializer
|
|
72
|
+
|
|
73
|
+
PLATFORMS
|
|
74
|
+
arm64-darwin-24
|
|
75
|
+
ruby
|
|
76
|
+
|
|
77
|
+
DEPENDENCIES
|
|
78
|
+
benchmark (>= 0.4, < 1.0)
|
|
79
|
+
benchmark-ips (>= 2.14, < 3.0)
|
|
80
|
+
minitest (>= 5.22, < 6.0)
|
|
81
|
+
puma (>= 6, < 8)
|
|
82
|
+
rake (>= 13.0)
|
|
83
|
+
standard (~> 1.44)
|
|
84
|
+
yabeda-rack-queue!
|
|
85
|
+
|
|
86
|
+
CHECKSUMS
|
|
87
|
+
anyway_config (2.8.0) sha256=f6797a7231f81202dcd3d0c07284e836e45713e761d320180348b13a5c7c9306
|
|
88
|
+
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
89
|
+
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
|
90
|
+
benchmark-ips (2.14.0) sha256=b72bc8a65d525d5906f8cd94270dccf73452ee3257a32b89fbd6684d3e8a9b1d
|
|
91
|
+
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
|
|
92
|
+
dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3
|
|
93
|
+
json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
|
|
94
|
+
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
95
|
+
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
96
|
+
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
|
|
97
|
+
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
|
98
|
+
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
|
|
99
|
+
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
|
|
100
|
+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
101
|
+
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
|
|
102
|
+
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
103
|
+
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
104
|
+
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
|
105
|
+
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
|
|
106
|
+
rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
|
|
107
|
+
rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
|
|
108
|
+
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
|
109
|
+
ruby-next-core (1.2.0) sha256=f6a7d00bb5186cecbb02f7f1845a0f3a2c9788d35b6ccff5c9be3f0d46799b86
|
|
110
|
+
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
111
|
+
standard (1.54.0) sha256=7a4b08f83d9893083c8f03bc486f0feeb6a84d48233b40829c03ef4767ea0100
|
|
112
|
+
standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b
|
|
113
|
+
standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2
|
|
114
|
+
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
115
|
+
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
116
|
+
yabeda (0.14.0) sha256=bc517bf22d692ebd80a29fc9fd2246c257aaf92d10b2735a775e2419351a43bf
|
|
117
|
+
yabeda-rack-queue (0.1.0)
|
|
118
|
+
|
|
119
|
+
BUNDLED WITH
|
|
120
|
+
4.0.3
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nate Berkopec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# yabeda-rack-queue
|
|
2
|
+
|
|
3
|
+
Rack middleware that measures HTTP request queue time. It reports the result to [Yabeda](https://github.com/yabeda-rb/yabeda) as a histogram.
|
|
4
|
+
|
|
5
|
+
## What is queue time?
|
|
6
|
+
|
|
7
|
+
A request may wait before your app handles it. A proxy or load balancer (like Nginx or Heroku) causes this wait. This is called queue time.
|
|
8
|
+
|
|
9
|
+
High queue time means your app is too busy. It cannot take new requests. That is a sign you need more capacity.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
Load balancers can add a header to each request. The header records when the request arrived. Common headers are `X-Request-Start` and `X-Queue-Start`.
|
|
14
|
+
|
|
15
|
+
This middleware reads that header. It subtracts the header's timestamp from the current time. Then it reports that value as `rack_queue.rack_queue_duration`.
|
|
16
|
+
|
|
17
|
+
> [!NOTE]
|
|
18
|
+
> If neither header is present, no measurement is taken. The request passes through unchanged.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add to your Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "yabeda-rack-queue"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Add the middleware to your Rack stack. You also need a Yabeda adapter. For example, use [yabeda-prometheus](https://github.com/yabeda-rb/yabeda-prometheus).
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "yabeda/rack/queue"
|
|
40
|
+
require "yabeda/prometheus"
|
|
41
|
+
|
|
42
|
+
Yabeda.configure!
|
|
43
|
+
|
|
44
|
+
use Yabeda::Rack::Queue::Middleware
|
|
45
|
+
run MyRackApp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For Rails, add it in `config/application.rb`:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
config.middleware.use Yabeda::Rack::Queue::Middleware
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Metric
|
|
55
|
+
|
|
56
|
+
| Name | Group | Type | Unit |
|
|
57
|
+
|------|-------|------|------|
|
|
58
|
+
| `rack_queue_duration` | `rack_queue` | histogram | seconds |
|
|
59
|
+
|
|
60
|
+
Access it in code:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Yabeda.rack_queue.rack_queue_duration
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Histogram buckets: 1 ms, 5 ms, 10 ms, 25 ms, 50 ms, 100 ms, 250 ms, 500 ms, 1 s, 2.5 s, 5 s, 10 s, 30 s, 60 s.
|
|
67
|
+
|
|
68
|
+
## Header formats
|
|
69
|
+
|
|
70
|
+
The middleware checks `X-Request-Start` first. If that header is absent, it tries `X-Queue-Start`.
|
|
71
|
+
|
|
72
|
+
Supported timestamp formats:
|
|
73
|
+
|
|
74
|
+
| Format | Example |
|
|
75
|
+
|--------|---------|
|
|
76
|
+
| Seconds (float) | `1609459200.123` |
|
|
77
|
+
| Milliseconds | `1609459200123` |
|
|
78
|
+
| Microseconds | `1609459200123456` |
|
|
79
|
+
| `t=` prefix | `t=1609459200.123` |
|
|
80
|
+
|
|
81
|
+
The middleware auto-detects the unit. It checks if the number fits a valid recent time.
|
|
82
|
+
|
|
83
|
+
## Puma adjustment
|
|
84
|
+
|
|
85
|
+
Puma sets `puma.request_body_wait` (in milliseconds) in the Rack env. This records how long Puma spent reading the request body.
|
|
86
|
+
|
|
87
|
+
The middleware subtracts this value from queue time. Without this step, large bodies make queue time appear too long.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
The middleware accepts these keyword arguments:
|
|
92
|
+
|
|
93
|
+
| Argument | Default | Purpose |
|
|
94
|
+
|----------|---------|---------|
|
|
95
|
+
| `reporter:` | `YabedaReporter.new` | Writes the value to Yabeda. |
|
|
96
|
+
| `parser:` | `HeaderTimestampParser.new` | Parses the header timestamp. |
|
|
97
|
+
| `logger:` | stderr | Gets warning messages. |
|
|
98
|
+
| `clock:` | `Process.clock_gettime(CLOCK_REALTIME)` | Returns current time in seconds. |
|
|
99
|
+
|
|
100
|
+
Example with a custom logger:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
use Yabeda::Rack::Queue::Middleware, logger: Rails.logger
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
- Ruby >= 3.1.
|
|
109
|
+
- yabeda >= 0.14, < 1.0.
|
|
110
|
+
- A Yabeda adapter. For example: [yabeda-prometheus](https://github.com/yabeda-rb/yabeda-prometheus).
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
Run tests:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bundle exec rake test
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Run the linter:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bundle exec standardrb
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Contributing
|
|
127
|
+
|
|
128
|
+
Bug reports and pull requests are welcome at <https://github.com/speedshop/yabeda-rack-queue>.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
task :lint do
|
|
7
|
+
sh "bundle exec standardrb"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
Rake::TestTask.new(:test) do |test|
|
|
11
|
+
test.libs << "test"
|
|
12
|
+
test.pattern = "test/**/*_test.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
task default: %i[lint test]
|
data/SPEC.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# yabeda-rack-queue Specification
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
This gem measures HTTP request queue time — the duration between when a reverse
|
|
6
|
+
proxy or load balancer first receives a request and when the Ruby application
|
|
7
|
+
begins processing it. It reports this as a Yabeda histogram metric.
|
|
8
|
+
|
|
9
|
+
The gem follows the Rack SPEC and uses Rack env/request-response conventions,
|
|
10
|
+
but it should not declare `rack` as a gem dependency.
|
|
11
|
+
|
|
12
|
+
## Testing Framework
|
|
13
|
+
|
|
14
|
+
The test suite uses Minitest.
|
|
15
|
+
|
|
16
|
+
## Linting
|
|
17
|
+
|
|
18
|
+
Linting uses standardrb.
|
|
19
|
+
|
|
20
|
+
## Metric
|
|
21
|
+
|
|
22
|
+
| Name | Type | Group | Unit | Description |
|
|
23
|
+
|------|------|-------|------|-------------|
|
|
24
|
+
| `rack_queue_duration` | histogram | `rack_queue` | seconds | Time a request waited in the upstream queue before reaching the application |
|
|
25
|
+
|
|
26
|
+
### Histogram Buckets
|
|
27
|
+
|
|
28
|
+
`[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]`
|
|
29
|
+
|
|
30
|
+
No tags are recorded by default.
|
|
31
|
+
|
|
32
|
+
## Headers
|
|
33
|
+
|
|
34
|
+
The middleware inspects these Rack environment keys, in order:
|
|
35
|
+
|
|
36
|
+
1. `HTTP_X_REQUEST_START`
|
|
37
|
+
2. `HTTP_X_QUEUE_START`
|
|
38
|
+
|
|
39
|
+
The first header that yields a valid timestamp is used. If neither header is
|
|
40
|
+
present or parseable, no metric is recorded and the request passes through
|
|
41
|
+
unaffected.
|
|
42
|
+
|
|
43
|
+
## Middleware Behavior
|
|
44
|
+
|
|
45
|
+
- The middleware always calls the downstream Rack app, regardless of header state.
|
|
46
|
+
- Queue time is computed **before** calling the downstream app (measures time
|
|
47
|
+
*before* application processing, not including it).
|
|
48
|
+
- The middleware does not modify the Rack env or the response in any way.
|
|
49
|
+
- The middleware never raises exceptions due to invalid input.
|
|
50
|
+
- Current time is measured via `Process.clock_gettime(Process::CLOCK_REALTIME)`
|
|
51
|
+
(wall clock, not monotonic — necessary because the header timestamp comes from
|
|
52
|
+
a different process).
|
|
53
|
+
|
|
54
|
+
## Performance Requirement
|
|
55
|
+
|
|
56
|
+
For a no-op Rack app (for example, one that returns `hello world`), middleware
|
|
57
|
+
throughput MUST exceed `1_000_000` calls/second.
|
|
58
|
+
|
|
59
|
+
This requirement is enforced with a `benchmark-ips` benchmark test.
|
|
60
|
+
|
|
61
|
+
## Header Value Parsing
|
|
62
|
+
|
|
63
|
+
Header values are parsed for compatibility with common reverse proxies and APM
|
|
64
|
+
agents.
|
|
65
|
+
|
|
66
|
+
1. First, extract a numeric timestamp token from the header value:
|
|
67
|
+
- `t=<number>` (preferred)
|
|
68
|
+
- or plain `<number>`
|
|
69
|
+
2. Then normalize units by trying divisors in order:
|
|
70
|
+
- divide by `1_000_000` (microseconds)
|
|
71
|
+
- else divide by `1_000` (milliseconds)
|
|
72
|
+
- else divide by `1` (seconds)
|
|
73
|
+
3. The first normalized value that is after the minimum acceptable epoch
|
|
74
|
+
(`2000-01-01`) is used.
|
|
75
|
+
|
|
76
|
+
Notes:
|
|
77
|
+
|
|
78
|
+
- This supports seconds, milliseconds, and microseconds (integer or decimal).
|
|
79
|
+
- Leading/trailing whitespace is ignored.
|
|
80
|
+
- If a header contains comma-separated values, use the first value.
|
|
81
|
+
|
|
82
|
+
### Known Format Examples
|
|
83
|
+
|
|
84
|
+
| Source | Header | Format | Example Value |
|
|
85
|
+
|--------|--------|--------|---------------|
|
|
86
|
+
| Heroku | `X-Request-Start` | milliseconds | `1512379167574` |
|
|
87
|
+
| Nginx | `X-Request-Start` | `t=` seconds.ms | `t=1512379167.574` |
|
|
88
|
+
| Apache | `X-Request-Start` | `t=` microseconds | `t=1570633834463123` |
|
|
89
|
+
| HAProxy (<1.9) | `X-Request-Start` | `t=` integer seconds | `t=1512379167` |
|
|
90
|
+
| F5 | `X-Request-Start` | `t=` milliseconds | `t=1512379167574` |
|
|
91
|
+
| Contour/Envoy | `X-Request-Start` | `t=` seconds.ms | `t=1512379167.574` |
|
|
92
|
+
|
|
93
|
+
## Validation
|
|
94
|
+
|
|
95
|
+
A parsed timestamp is rejected (treated as absent) if:
|
|
96
|
+
|
|
97
|
+
- The header value contains no extractable numeric portion
|
|
98
|
+
- The resulting timestamp is before 2000-01-01 00:00:00 UTC (epoch 946684800)
|
|
99
|
+
- The resulting timestamp is more than 30 seconds in the future
|
|
100
|
+
|
|
101
|
+
If a timestamp is invalid, we do not record anything. We should not raise any exceptions, it should just be a no-op.
|
|
102
|
+
|
|
103
|
+
### Negative Queue Time (Clock Skew)
|
|
104
|
+
|
|
105
|
+
If the computed queue time (`now - request_start`) is negative — which can happen
|
|
106
|
+
when the application server's clock is slightly ahead of the load balancer's
|
|
107
|
+
clock — the observation is dropped (no metric is recorded).
|
|
108
|
+
|
|
109
|
+
We'll log a WARN level message in this case.
|
|
110
|
+
|
|
111
|
+
## Puma Request Body Wait Adjustment
|
|
112
|
+
|
|
113
|
+
If `env["puma.request_body_wait"]` exists, subtract it from the computed queue
|
|
114
|
+
time to avoid counting time spent waiting for slow clients to upload request
|
|
115
|
+
bodies.
|
|
116
|
+
|
|
117
|
+
- `puma.request_body_wait` is interpreted as milliseconds and converted to seconds before subtraction
|
|
118
|
+
- Numeric strings are coerced to float before subtraction
|
|
119
|
+
- Non-numeric or negative values are treated as absent (no subtraction)
|
|
120
|
+
- If subtraction would make queue time negative, clamp to `0.0` seconds
|
|
121
|
+
|
|
122
|
+
## Parsing Truth Table
|
|
123
|
+
|
|
124
|
+
Assumptions used below:
|
|
125
|
+
|
|
126
|
+
- Minimum acceptable epoch: `946684800` (2000-01-01 UTC)
|
|
127
|
+
- `now = 1700000000` (example current time)
|
|
128
|
+
- Future cutoff = `now + 30` seconds
|
|
129
|
+
|
|
130
|
+
| Header value | Extracted token | Chosen normalization | Parsed timestamp (s) | Result |
|
|
131
|
+
|---|---:|---|---:|---|
|
|
132
|
+
| `t=1512379167.574` | `1512379167.574` | `/1` (seconds) | `1512379167.574` | accepted |
|
|
133
|
+
| `1512379167.574` | `1512379167.574` | `/1` (seconds) | `1512379167.574` | accepted |
|
|
134
|
+
| `t=1512379167574` | `1512379167574` | `/1000` (milliseconds) | `1512379167.574` | accepted |
|
|
135
|
+
| `1512379167574` | `1512379167574` | `/1000` (milliseconds) | `1512379167.574` | accepted |
|
|
136
|
+
| `t=1570633834463123` | `1570633834463123` | `/1000000` (microseconds) | `1570633834.463123` | accepted |
|
|
137
|
+
| `1570633834463123` | `1570633834463123` | `/1000000` (microseconds) | `1570633834.463123` | accepted |
|
|
138
|
+
| `t=1512379167` | `1512379167` | `/1` (seconds) | `1512379167` | accepted |
|
|
139
|
+
| `1512379167` | `1512379167` | `/1` (seconds) | `1512379167` | accepted |
|
|
140
|
+
| ` t=1512379167.574 ` | `1512379167.574` | `/1` (seconds) | `1512379167.574` | accepted (whitespace ignored) |
|
|
141
|
+
| `t=1512379167.574, t=1512379168.000` | `1512379167.574` (first value) | `/1` (seconds) | `1512379167.574` | accepted |
|
|
142
|
+
| `invalid` | _none_ | — | — | rejected (no numeric token) |
|
|
143
|
+
| `t=` | _none_ | — | — | rejected (empty token) |
|
|
144
|
+
| `t=0` | `0` | none pass minimum epoch | — | rejected (too old) |
|
|
145
|
+
| `t=915148800` | `915148800` | none pass minimum epoch | — | rejected (too old) |
|
|
146
|
+
| `t=1700000035` | `1700000035` | `/1` (seconds) | `1700000035` | rejected (more than 30s in future for assumed `now`) |
|
|
147
|
+
|
|
148
|
+
### Queue time post-processing examples
|
|
149
|
+
|
|
150
|
+
| Parsed `request_start` | `now` | Raw queue time (s) | `puma.request_body_wait` | Final outcome |
|
|
151
|
+
|---:|---:|---:|---:|---|
|
|
152
|
+
| `1699999999.900` | `1700000000.000` | `0.100` | _absent_ | `0.100` |
|
|
153
|
+
| `1699999999.900` | `1700000000.000` | `0.100` | `40` (ms) | `0.060` |
|
|
154
|
+
| `1699999999.900` | `1700000000.000` | `0.100` | `"40"` (ms) | `0.060` |
|
|
155
|
+
| `1700000000.050` | `1700000000.000` | `-0.050` | _absent_ | dropped (clock skew, WARN logged) |
|
|
156
|
+
| `1699999999.900` | `1700000000.000` | `0.100` | `200` (ms) | `0.000` (post-subtraction clamp) |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yabeda
|
|
4
|
+
module Rack
|
|
5
|
+
module Queue
|
|
6
|
+
class HeaderTimestampParser
|
|
7
|
+
MIN_EPOCH = Time.utc(2000, 1, 1).to_f
|
|
8
|
+
FUTURE_TOLERANCE = 30.0
|
|
9
|
+
DIVISORS = [1_000_000.0, 1_000.0, 1.0].freeze
|
|
10
|
+
NUMBER_RE = /[+-]?(?:\d+(?:\.\d+)?|\.\d+)/
|
|
11
|
+
T_EQUALS_RE = /t\s*=\s*(#{NUMBER_RE.source})/i
|
|
12
|
+
|
|
13
|
+
def parse(value, now:)
|
|
14
|
+
first = value.to_s.split(",", 2).first.to_s.strip
|
|
15
|
+
return if first.empty?
|
|
16
|
+
|
|
17
|
+
token = first[T_EQUALS_RE, 1] || first[NUMBER_RE, 0]
|
|
18
|
+
normalize(Float(token), now) if token
|
|
19
|
+
rescue ArgumentError, TypeError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def normalize(raw, now)
|
|
25
|
+
max = now + FUTURE_TOLERANCE
|
|
26
|
+
divisor = DIVISORS.find { |d| (raw / d).between?(MIN_EPOCH, max) }
|
|
27
|
+
raw / divisor if divisor
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yabeda"
|
|
4
|
+
|
|
5
|
+
Yabeda.configure do
|
|
6
|
+
group :rack_queue do
|
|
7
|
+
histogram :rack_queue_duration,
|
|
8
|
+
comment: "Time a request waited in the upstream queue before reaching the application",
|
|
9
|
+
unit: :seconds,
|
|
10
|
+
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yabeda
|
|
4
|
+
module Rack
|
|
5
|
+
module Queue
|
|
6
|
+
class Middleware
|
|
7
|
+
class StderrLogger
|
|
8
|
+
def warn(message) = Kernel.warn(message)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class YabedaReporter
|
|
12
|
+
def observe(value) = Yabeda.rack_queue.rack_queue_duration.measure({}, value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(app, reporter: YabedaReporter.new, logger: nil, clock: nil)
|
|
16
|
+
@app = app
|
|
17
|
+
@reporter = reporter
|
|
18
|
+
@parser = HeaderTimestampParser.new
|
|
19
|
+
@logger = logger || StderrLogger.new
|
|
20
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_REALTIME) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(env)
|
|
24
|
+
measure_queue_time(env) if env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"]
|
|
25
|
+
@app.call(env)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def measure_queue_time(env)
|
|
31
|
+
now = @clock.call
|
|
32
|
+
start = @parser.parse(env["HTTP_X_REQUEST_START"], now: now) ||
|
|
33
|
+
@parser.parse(env["HTTP_X_QUEUE_START"], now: now)
|
|
34
|
+
report_queue_time(env, now, start) if start
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def report_queue_time(env, now, request_start)
|
|
38
|
+
queue_time = now - request_start
|
|
39
|
+
return @logger.warn("Negative rack queue duration (#{queue_time}); dropping") if queue_time.negative?
|
|
40
|
+
|
|
41
|
+
body_wait = parse_body_wait(env["puma.request_body_wait"])
|
|
42
|
+
@reporter.observe([queue_time - (body_wait || 0), 0.0].max)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse_body_wait(value)
|
|
46
|
+
ms = Float(value)
|
|
47
|
+
ms / 1_000.0 unless ms.negative?
|
|
48
|
+
rescue ArgumentError, TypeError
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "puma"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
class PumaServerHarness
|
|
10
|
+
attr_reader :port
|
|
11
|
+
|
|
12
|
+
def initialize(app)
|
|
13
|
+
@app = app
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start
|
|
17
|
+
@server = Puma::Server.new(@app, nil, min_threads: 0, max_threads: 4)
|
|
18
|
+
@server.add_tcp_listener("127.0.0.1", 0)
|
|
19
|
+
@port = @server.connected_ports.first
|
|
20
|
+
@server.run(true, thread_name: "puma-e2e")
|
|
21
|
+
wait_until_ready
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stop
|
|
25
|
+
@server&.stop(true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def wait_until_ready
|
|
31
|
+
Timeout.timeout(5) do
|
|
32
|
+
loop do
|
|
33
|
+
socket = TCPSocket.new("127.0.0.1", port)
|
|
34
|
+
socket.close
|
|
35
|
+
break
|
|
36
|
+
rescue Errno::ECONNREFUSED
|
|
37
|
+
sleep 0.01
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class PumaIntegrationTest < Minitest::Test
|
|
44
|
+
def setup
|
|
45
|
+
super
|
|
46
|
+
rack_app = Yabeda::Rack::Queue::Middleware.new(
|
|
47
|
+
->(_env) { [200, {"content-type" => "text/plain"}, ["ok"]] }
|
|
48
|
+
)
|
|
49
|
+
@server = PumaServerHarness.new(rack_app)
|
|
50
|
+
@server.start
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def teardown
|
|
54
|
+
@server&.stop
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_records_rack_queue_duration_histogram_via_yabeda_on_real_http_request
|
|
59
|
+
requested_queue_time_seconds = 0.12
|
|
60
|
+
request_start_ms = ((Time.now.to_f - requested_queue_time_seconds) * 1_000).to_i
|
|
61
|
+
uri = URI("http://127.0.0.1:#{@server.port}/")
|
|
62
|
+
request = Net::HTTP::Get.new(uri)
|
|
63
|
+
request["X-Request-Start"] = request_start_ms.to_s
|
|
64
|
+
|
|
65
|
+
response = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) }
|
|
66
|
+
|
|
67
|
+
assert_equal "200", response.code
|
|
68
|
+
|
|
69
|
+
metric = Yabeda.rack_queue.rack_queue_duration
|
|
70
|
+
measured = Yabeda::TestAdapter.instance.histograms.fetch(metric).fetch({})
|
|
71
|
+
|
|
72
|
+
assert_kind_of Float, measured
|
|
73
|
+
assert_operator measured, :>=, requested_queue_time_seconds
|
|
74
|
+
end
|
|
75
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "minitest/autorun"
|
|
5
|
+
require "yabeda/test_adapter"
|
|
6
|
+
require "yabeda/rack/queue"
|
|
7
|
+
|
|
8
|
+
Yabeda.register_adapter(:test, Yabeda::TestAdapter.instance)
|
|
9
|
+
Yabeda.configure! unless Yabeda.configured?
|
|
10
|
+
|
|
11
|
+
class Minitest::Test
|
|
12
|
+
def setup
|
|
13
|
+
super
|
|
14
|
+
Yabeda::TestAdapter.instance.reset!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class GemspecTest < Minitest::Test
|
|
6
|
+
def test_does_not_declare_rack_runtime_dependency
|
|
7
|
+
gemspec_path = File.expand_path("../../../../yabeda-rack-queue.gemspec", __dir__)
|
|
8
|
+
spec = Gem::Specification.load(gemspec_path)
|
|
9
|
+
|
|
10
|
+
refute_nil spec
|
|
11
|
+
assert_nil spec.runtime_dependencies.find { |dependency| dependency.name == "rack" }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class HeaderTimestampParserTest < Minitest::Test
|
|
6
|
+
def setup
|
|
7
|
+
super
|
|
8
|
+
@parser = Yabeda::Rack::Queue::HeaderTimestampParser.new
|
|
9
|
+
@now = 1_700_000_000.0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_accepts_known_valid_values_from_truth_table
|
|
13
|
+
expectations = {
|
|
14
|
+
"t=1512379167.574" => 1_512_379_167.574,
|
|
15
|
+
"1512379167.574" => 1_512_379_167.574,
|
|
16
|
+
"t=1512379167574" => 1_512_379_167.574,
|
|
17
|
+
"1512379167574" => 1_512_379_167.574,
|
|
18
|
+
"t=1570633834463123" => 1_570_633_834.463123,
|
|
19
|
+
"1570633834463123" => 1_570_633_834.463123,
|
|
20
|
+
"t=1512379167" => 1_512_379_167.0,
|
|
21
|
+
"1512379167" => 1_512_379_167.0,
|
|
22
|
+
" t=1512379167.574 " => 1_512_379_167.574,
|
|
23
|
+
"t=1512379167.574, t=1512379168.000" => 1_512_379_167.574
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
expectations.each do |header_value, expected|
|
|
27
|
+
actual = @parser.parse(header_value, now: @now)
|
|
28
|
+
assert_in_delta expected, actual, 1e-9, header_value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_rejects_known_invalid_values_from_truth_table
|
|
33
|
+
["invalid", "t=", "t=0", "t=915148800", "t=1700000035"].each do |header_value|
|
|
34
|
+
assert_nil @parser.parse(header_value, now: @now), header_value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_prefers_t_equals_token_over_plain_token_when_both_are_present
|
|
39
|
+
value = @parser.parse("1512370000 t=1512379167.574", now: @now)
|
|
40
|
+
assert_in_delta 1_512_379_167.574, value, 1e-9
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_returns_nil_for_non_string_values_that_cannot_be_parsed
|
|
44
|
+
assert_nil @parser.parse(nil, now: @now)
|
|
45
|
+
assert_nil @parser.parse(Object.new, now: @now)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_rejects_values_more_than_30_seconds_in_the_future
|
|
49
|
+
header_value = "t=#{@now + 30.001}"
|
|
50
|
+
assert_nil @parser.parse(header_value, now: @now)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_accepts_values_exactly_30_seconds_in_the_future
|
|
54
|
+
header_value = "t=#{@now + 30.0}"
|
|
55
|
+
assert_in_delta @now + 30.0, @parser.parse(header_value, now: @now), 1e-9
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_uses_only_first_comma_separated_value
|
|
59
|
+
assert_nil @parser.parse("invalid, t=1512379167.574", now: @now)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class MetricTest < Minitest::Test
|
|
6
|
+
def test_registers_rack_queue_duration_histogram_with_required_metadata
|
|
7
|
+
metric = Yabeda.rack_queue.rack_queue_duration
|
|
8
|
+
|
|
9
|
+
assert_instance_of Yabeda::Histogram, metric
|
|
10
|
+
assert_equal :rack_queue, metric.group
|
|
11
|
+
assert_equal :seconds, metric.unit
|
|
12
|
+
assert_equal "Time a request waited in the upstream queue before reaching the application", metric.comment
|
|
13
|
+
assert_equal [], metric.tags
|
|
14
|
+
assert_equal [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], metric.buckets
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "benchmark/ips"
|
|
5
|
+
|
|
6
|
+
class MiddlewarePerformanceTest < Minitest::Test
|
|
7
|
+
MINIMUM_IPS = 1_000_000.0
|
|
8
|
+
|
|
9
|
+
def test_processes_noop_rack_app_above_one_million_calls_per_second
|
|
10
|
+
response = [200, {"content-type" => "text/plain"}, ["hello world"]].freeze
|
|
11
|
+
app = ->(_env) { response }
|
|
12
|
+
middleware = Yabeda::Rack::Queue::Middleware.new(app)
|
|
13
|
+
env = {}.freeze
|
|
14
|
+
|
|
15
|
+
report = Benchmark.ips do |x|
|
|
16
|
+
x.config(time: 1, warmup: 0.5)
|
|
17
|
+
x.report("middleware noop call") { middleware.call(env) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
observed_ips = report.entries.fetch(0).ips
|
|
21
|
+
assert_operator observed_ips, :>, MINIMUM_IPS
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CapturingReporter
|
|
6
|
+
attr_reader :values
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@values = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def observe(value)
|
|
13
|
+
@values << value
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class CapturingLogger
|
|
18
|
+
attr_reader :warnings
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@warnings = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def warn(message)
|
|
25
|
+
@warnings << message
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class AppSpy
|
|
30
|
+
attr_reader :called_count, :last_env
|
|
31
|
+
|
|
32
|
+
def initialize(response)
|
|
33
|
+
@response = response
|
|
34
|
+
@called_count = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(env)
|
|
38
|
+
@called_count += 1
|
|
39
|
+
@last_env = env
|
|
40
|
+
@response
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class MiddlewareTest < Minitest::Test
|
|
45
|
+
def setup
|
|
46
|
+
super
|
|
47
|
+
@response = [201, {"content-type" => "text/plain"}, ["ok"]]
|
|
48
|
+
@app = AppSpy.new(@response)
|
|
49
|
+
@reporter = CapturingReporter.new
|
|
50
|
+
@now = 1_700_000_000.0
|
|
51
|
+
@clock = -> { @now }
|
|
52
|
+
@logger = CapturingLogger.new
|
|
53
|
+
@middleware = Yabeda::Rack::Queue::Middleware.new(
|
|
54
|
+
@app,
|
|
55
|
+
reporter: @reporter,
|
|
56
|
+
clock: @clock,
|
|
57
|
+
logger: @logger
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_always_calls_downstream_app_and_returns_response_unchanged
|
|
62
|
+
env = {}
|
|
63
|
+
|
|
64
|
+
result = @middleware.call(env)
|
|
65
|
+
|
|
66
|
+
assert_same @response, result
|
|
67
|
+
assert_equal 1, @app.called_count
|
|
68
|
+
assert_same env, @app.last_env
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_does_not_mutate_rack_env
|
|
72
|
+
env = {"HTTP_X_REQUEST_START" => "t=1699999999.9", "custom.key" => "value"}
|
|
73
|
+
original = env.dup
|
|
74
|
+
|
|
75
|
+
@middleware.call(env)
|
|
76
|
+
|
|
77
|
+
assert_equal original, env
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_records_nothing_when_neither_header_is_present
|
|
81
|
+
@middleware.call({})
|
|
82
|
+
|
|
83
|
+
assert_empty @reporter.values
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_uses_x_request_start_before_x_queue_start_when_both_are_valid
|
|
87
|
+
env = {
|
|
88
|
+
"HTTP_X_REQUEST_START" => "t=1699999999.9",
|
|
89
|
+
"HTTP_X_QUEUE_START" => "t=1699999999.8"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@middleware.call(env)
|
|
93
|
+
|
|
94
|
+
assert_in_delta 0.1, @reporter.values.last, 1e-4
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_falls_back_to_x_queue_start_when_x_request_start_is_invalid
|
|
98
|
+
env = {
|
|
99
|
+
"HTTP_X_REQUEST_START" => "invalid",
|
|
100
|
+
"HTTP_X_QUEUE_START" => "t=1699999999.9"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@middleware.call(env)
|
|
104
|
+
|
|
105
|
+
assert_in_delta 0.1, @reporter.values.last, 1e-4
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_computes_queue_time_before_calling_downstream_app
|
|
109
|
+
sleeping_app = lambda do |_env|
|
|
110
|
+
sleep 0.05
|
|
111
|
+
@response
|
|
112
|
+
end
|
|
113
|
+
test_middleware = Yabeda::Rack::Queue::Middleware.new(
|
|
114
|
+
sleeping_app,
|
|
115
|
+
reporter: @reporter,
|
|
116
|
+
clock: @clock,
|
|
117
|
+
logger: @logger
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
test_middleware.call("HTTP_X_REQUEST_START" => "t=1699999999.9")
|
|
121
|
+
|
|
122
|
+
assert_in_delta 0.1, @reporter.values.last, 1e-4
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_uses_process_clock_gettime_realtime_by_default
|
|
126
|
+
middleware = Yabeda::Rack::Queue::Middleware.new(@app, reporter: @reporter, logger: @logger)
|
|
127
|
+
observed_clock_ids = []
|
|
128
|
+
clock_gettime_stub = lambda do |clock_id|
|
|
129
|
+
observed_clock_ids << clock_id
|
|
130
|
+
@now
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Process.stub(:clock_gettime, clock_gettime_stub) do
|
|
134
|
+
middleware.call("HTTP_X_REQUEST_START" => "t=1699999999.9")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
refute_empty @reporter.values
|
|
138
|
+
assert_equal [Process::CLOCK_REALTIME], observed_clock_ids
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_never_raises_on_invalid_header_values
|
|
142
|
+
@middleware.call("HTTP_X_REQUEST_START" => Object.new, "HTTP_X_QUEUE_START" => "")
|
|
143
|
+
assert_equal 1, @app.called_count
|
|
144
|
+
assert_empty @reporter.values
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def test_drops_negative_queue_times_and_logs_warning
|
|
148
|
+
@middleware.call("HTTP_X_REQUEST_START" => "t=1700000000.1")
|
|
149
|
+
|
|
150
|
+
assert_equal 1, @app.called_count
|
|
151
|
+
assert_empty @reporter.values
|
|
152
|
+
assert_includes @logger.warnings.join("\n"), "Negative rack queue duration"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_subtracts_puma_request_body_wait_milliseconds_from_queue_time
|
|
156
|
+
@middleware.call(
|
|
157
|
+
"HTTP_X_REQUEST_START" => "t=1699999999.9",
|
|
158
|
+
"puma.request_body_wait" => 40
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert_in_delta 0.06, @reporter.values.last, 1e-4
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def test_coerces_string_puma_request_body_wait_values
|
|
165
|
+
@middleware.call(
|
|
166
|
+
"HTTP_X_REQUEST_START" => "t=1699999999.9",
|
|
167
|
+
"puma.request_body_wait" => "40"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
assert_in_delta 0.06, @reporter.values.last, 1e-4
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_ignores_non_numeric_puma_request_body_wait_values
|
|
174
|
+
@middleware.call(
|
|
175
|
+
"HTTP_X_REQUEST_START" => "t=1699999999.9",
|
|
176
|
+
"puma.request_body_wait" => "not-a-number"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert_in_delta 0.1, @reporter.values.last, 1e-4
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def test_ignores_negative_puma_request_body_wait_values
|
|
183
|
+
@middleware.call(
|
|
184
|
+
"HTTP_X_REQUEST_START" => "t=1699999999.9",
|
|
185
|
+
"puma.request_body_wait" => -40
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
assert_in_delta 0.1, @reporter.values.last, 1e-4
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_clamps_to_zero_after_puma_request_body_wait_subtraction
|
|
192
|
+
@middleware.call(
|
|
193
|
+
"HTTP_X_REQUEST_START" => "t=1699999999.9",
|
|
194
|
+
"puma.request_body_wait" => 200
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
assert_equal 0.0, @reporter.values.last
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/yabeda/rack/queue/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "yabeda-rack-queue"
|
|
7
|
+
spec.version = Yabeda::Rack::Queue::VERSION
|
|
8
|
+
spec.authors = ["Nate Berkopec"]
|
|
9
|
+
spec.email = ["nate.berkopec@speedshop.co"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Yabeda middleware for HTTP request queue duration"
|
|
12
|
+
spec.description = <<~DESCRIPTION
|
|
13
|
+
Rack middleware that measures HTTP request queue duration from upstream
|
|
14
|
+
headers and reports it to Yabeda as a histogram metric.
|
|
15
|
+
DESCRIPTION
|
|
16
|
+
spec.homepage = "https://github.com/speedshop/yabeda-rack-queue"
|
|
17
|
+
spec.license = "MIT"
|
|
18
|
+
spec.required_ruby_version = ">= 3.1"
|
|
19
|
+
|
|
20
|
+
spec.metadata = {
|
|
21
|
+
"bug_tracker_uri" => "https://github.com/speedshop/yabeda-rack-queue/issues",
|
|
22
|
+
"changelog_uri" => "https://github.com/speedshop/yabeda-rack-queue/releases",
|
|
23
|
+
"homepage_uri" => spec.homepage,
|
|
24
|
+
"source_code_uri" => "https://github.com/speedshop/yabeda-rack-queue",
|
|
25
|
+
"rubygems_mfa_required" => "true"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
spec.files = Dir.chdir(__dir__) do
|
|
29
|
+
`git ls-files -z`.split("\x0").reject do |file|
|
|
30
|
+
file.start_with?(".github/", ".pi/")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
spec.add_dependency "yabeda", ">= 0.14", "< 1.0"
|
|
36
|
+
|
|
37
|
+
spec.add_development_dependency "puma", ">= 6", "< 8"
|
|
38
|
+
spec.add_development_dependency "rake", ">= 13.0"
|
|
39
|
+
spec.add_development_dependency "minitest", ">= 5.22", "< 6.0"
|
|
40
|
+
spec.add_development_dependency "benchmark", ">= 0.4", "< 1.0"
|
|
41
|
+
spec.add_development_dependency "benchmark-ips", ">= 2.14", "< 3.0"
|
|
42
|
+
spec.add_development_dependency "standard", "~> 1.44"
|
|
43
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yabeda-rack-queue
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nate Berkopec
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: yabeda
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.14'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.14'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: puma
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '6'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '8'
|
|
42
|
+
type: :development
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '6'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '8'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: rake
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '13.0'
|
|
59
|
+
type: :development
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '13.0'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: minitest
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '5.22'
|
|
73
|
+
- - "<"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '6.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '5.22'
|
|
83
|
+
- - "<"
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '6.0'
|
|
86
|
+
- !ruby/object:Gem::Dependency
|
|
87
|
+
name: benchmark
|
|
88
|
+
requirement: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0.4'
|
|
93
|
+
- - "<"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.0'
|
|
96
|
+
type: :development
|
|
97
|
+
prerelease: false
|
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0.4'
|
|
103
|
+
- - "<"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '1.0'
|
|
106
|
+
- !ruby/object:Gem::Dependency
|
|
107
|
+
name: benchmark-ips
|
|
108
|
+
requirement: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '2.14'
|
|
113
|
+
- - "<"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '3.0'
|
|
116
|
+
type: :development
|
|
117
|
+
prerelease: false
|
|
118
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - ">="
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '2.14'
|
|
123
|
+
- - "<"
|
|
124
|
+
- !ruby/object:Gem::Version
|
|
125
|
+
version: '3.0'
|
|
126
|
+
- !ruby/object:Gem::Dependency
|
|
127
|
+
name: standard
|
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
|
129
|
+
requirements:
|
|
130
|
+
- - "~>"
|
|
131
|
+
- !ruby/object:Gem::Version
|
|
132
|
+
version: '1.44'
|
|
133
|
+
type: :development
|
|
134
|
+
prerelease: false
|
|
135
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
136
|
+
requirements:
|
|
137
|
+
- - "~>"
|
|
138
|
+
- !ruby/object:Gem::Version
|
|
139
|
+
version: '1.44'
|
|
140
|
+
description: |
|
|
141
|
+
Rack middleware that measures HTTP request queue duration from upstream
|
|
142
|
+
headers and reports it to Yabeda as a histogram metric.
|
|
143
|
+
email:
|
|
144
|
+
- nate.berkopec@speedshop.co
|
|
145
|
+
executables: []
|
|
146
|
+
extensions: []
|
|
147
|
+
extra_rdoc_files: []
|
|
148
|
+
files:
|
|
149
|
+
- ".gitignore"
|
|
150
|
+
- Gemfile
|
|
151
|
+
- Gemfile.lock
|
|
152
|
+
- LICENSE.txt
|
|
153
|
+
- README.md
|
|
154
|
+
- Rakefile
|
|
155
|
+
- SPEC.md
|
|
156
|
+
- lib/yabeda-rack-queue.rb
|
|
157
|
+
- lib/yabeda/rack/queue.rb
|
|
158
|
+
- lib/yabeda/rack/queue/header_timestamp_parser.rb
|
|
159
|
+
- lib/yabeda/rack/queue/metric.rb
|
|
160
|
+
- lib/yabeda/rack/queue/middleware.rb
|
|
161
|
+
- lib/yabeda/rack/queue/version.rb
|
|
162
|
+
- test/e2e/puma_integration_test.rb
|
|
163
|
+
- test/test_helper.rb
|
|
164
|
+
- test/yabeda/rack/queue/gemspec_test.rb
|
|
165
|
+
- test/yabeda/rack/queue/header_timestamp_parser_test.rb
|
|
166
|
+
- test/yabeda/rack/queue/metric_test.rb
|
|
167
|
+
- test/yabeda/rack/queue/middleware_performance_test.rb
|
|
168
|
+
- test/yabeda/rack/queue/middleware_test.rb
|
|
169
|
+
- yabeda-rack-queue.gemspec
|
|
170
|
+
homepage: https://github.com/speedshop/yabeda-rack-queue
|
|
171
|
+
licenses:
|
|
172
|
+
- MIT
|
|
173
|
+
metadata:
|
|
174
|
+
bug_tracker_uri: https://github.com/speedshop/yabeda-rack-queue/issues
|
|
175
|
+
changelog_uri: https://github.com/speedshop/yabeda-rack-queue/releases
|
|
176
|
+
homepage_uri: https://github.com/speedshop/yabeda-rack-queue
|
|
177
|
+
source_code_uri: https://github.com/speedshop/yabeda-rack-queue
|
|
178
|
+
rubygems_mfa_required: 'true'
|
|
179
|
+
rdoc_options: []
|
|
180
|
+
require_paths:
|
|
181
|
+
- lib
|
|
182
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - ">="
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: '3.1'
|
|
187
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
188
|
+
requirements:
|
|
189
|
+
- - ">="
|
|
190
|
+
- !ruby/object:Gem::Version
|
|
191
|
+
version: '0'
|
|
192
|
+
requirements: []
|
|
193
|
+
rubygems_version: 4.0.3
|
|
194
|
+
specification_version: 4
|
|
195
|
+
summary: Yabeda middleware for HTTP request queue duration
|
|
196
|
+
test_files: []
|