Skip to content

Commit e87fae1

Browse files
committed
feat: Generate metrics using a separate handler
1 parent 7f8ce16 commit e87fae1

File tree

6 files changed

+192
-39
lines changed

6 files changed

+192
-39
lines changed

instrumentation/rack/Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ group :test do
1212
gem 'opentelemetry-instrumentation-base', path: '../base'
1313
gem 'rack-test', '~> 2.1.0'
1414
gem 'pry-byebug'
15+
gem 'opentelemetry-metrics-sdk'
16+
gem 'opentelemetry-metrics-api'
1517
end

instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base
4343
# @return [Array] consisting of a middleware and arguments used in rack builders
4444
def middleware_args
4545
if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler)
46-
[::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new]]
46+
handlers = [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new]
47+
handlers << OpenTelemetry::Instrumentation::Rack::Middlewares::MetricsEventHandler.new if metrics_enabled?
48+
[::Rack::Events, handlers]
4749
else
4850
[OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware]
4951
end
@@ -53,6 +55,7 @@ def middleware_args
5355

5456
def require_dependencies
5557
require_relative 'middlewares/event_handler' if defined?(::Rack::Events)
58+
require_relative 'middlewares/metrics_event_handler' if metrics_enabled?
5659
require_relative 'middlewares/tracer_middleware'
5760
end
5861

instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def extract_request_headers(env)
128128
end
129129

