Skip to content

Commit d33f60a

Browse files
committed
fix: HTTP unknown methods
Ensures that all HTTP client gems support unknown methods as described in semantic conventions: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span See #1779
1 parent 1e01808 commit d33f60a

File tree

62 files changed

+1270
-202
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1270
-202
lines changed

instrumentation/ethon/lib/opentelemetry/instrumentation/ethon.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ module Ethon
1515
end
1616
end
1717

18+
require_relative 'ethon/http_helper'
1819
require_relative 'ethon/instrumentation'
1920
require_relative 'ethon/version'
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
module OpenTelemetry
8+
module Instrumentation
9+
module Ethon
10+
# Helper module for HTTP method normalization
11+
# @api private
12+
module HttpHelper
13+
# Lightweight struct to hold span creation attributes
14+
SpanCreationAttributes = Struct.new(:span_name, :normalized_method, :original_method, keyword_init: true)
15+
16+
# Pre-computed mapping to avoid string allocations during normalization
17+
METHOD_CACHE = {
18+
'CONNECT' => 'CONNECT',
19+
'DELETE' => 'DELETE',
20+
'GET' => 'GET',
21+
'HEAD' => 'HEAD',
22+
'OPTIONS' => 'OPTIONS',
23+
'PATCH' => 'PATCH',
24+
'POST' => 'POST',
25+
'PUT' => 'PUT',
26+
'TRACE' => 'TRACE',
27+
'connect' => 'CONNECT',
28+
'delete' => 'DELETE',
29+
'get' => 'GET',
30+
'head' => 'HEAD',
31+
'options' => 'OPTIONS',
32+
'patch' => 'PATCH',
33+
'post' => 'POST',
34+
'put' => 'PUT',
35+
'trace' => 'TRACE',
36+
:connect => 'CONNECT',
37+
:delete => 'DELETE',
38+
:get => 'GET',
39+
:head => 'HEAD',
40+
:options => 'OPTIONS',
41+
:patch => 'PATCH',
42+
:post => 'POST',
43+
:put => 'PUT',
44+
:trace => 'TRACE'
45+
}.freeze
46+
47+
# Pre-computed span names for old semantic conventions to avoid allocations
48+
OLD_SPAN_NAMES = {
49+
'CONNECT' => 'HTTP CONNECT',
50+
'DELETE' => 'HTTP DELETE',
51+
'GET' => 'HTTP GET',
52+
'HEAD' => 'HTTP HEAD',
53+
'OPTIONS' => 'HTTP OPTIONS',
54+
'PATCH' => 'HTTP PATCH',
55+
'POST' => 'HTTP POST',
56+
'PUT' => 'HTTP PUT',
57+
'TRACE' => 'HTTP TRACE'
58+
}.freeze
59+
60+
private_constant :METHOD_CACHE, :OLD_SPAN_NAMES
61+
62+
# Prepares all span data for the specified semantic convention in a single call
63+
# @param method [String, Symbol] The HTTP method
64+
# @param semconv [Symbol] The semantic convention to use (:stable or :old)
65+
# @return [SpanCreationAttributes] struct containing span_name, normalized_method, and original_method
66+
def self.span_attrs_for(method, semconv: :stable)
67+
normalized = METHOD_CACHE[method]
68+
if normalized
69+
span_name = semconv == :old ? OLD_SPAN_NAMES[normalized] : normalized
70+
SpanCreationAttributes.new(
71+
span_name: span_name,
72+
normalized_method: normalized,
73+
original_method: nil
74+
)
75+
else
76+
SpanCreationAttributes.new(
77+
span_name: 'HTTP',
78+
normalized_method: '_OTHER',
79+
original_method: method.to_s
80+
)
81+
end
82+
end
83+
end
84+
end
85+
end
86+
end

instrumentation/ethon/lib/opentelemetry/instrumentation/ethon/patches/dup/easy.rb

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,11 @@ module Patches
1212
module Dup
1313
# Ethon::Easy patch for instrumentation
1414
module Easy
15-
ACTION_NAMES_TO_HTTP_METHODS = Hash.new do |h, k|
16-
# #to_s is required because user input could be symbol or string
17-
h[k] = k.to_s.upcase
18-
end
19-
HTTP_METHODS_TO_SPAN_NAMES = Hash.new do |h, k|
20-
h[k] = k.to_s
21-
h[k] = 'HTTP' if k == '_OTHER'
22-
end
23-
2415
# Constant for the HTTP status range
2516
HTTP_STATUS_SUCCESS_RANGE = (100..399)
2617

2718
def http_request(url, action_name, options = {})
28-
@otel_method = ACTION_NAMES_TO_HTTP_METHODS[action_name]
19+
@otel_method = action_name
2920
super
3021
end
3122

