http_instrumentation 1.0.1 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1457b4d97046d8c0a41e99aa0825386e3540eb67ac5603fb0ecc80e2fca77e92
4
- data.tar.gz: 78b242f2f2821f22d8abddd754e641a271232da63e8218b2492ecd0907e43eca
3
+ metadata.gz: 2deee15a4609f8d50a706613af0805d53bc54beaf3e17f65b68f17c17453fed9
4
+ data.tar.gz: a3a76bc88fe4a5b799b59a807ac665dcd4b6f52f280236f7b8b804425a01846f
5
5
  SHA512:
6
- metadata.gz: 6aab6fde374e4f00c40509e32950fc8d3ab3b18666a8c5be593f1fc2d58a1907ab19ea1ba2a309d1728a6c3611aaeae54f8873a2e25b7450a55dbe9952358811
7
- data.tar.gz: a22576c8eb6253c18c3705211c09764df2c1221c65797fcb944c8f3a62f31c7b7f5f6aec1abe607393c98ebd9bc90792662e609673a1f4bdea86f3b33030e132
6
+ metadata.gz: 932361a2de24b64de68929b61379e1d8f01acce64f3f03070070ccfb9da8a369329c7703f84f774e236c69a2343dde4c5a6add5fd21add2db9025cdde3515a7b
7
+ data.tar.gz: fa77a297e489a26894c6f49961a5f057bfb239385a93d96b0252892db411922e9e2c75d0abb9de663fe059d883d9e07f06a043ae27b7704a4e868c97a49d4eb8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.0.2
8
+
9
+ ### Fixed
10
+ - Requiring the gem now loads the ActiveSupport framework itself instead of just `active_support/notifications`, which is not self-sufficient on ActiveSupport 7+ and raised `NameError: uninitialized constant ActiveSupport::IsolatedExecutionState` on the first request if the host application had not already loaded ActiveSupport.
11
+ - The httpclient and excon instrumentation now forward blocks passed to the instrumented methods, so streaming response blocks are no longer silently ignored when the aliasing strategy is used.
12
+ - The excon instrumentation now encodes query parameters passed as a hash when building the URL for the event payload, so `access_token` parameters are properly stripped from the `:uri` payload value instead of leaking through in unparseable form.
13
+ - The aliased flag on instrumentation modules is now set before the instrumented method is swapped in, so a request on another thread can no longer hit a window during installation where it would raise `NoMethodError`.
14
+ - Removed the nonexistent `:net_http2` entry from `HTTPInstrumentation::IMPLEMENTATIONS`; no hook for it was ever implemented.
15
+ - `HTTPInstrumentation.initialize!` now accepts a single symbol for the `only` option instead of raising `NoMethodError`.
16
+ - The `except` option to `HTTPInstrumentation.initialize!` now excludes the listed libraries instead of instrumenting only those libraries.
17
+ - Requests made with relative URLs (e.g. a patron session with a `base_url`) no longer raise `URI::InvalidURIError` from the instrumentation after the request completes.
18
+ - Installing the instrumentation is now protected by a mutex so concurrent calls to `initialize!` cannot install the aliased methods twice, which would have caused infinite recursion on subsequent requests.
19
+ - The excon instrumentation now reports per-request values (path, query, etc.) instead of letting the connection's defaults override them in the event payload.
20
+ - The event payload now always includes the `:count` key even when the request raises an error.
21
+ - The net/http instrumentation no longer lets an error raised while building the event payload escape to the caller, matching the behavior of the other hooks.
22
+ - The ethon instrumentation clears the request info captured from `http_request` after each `perform`, so reusing an easy handle without going through `http_request` no longer reports the previous request's method and URL. The URL set directly on the handle is now also reported as a fallback.
23
+ - The `:url` payload value no longer includes an explicit port for URLs using the default port for their scheme.
24
+
25
+ ### Changed
26
+ - The minimum required Ruby version has been updated to 2.6.
27
+
7
28
  ## 1.0.1
8
29
 
