Skip to content

Commit 57feffb

Browse files
authored
Merge pull request #245 from prometheus/sinjo-better-path-labels
Use framework-specific route info and handle consecutive path segments containing IDs in Collector
2 parents 9c896d0 + 9d8ba56 commit 57feffb

3 files changed

Lines changed: 144 additions & 4 deletions

File tree

UPGRADING.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ recommends exporting a default value for any time series you know will exist in
140140
For series with no labels, other Prometheus clients (including Go, Java, and Python) do
141141
this automatically, so we have matched that behaviour in the 3.x.x series.
142142

143-
## Path generation fix in Collector middleware
143+
## Added `SCRIPT_NAME` to path labels in Collector middleware
144144

145145
Previously, we did not include `Rack::Request`'s `SCRIPT_NAME` when building paths in
146146
`Prometheus::Middleware::Collector`. We have now added this, which means that any
@@ -151,6 +151,22 @@ This will most typically be present when mounting several Rack applications in t
151151
server process, such as when using [Rails
152152
Engines](https://guides.rubyonrails.org/engines.html).
153153

154+
## Improved stripping of IDs/UUIDs from paths in Collector middleware
155+
156+
Where available (currently for applications written in the Sinatra and Grape frameworks),
157+
we now use framework-specific equivalents to `PATH_INFO` in
158+
`Prometheus::Middleware::Collector`, which means that rather than having path segments
159+
replaced with the generic `:id` and `:uuid` placeholders, you'll see the route as you
160+
defined it in your framework.
161+
162+
For frameworks where that information isn't available to us (most notably Rails), we still
163+
fall back to using `PATH_INFO`, though we have also improved how we strip IDs/UUIDs from
164+
it. Previously, we would only strip them from alternating path segments due to the way we
165+
were matching them. We have improved that matching so it works even when there are
166+
IDs/UUIDs in consecutive path segments.
167+
168+
You may notice the path label change for some of your endpoints.
169+
154170
## Improved validation of label names
155171

156172
Earlier versions of the Ruby Prometheus client performed limited validation of label names

lib/prometheus/middleware/collector.rb

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def trace(env)
6767
end
6868

6969
def record(env, code, duration)
70-
path = [env["SCRIPT_NAME"], env['PATH_INFO']].join
70+
path = generate_path(env)
7171

7272
counter_labels = {
7373
code: code,
@@ -87,10 +87,58 @@ def record(env, code, duration)
8787
nil
8888
end
8989

90+
# While `PATH_INFO` is framework agnostic, and works for any Rack app, some Ruby web
91+
# frameworks pass a more useful piece of information into the request env - the
92+
# route that the request matched.
93+
#
94+
# This means that rather than using our generic `:id` and `:uuid` replacements in
95+
# the `path` label for any path segments that look like dynamic IDs, we can put the
96+
# actual route that matched in there, with correctly named parameters. For example,
97+
# if a Sinatra app defined a route like:
98+
#
99+
# get "/foo/:bar" do
100+
# ...
101+
# end
102+
#
103+
# instead of containing `/foo/:id`, the `path` label would contain `/foo/:bar`.
104+
#
105+
# Sadly, Rails is a notable exception, and (as far as I can tell at the time of
106+
# writing) doesn't provide this info in the request env.
107+
def generate_path(env)
108+
if env['sinatra.route']
109+
route = env['sinatra.route'].partition(' ').last
110+
elsif env['grape.routing_args']
111+
# We are deep in the weeds of an object that Grape passes into the request env,
112+
# but don't document any explicit guarantees about. Let's have a fallback in
113+
# case they change it down the line.
114+
#
115+
# This code would be neater with the safe navigation operator (`&.`) here rather
116+
# than the much more verbose `respond_to?` calls, but unlike Rails' `try`
117+
# method, it still raises an error if the object is non-nil, but doesn't respond
118+
# to the method being called on it.
119+
route = nil
120+
121+
route_info = env.dig('grape.routing_args', :route_info)
122+
if route_info.respond_to?(:pattern)
123+
pattern = route_info.pattern
124+
if pattern.respond_to?(:origin)
125+
route = pattern.origin
126+
end
127+
end
128+
129+
# Fall back to PATH_INFO if Grape change the structure of `grape.routing_args`
130+
route ||= env['PATH_INFO']
131+
else
132+
route = env['PATH_INFO']
133+
end
134+
135+
[env['SCRIPT_NAME'], route].join
136+
end
137+
90138
def strip_ids_from_path(path)
91139
path
92-
.gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1')
93-
.gsub(%r{/\d+(/|$)}, '/:id\\1')
140+
.gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)}, '/:uuid\\1')
141+
.gsub(%r{/\d+(?=/|$)}, '/:id\\1')
94142
end
95143
end
96144
end

spec/prometheus/middleware/collector_spec.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,82 @@
9595
expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1)
9696
end
9797

98+
it 'handles consecutive path segments containing IDs' do
99+
expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3)
100+
101+
get '/foo/42/24'
102+
103+
metric = :http_server_requests_total
104+
labels = { method: 'get', path: '/foo/:id/:id', code: '200' }
105+
expect(registry.get(metric).get(labels: labels)).to eql(1.0)
106+
107+
metric = :http_server_request_duration_seconds
108+
labels = { method: 'get', path: '/foo/:id/:id' }
109+
expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1)
110+
end
111+
112+
it 'handles consecutive path segments containing UUIDs' do
113+
expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3)
114+
115+
get '/foo/5180349d-a491-4d73-af30-4194a46bdff3/5180349d-a491-4d73-af30-4194a46bdff2'
116+
117+
metric = :http_server_requests_total
118+
labels = { method: 'get', path: '/foo/:uuid/:uuid', code: '200' }
119+
expect(registry.get(metric).get(labels: labels)).to eql(1.0)
120+
121+
metric = :http_server_request_duration_seconds
122+
labels = { method: 'get', path: '/foo/:uuid/:uuid' }
123+
expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1)
124+
end
125+
126+
it 'prefers sinatra.route to PATH_INFO' do
127+
metric = :http_server_requests_total
128+
129+
env('sinatra.route', 'GET /foo/:named_param')
130+
get '/foo/7'
131+
env('sinatra.route', nil)
132+
expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:named_param")
133+
end
134+
135+
it 'prefers grape.routing_args to PATH_INFO' do
136+
metric = :http_server_requests_total
137+
138+
# request.env["grape.routing_args"][:route_info].pattern.origin
139+
#
140+
# Yes, this is the object you have to traverse to get the path.
141+
#
142+
# No, I'm not happy about it either.
143+
grape_routing_args = {
144+
route_info: double(:route_info,
145+
pattern: double(:pattern,
146+
origin: '/foo/:named_param'
147+
)
148+
)
149+
}
150+
151+
env('grape.routing_args', grape_routing_args)
152+
get '/foo/7'
153+
env('grape.routing_args', nil)
154+
expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:named_param")
155+
end
156+
157+
it "falls back to PATH_INFO if the structure of grape.routing_args changes" do
158+
metric = :http_server_requests_total
159+
160+
grape_routing_args = {
161+
route_info: double(:route_info,
162+
pattern: double(:pattern,
163+
origin_but_different: '/foo/:named_param'
164+
)
165+
)
166+
}
167+
168+
env('grape.routing_args', grape_routing_args)
169+
get '/foo/7'
170+
env('grape.routing_args', nil)
171+
expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:id")
172+
end
173+
98174
context 'when the app raises an exception' do
99175
let(:original_app) do
100176
lambda do |env|

0 commit comments

Comments
 (0)