@@ -77,12 +68,11 @@ def reset
7768
end
7869

7970
def otel_before_request
80-
method = '_OTHER' # Could be GET or not HTTP at all
81-
method = @otel_method if instance_variable_defined?(:@otel_method) && !@otel_method.nil?
71+
span_data = HttpHelper.span_attrs_for(@otel_method)
8272

8373
@otel_span = tracer.start_span(
84-
HTTP_METHODS_TO_SPAN_NAMES[method],
85-
attributes: span_creation_attributes(method),
74+
span_data.span_name,
75+
attributes: span_creation_attributes(span_data),
8676
kind: :client
8777
)
8878

@@ -99,12 +89,12 @@ def otel_span_started?
9989

10090
private
10191

102-
def span_creation_attributes(method)
103-
http_method = (method == '_OTHER' ? 'N/A' : method)
92+
def span_creation_attributes(span_data)
10493
instrumentation_attrs = {
105-
'http.method' => http_method,
106-
'http.request.method' => method
94+
'http.method' => span_data.normalized_method,
95+
'http.request.method' => span_data.normalized_method
10796
}
97+
instrumentation_attrs['http.request.method_original'] = span_data.original_method if span_data.original_method
10898

10999
uri = _otel_cleanse_uri(url)
110100
if uri

instrumentation/ethon/lib/opentelemetry/instrumentation/ethon/patches/old/easy.rb

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,11 @@ module Patches
1212
module Old
1313
# Ethon::Easy patch for instrumentation
1414
module Easy
15-
ACTION_NAMES_TO_HTTP_METHODS = Hash.new do |h, k|
16-
# #to_s is required because user input could be symbol or string
17-
h[k] = k.to_s.upcase
18-
end
19-
HTTP_METHODS_TO_SPAN_NAMES = Hash.new { |h, k| h[k] = "HTTP #{k}" }
20-
2115
# Constant for the HTTP status range
2216
HTTP_STATUS_SUCCESS_RANGE = (100..399)
2317

2418
def http_request(url, action_name, options = {})
25-
@otel_method = ACTION_NAMES_TO_HTTP_METHODS[action_name]
19+
@otel_method = action_name
2620
super
2721
end
2822

@@ -73,12 +67,11 @@ def reset
7367
end
7468

7569
def otel_before_request
76-
method = 'N/A' # Could be GET or not HTTP at all
77-
method = @otel_method if instance_variable_defined?(:@otel_method) && !@otel_method.nil?
70+
span_data = HttpHelper.span_attrs_for(@otel_method, semconv: :old)
7871

7972
@otel_span = tracer.start_span(
80-
HTTP_METHODS_TO_SPAN_NAMES[method],
81-
attributes: span_creation_attributes(method),
73+
span_data.span_name,
74+
attributes: span_creation_attributes(span_data),
8275
kind: :client
8376
)
8477

@@ -95,9 +88,9 @@ def otel_span_started?
9588

9689
private
9790

98-
def span_creation_attributes(method)
91+
def span_creation_attributes(span_data)
9992
instrumentation_attrs = {
100-
'http.method' => method
93+
'http.method' => span_data.normalized_method
10194
}
10295

10396
uri = _otel_cleanse_uri(url)

instrumentation/ethon/lib/opentelemetry/instrumentation/ethon/patches/stable/easy.rb

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,11 @@ module Patches
1212
module Stable
1313
# Ethon::Easy patch for instrumentation
1414
module Easy
15-
ACTION_NAMES_TO_HTTP_METHODS = Hash.new do |h, k|
16-
# #to_s is required because user input could be symbol or string
17-
h[k] = k.to_s.upcase
18-
end
19-
HTTP_METHODS_TO_SPAN_NAMES = Hash.new do |h, k|
20-
h[k] = k.to_s
21-
h[k] = 'HTTP' if k == '_OTHER'
22-
end
23-
2415
# Constant for the HTTP status range
2516
HTTP_STATUS_SUCCESS_RANGE = (100..399)
2617

2718
def http_request(url, action_name, options = {})
28-
@otel_method = ACTION_NAMES_TO_HTTP_METHODS[action_name]
19+
@otel_method = action_name
2920
super
3021
end
3122

@@ -76,12 +67,11 @@ def reset
7667
end
7768

7869
def otel_before_request
79-
method = '_OTHER' # Could be GET or not HTTP at all
80-
method = @otel_method if instance_variable_defined?(:@otel_method) && !@otel_method.nil?
70+
span_data = HttpHelper.span_attrs_for(@otel_method)
8171

8272
@otel_span = tracer.start_span(
83-
HTTP_METHODS_TO_SPAN_NAMES[method],
84-
attributes: span_creation_attributes(method),
73+
span_data.span_name,
74+
attributes: span_creation_attributes(span_data),
8575
kind: :client
8676
)
8777