9
30
  ### Fixed
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Continuous Integration](https://github.com/bdurand/http_instrumentation/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/http_instrumentation/actions/workflows/continuous_integration.yml)
4
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+ [![Gem Version](https://badge.fury.io/rb/http_instrumentation.svg)](https://badge.fury.io/rb/http_instrumentation)
5
6
 
6
7
  This gem adds instrumentation to a variety of the most commonly used Ruby HTTP client libraries via ActiveSupport notifications. The goal is to add a common instrumentation interface across all the HTTP client libraries used by an application (including ones installed as dependencies of other gems).
7
8
 
@@ -19,6 +20,8 @@ This gem adds instrumentation to a variety of the most commonly used Ruby HTTP c
19
20
 
20
21
  Note that several other popular HTTP client libraries like [Faraday](https://github.com/lostisland/faraday), [HTTParty](https://github.com/jnunemaker/httparty), and [RestClient](https://github.com/rest-client/rest-client) are built on top of these low level libraries.
21
22
 
23
+ A few code paths are not instrumented: curb requests that don't go through `Curl::Easy#http` (e.g. the native `http_get` and `http_post` helpers) and httpclient streaming responses.
24
+
22
25
  ## Usage
23
26
 
24
27
  To capture information about HTTP requests, simply subscribe to the `request.http` events with [ActiveSupport notifications](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) (note that you should really use `monotonic_subscribe` instead of `subscribe` to avoid issues with clock adjustments).
@@ -63,6 +66,17 @@ HTTPX.get("https://example.com/r1", "https://example.com/r2")
63
66
  # => HTTP request: client: httpx, count: 2, duration: 150ms
64
67
  ```
65
68
 
69
+ ### Instrumenting Libraries Loaded Later
70
+
71
+ All supported libraries that have already been loaded are automatically instrumented when this gem is required. Instrumentation cannot be removed once it has been installed.
72
+
73
+ If an HTTP client library is loaded after this gem, you can call `HTTPInstrumentation.initialize!` again to instrument it. The `only` and `except` options can be used to limit which libraries are instrumented.
74
+
75
+ ```ruby
76
+ require "httpx"
77
+ HTTPInstrumentation.initialize!(only: [:httpx])
78
+ ```
79
+
66
80
  ### Security
67
81
 
68
82
  The `:uri` element in the event payload will be sanitized to remove any user/password elements encoded in the URL as well as any `access_token` query parameters.
@@ -115,7 +129,7 @@ You can also take advantage of the existing instrumentation and just override th
115
129
  ```ruby
116
130
  class MyHttpClient
117
131
  def get(url)
118
- HTTPInstrumentation.client("my_client")
132
+ HTTPInstrumentation.client("my_client") do
119
133
  Net::HTTP.get(URI(url))
120
134
  end
121
135
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.1
1
+ 1.0.2
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.require_paths = ["lib"]
31
31
 
32
- spec.required_ruby_version = ">= 2.5"
32
+ spec.required_ruby_version = ">= 2.6"
33
33
 
34
34
  spec.add_dependency "activesupport", ">= 4.2"
35
35
 
@@ -61,14 +61,23 @@ module HTTPInstrumentation
61
61
 
62
62
  def perform(*args)
63
63
  HTTPInstrumentation.instrument("ethon") do |payload|
64
+ payload[:http_method] = @http_instrumentation_method
65
+ begin
66
+ payload[:url] = (@http_instrumentation_url || url)
67
+ rescue
68
+ payload[:url] = @http_instrumentation_url
69
+ end
70
+ # Clear the request info so it isn't reported again if the handle
71
+ # is reused without going through http_request.
72
+ @http_instrumentation_method = nil
73
+ @http_instrumentation_url = nil
74
+
64
75
  retval = if HTTPInstrumentation::Instrumentation::EthonHook::Easy.aliased
65
76
  perform_without_http_instrumentation(*args)
66
77
  else
67
78
  super
68
79
  end
69
80
 
70
- payload[:http_method] = @http_instrumentation_method
71
- payload[:url] = @http_instrumentation_url
72
81
  begin
73
82
  payload[:status_code] = response_code
74
83
  rescue
@@ -21,27 +21,31 @@ module HTTPInstrumentation
21
21
  attr_accessor :aliased
22
22
  end
23
23
 
24
- def request(params = {})
24
+ def request(params = {}, &block)
25
25
  HTTPInstrumentation.instrument("excon") do |payload|
26
26
  response = if HTTPInstrumentation::Instrumentation::ExconHook.aliased
27
- request_without_http_instrumentation(params)
27
+ request_without_http_instrumentation(params, &block)
28
28
  else
29
29
  super
30
30
  end
31
31
 
32
32
  begin
33
33
  info = params
34
+ # Merge connection defaults under the per-request params so the
35
+ # request values win.
34
36
  if respond_to?(:connection)
35
- info = info.merge(connection)
37
+ info = connection.merge(params)
36
38
  elsif respond_to?(:data)
37
- info = info.merge(data)
39
+ info = data.merge(params)
38
40
  end
39
41
 
40
42
  scheme = info[:scheme]&.downcase
41
43
  default_port = ((scheme == "https") ? 443 : 80)
42
44
  port = info[:port]
45
+ query = info[:query]
46
+ query = URI.encode_www_form(query) if query.is_a?(Hash)
43
47
  payload[:http_method] = (info[:http_method] || info[:method])
44
- payload[:url] = "#{scheme}://#{info[:host]}#{":#{port}" unless port == default_port}#{info[:path]}#{"?#{info[:query]}" unless info[:query].to_s.empty?}"
48
+ payload[:url] = "#{scheme}://#{info[:host]}#{":#{port}" unless port == default_port}#{info[:path]}#{"?#{query}" unless query.to_s.empty?}"
45
49
  payload[:status_code] = response.status
46
50
  rescue
47
51
  end
@@ -21,10 +21,10 @@ module HTTPInstrumentation
21
21
  attr_accessor :aliased
22
22
  end
23
23
 
24
- def do_get_block(request, *args)
24
+ def do_get_block(request, *args, &block)
25
25
  HTTPInstrumentation.instrument("httpclient") do |payload|
26
26
  response = if HTTPInstrumentation::Instrumentation::HTTPClientHook.aliased
27
- do_get_block_without_http_instrumentation(request, *args)
27
+ do_get_block_without_http_instrumentation(request, *args, &block)
28
28
  else
29
29
  super
30
30
  end
@@ -37,12 +37,17 @@ module HTTPInstrumentation
37
37
  super
38
38
  end
39
39
 
40
- default_port = (use_ssl? ? 443 : 80)
41
- scheme = (use_ssl? ? "https" : "http")
42
- url = "#{scheme}://#{address}#{":#{port}" unless port == default_port}#{req.path}"
43
- payload[:http_method] = req.method
44
- payload[:url] = url
45
- payload[:status_code] = response.code
40
+ begin
41
+ default_port = (use_ssl? ? 443 : 80)
42
+ scheme = (use_ssl? ? "https" : "http")
43
+ url = "#{scheme}://#{address}#{":#{port}" unless port == default_port}#{req.path}"
44
+ payload[:http_method] = req.method
45
+ payload[:url] = url
46
+ payload[:status_code] = response.code
47
+ rescue
48
+ # Instrumentation must never break the request; if the payload can't
49
+ # be built, return the response without the extra fields.
50
+ end
46
51
 
47
52
  response
48
53
  end
@@ -12,6 +12,12 @@ require_relative "instrumentation/typhoeus_hook"
12
12
 
13
13
  module HTTPInstrumentation
14
14
  module Instrumentation
15
+ # Lock to prevent concurrent calls from installing the same hooks twice;
16
+ # double aliasing would point the _without_http_instrumentation method at
17
+ # the instrumented method itself, causing infinite recursion.
18
+ INSTRUMENT_LOCK = Mutex.new
19
+ private_constant :INSTRUMENT_LOCK
20
+
15
21
  class << self
16
22
  # Helper method to add an instrumentation module to methods on a class. The
17
23
  # methods must be defined in the instrumentation module.
@@ -23,43 +29,64 @@ module HTTPInstrumentation
23
29
  # may have already prepended the methods. Aliasing is the default strategy because
24
30
  # prepending after aliasing will work, but aliasing after prepending will not.
25
31
  def instrument!(klass, instrumentation_module, methods)
26
- return if klass.include?(instrumentation_module)
32
+ INSTRUMENT_LOCK.synchronize do
33
+ return if klass.include?(instrumentation_module)
27
34
 
28
- methods = Array(methods).collect(&:to_sym)
35
+ methods = Array(methods).collect(&:to_sym)
29
36
 
30
- if HTTPInstrumentation.force_prepend? || methods_prepended?(klass, methods)
31
- klass.prepend(instrumentation_module)
32
- instrumentation_module.aliased = false
33
- else
34
- Array(methods).each do |method|
35
- instrumentation_module.alias_method("#{method}_with_http_instrumentation", method)
36
- end
37
+ if HTTPInstrumentation.force_prepend? || methods_defined_in_module?(klass, methods)
38
+ klass.prepend(instrumentation_module)
39
+ instrumentation_module.aliased = false
40
+ else
41
+ methods.each do |method|
42
+ instrumentation_module.alias_method("#{method}_with_http_instrumentation", method)
43
+ end
37
44
 
38
- klass.include(instrumentation_module)
45
+ klass.include(instrumentation_module)
39
46
 
40
- Array(methods).each do |method|
41
- klass.alias_method("#{method}_without_http_instrumentation", method)
42
- klass.alias_method(method, "#{method}_with_http_instrumentation")
43
- end
47
+ swap_methods = methods.reject { |method| defines_method?(klass, "#{method}_without_http_instrumentation") }
48
+
49
+ swap_methods.each do |method|
50
+ klass.alias_method("#{method}_without_http_instrumentation", method)
51
+ end
44
52
 
45
- instrumentation_module.aliased = true
53
+ # The aliased flag must be set before the instrumented methods are
54
+ # swapped in; a call landing in between would take the super branch
55
+ # and raise NoMethodError since the module was included, not prepended.
56
+ instrumentation_module.aliased = true
57
+
58
+ swap_methods.each do |method|
59
+ klass.alias_method(method, "#{method}_with_http_instrumentation")
60
+ end
61
+ end
46
62
  end
47
63
  end
48
64
 
49
65
  private
50
66
 
51
- def methods_prepended?(klass, methods)
52
- prepended = false
67
+ # Returns true if any of the methods are defined in a module in the class's
68
+ # ancestry rather than directly on the class itself. This covers both modules
69
+ # prepended in front of the class and modules included behind it. In either
70
+ # case the aliasing strategy cannot be used: including the instrumentation
71
+ # module would insert it ahead of the module that defines the method, so
72
+ # aliasing the method afterward would capture the instrumented method itself
73
+ # and cause infinite recursion.
74
+ def methods_defined_in_module?(klass, methods)
75
+ defined_in_module = false
53
76
 
54
77
  klass.ancestors.each do |mod|
55
78
  next unless mod.is_a?(Module) && !mod.is_a?(Class)
56
79
 
57
80
  module_methods = mod.instance_methods(false) + mod.private_instance_methods(false)
58
- prepended = (module_methods & methods).any?
59
- break if prepended
81
+ defined_in_module = (module_methods & methods).any?
82
+ break if defined_in_module
60
83
  end
61
84
 
62
- prepended
85
+ defined_in_module
86
+ end
87
+
88
+ def defines_method?(klass, method)
89
+ klass.method_defined?(method) || klass.private_method_defined?(method)
63
90
  end
64
91
  end
65
92
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
3
4
  require "active_support/notifications"
4
5
 
5
6
  require_relative "http_instrumentation/instrumentation"
@@ -13,7 +14,6 @@ module HTTPInstrumentation
13
14
  :httpclient,
14
15
  :httpx,
15
16
  :net_http,
16
- :net_http2,
17
17
  :patron,
18
18
  :typhoeus
19
19
  ].freeze
@@ -29,8 +29,8 @@ module HTTPInstrumentation
29
29
  # @param except [Array<Symbol>] List of libraries to not instrument.
30
30
  # @return [void]
31
31
  def initialize!(only: nil, except: nil)
32
- list = (only || IMPLEMENTATIONS)
33
- list &= Array(except) if except
32
+ list = Array(only || IMPLEMENTATIONS)
33
+ list -= Array(except) if except
34
34
 
35
35
  Instrumentation::CurbHook.instrument! if list.include?(:curb)
36
36
  Instrumentation::EthonHook.instrument! if list.include?(:ethon)
@@ -45,6 +45,9 @@ module HTTPInstrumentation
45
45
 
46
46
  # Silence instrumentation for the duration of the block.
47
47
  #
48
+ # Note that silencing is stored in fiber-local storage, so it does not
49
+ # propagate into other threads or fibers spawned within the block.
50
+ #
48
51
  # @return [Object] the return value of the block
49
52
  def silence(&block)
50
53
  save_val = Thread.current[:http_instrumentation_silence]
@@ -101,25 +104,7 @@ module HTTPInstrumentation
101
104
  return yield(payload) if silenced?
102
105
 
103
106
  ActiveSupport::Notifications.instrument(EVENT, payload) do
104
- retval = silence { yield(payload) }
105
-
106
- payload[:http_method] = normalize_http_method(payload[:http_method]) if payload.include?(:http_method)
107
-
108
- if payload.include?(:url)
109
- uri = sanitized_uri(payload[:url])
110
- if uri
111
- payload[:url] = uri_without_query_string(uri)
112
- payload[:uri] = uri
113
- else
114
- payload[:url] = payload[:url]&.to_s
115
- end
116
- end
117
-
118
- payload[:status_code] = payload[:status_code]&.to_i if payload.include?(:status_code)
119
-
120
- payload[:count] = (payload.include?(:count) ? payload[:count].to_i : 1)
121
-
122
- retval
107
+ instrument_request(payload, &block)
123
108
  end
124
109
  end
125
110
 
@@ -133,6 +118,29 @@ module HTTPInstrumentation
133
118
 
134
119
  private
135
120
 
121
+ # Yield the block with instrumentation silenced and then normalize the
122
+ # payload. The payload is normalized even if the block raises an error so
123
+ # that events for failed requests still include the normalized keys.
124
+ def instrument_request(payload)
125
+ silence { yield(payload) }
126
+ ensure
127
+ payload[:http_method] = normalize_http_method(payload[:http_method]) if payload.include?(:http_method)
128
+
129
+ if payload.include?(:url)
130
+ uri = sanitized_uri(payload[:url])
131
+ if uri
132
+ payload[:url] = uri_without_query_string(uri)
133
+ payload[:uri] = uri
134
+ else
135
+ payload[:url] = payload[:url]&.to_s
136
+ end
137
+ end
138
+
139
+ payload[:status_code] = payload[:status_code]&.to_i if payload.include?(:status_code)
140
+
141
+ payload[:count] = (payload.include?(:count) ? payload[:count].to_i : 1)
142
+ end
143
+
136
144
  # Turn the given value into a lowercase symbol.
137
145
  def normalize_http_method(method)
138
146
  return nil if method.nil?
@@ -171,7 +179,10 @@ module HTTPInstrumentation
171
179
  end
172
180
 
173
181
  def uri_without_query_string(uri)
174
- URI("#{uri.scheme}://#{uri.host}:#{uri.port}#{uri.path}").to_s
182
+ stripped = uri.dup
183
+ stripped.query = nil if stripped.respond_to?(:query=)
184
+ stripped.fragment = nil if stripped.respond_to?(:fragment=)
185
+ stripped.to_s
175
186
  end
176
187
  end
177
188
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http_instrumentation
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2023-08-08 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -38,7 +37,6 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
- description:
42
40
  email:
43
41
  - bbdurand@gmail.com
44
42
  executables: []
@@ -65,7 +63,6 @@ homepage: https://github.com/bdurand/http_instrumentation
65
63
  licenses:
66
64
  - MIT
67
65
  metadata: {}
68
- post_install_message:
69
66
  rdoc_options: []
70
67
  require_paths:
71
68
  - lib
@@ -73,15 +70,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
73
70
  requirements:
74
71
  - - ">="
75
72
  - !ruby/object:Gem::Version
76
- version: '2.5'
73
+ version: '2.6'
77
74
  required_rubygems_version: !ruby/object:Gem::Requirement
78
75
  requirements:
79
76
  - - ">="
80
77
  - !ruby/object:Gem::Version
81
78
  version: '0'
82
79
  requirements: []
83
- rubygems_version: 3.2.22
84
- signing_key:
80
+ rubygems_version: 4.0.3
85
81
  specification_version: 4
86
82
  summary: ActiveSupoprt instrumentation for a variety of Ruby HTTP client libraries.
87
83
  test_files: []