130130
def extract_response_attributes(response)
131+
# TODO: Rack spec states status should always be an integer, so we might not need to coerce
131132
attributes = { 'http.status_code' => response.status.to_i }
132133
attributes.merge!(extract_response_headers(response.headers))
133134
attributes
@@ -203,7 +204,6 @@ def detach_context(request)
203204
token, span = request.env[OTEL_TOKEN_AND_SPAN]
204205
span.finish
205206
OpenTelemetry::Context.detach(token)
206-
record_http_server_request_duration_metric(span)
207207
rescue StandardError => e
208208
OpenTelemetry.handle_error(exception: e)
209209
end
@@ -263,43 +263,6 @@ def create_span(parent_context, request)
263263
span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil?
264264
span
265265
end
266-
267-
# Metrics stuff
268-
HTTP_SERVER_REQUEST_DURATION_ATTRS_FROM_SPAN = %w[http.method http.scheme http.route http.status_code http.host].freeze
269-
270-
def metrics_enabled?
271-
OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.metrics_enabled?
272-
end
273-
274-
def meter
275-
return unless metrics_enabled?
276-
277-
OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.meter
278-
end
279-
280-
def http_server_request_duration_histogram
281-
return unless metrics_enabled?
282-
283-
@http_server_request_duration_histogram ||= meter.create_histogram(
284-
'http.server.request.duration',
285-
unit: 's',
286-
description: 'Duration of HTTP server requests.'
287-
)
288-
end
289-
290-
def record_http_server_request_duration_metric(span)
291-
return unless metrics_enabled?
292-
293-
# find span duration
294-
# end - start / a billion to convert nanoseconds to seconds
295-
duration = (span.end_timestamp - span.start_timestamp) / Float(10**9)
296-
# glean attributes
297-
attrs = span.attributes.select { |k, _v| HTTP_SERVER_REQUEST_DURATION_ATTRS_FROM_SPAN.include?(k) }
298-
# set error
299-
attrs['error.type'] = span.status.description if span.status.code == OpenTelemetry::Trace::Status::ERROR
300-
301-
http_server_request_duration_histogram.record(duration, attributes: attrs)
302-
end
303266
end
304267
end
305268
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
require_relative '../util'
8+
9+
module OpenTelemetry
10+
module Instrumentation
11+
module Rack
12+
module Middlewares
13+
# OTel Rack Metrics Event Handler
14+
#
15+
# @see Rack::Events
16+
class MetricsEventHandler
17+
include ::Rack::Events::Abstract
18+
19+
OTEL_METRICS = 'otel.rack.metrics'
20+
21+
def on_start(request, _)
22+
request.env[OTEL_METRICS] = { start_time: monotonic_time_now_nano }
23+
rescue StandardError => e
24+
OpenTelemetry.handle_error(exception: e)
25+
end
26+
27+
def on_error(request, _, error)
28+
request.env[OTEL_METRICS][:error] = error.class.to_s
29+
rescue StandardError => e
30+
OpenTelemetry.handle_error(exception: e)
31+
end
32+
33+
def on_finish(request, response)
34+
record_http_server_request_duration_metric(request, response)
35+
rescue StandardError => e
36+
OpenTelemetry.handle_error(exception: e)
37+
end
38+
39+
private
40+
41+
def meter
42+
OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.meter
43+
end
44+
45+
def http_server_request_duration_histogram
46+
@http_server_request_duration_histogram ||= meter.create_histogram(
47+
'http.server.request.duration',
48+
unit: 's',
49+
description: 'Duration of HTTP server requests.'
50+
)
51+
end
52+
53+
def record_http_server_request_duration_metric(request, response)
54+
metrics_env = request.env[OTEL_METRICS]
55+
duration = (monotonic_time_now_nano - metrics_env[:start_time]) / Float(10**9)
56+
attrs = request_metric_attributes(request.env)
57+
attrs['error.type'] = metrics_env[:error] if metrics_env[:error]
58+
attrs['http.response.status.code'] = response.status
59+
60+
http_server_request_duration_histogram.record(duration, attributes: attrs)
61+
end
62+
63+
def monotonic_time_now_nano
64+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
65+
end
66+
67+
def request_metric_attributes(env)
68+
{
69+
'http.method' => env['REQUEST_METHOD'],
70+
'http.host' => env['HTTP_HOST'] || 'unknown',
71+
'http.scheme' => env['rack.url_scheme'],
72+
'http.route' => "#{env['PATH_INFO']}#{('?' + env['QUERY_STRING']) unless env['QUERY_STRING'].empty?}"
73+
}
74+
end
75+
end
76+
end
77+
end
78+
end
79+
end
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
require_relative '../../../../test_helper'
8+
require_relative '../../../../../lib/opentelemetry/instrumentation/rack'
9+
require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation'
10+
require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/metrics_event_handler'
11+
12+
# test command:
13+
# be appraisal rack-latest ruby test/opentelemetry/instrumentation/rack/middlewares/metric_event_handler_test.rb
14+
describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::MetricsEventHandler' do
15+
include Rack::Test::Methods
16+
17+
let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack }
18+
let(:instrumentation_class) { instrumentation_module::Instrumentation }
19+
let(:instrumentation) { instrumentation_class.instance }
20+
let(:send_metrics) { true }
21+
let(:config) do
22+
{
23+
send_metrics: send_metrics,
24+
use_rack_events: true
25+
}
26+
end
27+
28+
let(:handler) do
29+
OpenTelemetry::Instrumentation::Rack::Middlewares::MetricsEventHandler.new
30+
end
31+
32+
let(:exporter) { METRICS_EXPORTER }
33+
34+
let(:last_snapshot) do
35+
exporter.pull
36+
exporter.metric_snapshots
37+
end
38+
39+
let(:after_close) { nil }
40+
let(:response_body) { Rack::BodyProxy.new(['Hello World']) { after_close&.call } }
41+
let(:service) do
42+
->(_arg) { [200, { 'Content-Type' => 'text/plain' }, response_body] }
43+
end
44+
45+
let(:app) do
46+
Rack::Builder.new.tap do |builder|
47+
builder.use Rack::Events, [handler]
48+
builder.run service
49+
end
50+
end
51+
52+
let(:uri) { '/' }
53+
let(:headers) { {} }
54+
55+
before do
56+
exporter.reset
57+
instrumentation.instance_variable_set(:@installed, false)
58+
# TODO: fix this so we don't have to force metrics to be enabled
59+
instrumentation.instance_variable_set(:@metrics_enabled, true)
60+
instrumentation.install(config)
61+
end
62+
63+
describe '#call' do
64+
before do
65+
get uri, {}, headers
66+
end
67+
68+
it 'records a metric' do
69+
metric = last_snapshot[0][0]
70+
assert_instance_of OpenTelemetry::SDK::Metrics::State::MetricData, metric
71+
assert_equal metric.name, 'http.server.request.duration'
72+
assert_equal metric.description, 'Duration of HTTP server requests.'
73+
assert_equal metric.unit, 's'
74+
assert_equal metric.instrument_kind, :histogram
75+
assert_equal metric.data_points[0].attributes, { 'http.method' => 'GET', 'http.host' => 'example.org', 'http.scheme' => 'http', 'http.route' => '/', 'http.response.status.code' => 200 }
76+
# assert_equal metric.data_points[0].sum?, expected # to check the duration
77+
end
78+
79+
# it 'records an error class if raised' {}
80+
# it 'creates the right histogram' {}
81+
# it 'assigns the right attributes' {}
82+
# it 'does not record a metric if send_metrics is false' {}
83+
# # do we need a totally separate testing environment for metrics so that the
84+
# # traces tests do not run with the metrics sdk and api enabled?
85+
# it 'rescues errors raised by OTel on_start' {}
86+
# it 'rescues errors raised by OTel on_error' {}
87+
# it 'rescues errors raised by OTel on_finish' {}
88+
# it 'preserves the :start_time in the rack environment?' {}
89+
# it 'includes a query string where present' {}
90+
# it 'does not include the question mark if the query string is blank' {}
91+
# it 'has a valid duration recorded for the value' {}
92+
# it 'records data points for multiple requests' {}
93+
# it 'creates the instrument only once' {}
94+
end
95+
end

instrumentation/rack/test/test_helper.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@
1717
EXPORTER = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
1818
span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(EXPORTER)
1919

20+
METRICS_EXPORTER = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new
21+
22+
module MetricsPatch
23+
def metrics_configuration_hook
24+
OpenTelemetry.meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: @resource)
25+
OpenTelemetry.meter_provider.add_metric_reader(METRICS_EXPORTER)
26+
end
27+
end
28+
29+
OpenTelemetry::SDK::Configurator.prepend(MetricsPatch)
30+
2031
OpenTelemetry::SDK.configure do |c|
2132
c.error_handler = ->(exception:, message:) { raise(exception || message) }
2233
c.logger = Logger.new($stderr, level: ENV.fetch('OTEL_LOG_LEVEL', 'fatal').to_sym)

0 commit comments

Comments
 (0)