@@ -98,10 +88,11 @@ def otel_span_started?
9888

9989
private
10090

101-
def span_creation_attributes(method)
91+
def span_creation_attributes(span_data)
10292
instrumentation_attrs = {
103-
'http.request.method' => method
93+
'http.request.method' => span_data.normalized_method
10494
}
95+
instrumentation_attrs['http.request.method_original'] = span_data.original_method if span_data.original_method
10596

10697
uri = _otel_cleanse_uri(url)
10798
if uri

instrumentation/ethon/test/opentelemetry/instrumentation/ethon/dup/instrumentation_test.rb

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
easy.perform
7272

7373
_(span.name).must_equal 'HTTP'
74-
_(span.attributes['http.method']).must_equal 'N/A'
74+
_(span.attributes['http.method']).must_equal '_OTHER'
7575
_(span.attributes['http.status_code']).must_be_nil
7676
_(span.attributes['http.url']).must_equal 'http://example.com/test'
7777
_(span.attributes['net.peer.name']).must_equal 'example.com'
@@ -92,7 +92,7 @@
9292
# NOTE: check the finished spans since we expect to have closed it
9393
span = exporter.finished_spans.first
9494
_(span.name).must_equal 'HTTP'
95-
_(span.attributes['http.method']).must_equal 'N/A'
95+
_(span.attributes['http.method']).must_equal '_OTHER'
9696
_(span.attributes['http.status_code']).must_be_nil
9797
_(span.attributes['http.url']).must_equal 'http://example.com/test'
9898
_(span.attributes['http.request.method']).must_equal '_OTHER'
@@ -121,7 +121,7 @@ def stub_response(options)
121121
it 'when response is successful' do
122122
stub_response(response_code: 200) do
123123
_(span.name).must_equal 'HTTP'
124-
_(span.attributes['http.method']).must_equal 'N/A'
124+
_(span.attributes['http.method']).must_equal '_OTHER'
125125
_(span.attributes['http.request.method']).must_equal '_OTHER'
126126
_(span.attributes['http.status_code']).must_equal 200
127127
_(span.attributes['http.response.status_code']).must_equal 200
@@ -137,7 +137,7 @@ def stub_response(options)
137137
it 'when response is not successful' do
138138
stub_response(response_code: 500) do
139139
_(span.name).must_equal 'HTTP'
140-
_(span.attributes['http.method']).must_equal 'N/A'
140+
_(span.attributes['http.method']).must_equal '_OTHER'
141141
_(span.attributes['http.request.method']).must_equal '_OTHER'
142142
_(span.attributes['http.status_code']).must_equal 500
143143
_(span.attributes['http.response.status_code']).must_equal 500
@@ -153,7 +153,7 @@ def stub_response(options)
153153
it 'when request times out' do
154154
stub_response(response_code: 0, return_code: :operation_timedout) do
155155
_(span.name).must_equal 'HTTP'
156-
_(span.attributes['http.method']).must_equal 'N/A'
156+
_(span.attributes['http.method']).must_equal '_OTHER'
157157
_(span.attributes['http.request.method']).must_equal '_OTHER'
158158
_(span.attributes['http.status_code']).must_be_nil
159159
_(span.attributes['http.response.status_code']).must_be_nil
@@ -232,7 +232,7 @@ def stub_response(options)
232232
end
233233

234234
it 'cleans up @otel_method' do
235-
_(easy.instance_eval { @otel_method }).must_equal 'PUT'
235+
_(easy.instance_eval { @otel_method }).must_equal :put
236236

237237
easy.reset
238238

@@ -256,6 +256,32 @@ def stub_response(options)
256256
end
257257
end
258258
end
259+
260+
describe 'with unknown HTTP method' do
261+
def stub_response(options)
262+
easy.stub(:mirror, Ethon::Easy::Mirror.new(options)) do
263+
easy.otel_before_request
264+
# NOTE: perform calls complete
265+
easy.complete
266+
267+
yield
268+
end
269+
end
270+
271+
it 'normalizes unknown HTTP methods' do
272+
easy.http_request('http://example.com/purge', :purge)
273+
274+
stub_response(response_code: 200) do
275+
_(exporter.finished_spans.size).must_equal 1
276+
_(span.name).must_equal 'HTTP'
277+
_(span.attributes['http.method']).must_equal '_OTHER'
278+
_(span.attributes['http.url']).must_equal 'http://example.com/purge'
279+
_(span.attributes['http.request.method']).must_equal '_OTHER'
280+
_(span.attributes['http.request.method_original']).must_equal 'purge'
281+
_(span.attributes['url.full']).must_equal 'http://example.com/purge'
282+
end
283+
end
284+
end
259285
end
260286

261287
describe 'multi' do

0 commit comments

Comments
 (0)