apidepth 0.2.2 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1863cc44a0aa90d68dbae4bba31293e4d8154a3a92c1fa0030c3a0002cf5237
4
- data.tar.gz: 881062b88799903817b01067cfad0227499402073cd8e58702de8eb5cbf00f3e
3
+ metadata.gz: ee27b15f831798b976f293eda67c90c9cccab735e75cb82a225d3ea511331622
4
+ data.tar.gz: 2ed0a290e8acab029c51eb12a116d295f8dfaa1390eef934cb61e5f9344b63ce
5
5
  SHA512:
6
- metadata.gz: a3f798621d4d00f8c158ec0ed50b1c00fb8e215da5efaec04fe04a996c29eec97dcf746c355eb16336b98ac46655133f7a16f0c4325cbec7b20e3bff72b37fa3
7
- data.tar.gz: 88da8b454854604520dd0d025eb08ece7da634adb8951f59bc9c6b1421d6440b6ce7a0d9d82413f740025d9df361a80f530c18c8359a7484ca5ce4bf3d24d43e
6
+ metadata.gz: b32de3265dc5a4add529dd10741d21aa935ba18fccf669614fb74b9b06ed5d8125200d0ec26be7cf0c9349b8f6cc9a56d173ed4b2ba89acd551b5684bd411aa8
7
+ data.tar.gz: bf7106cbfdf0a003824b625680a047978f1f8a27c862ce8df52551f316405e47dbd4444e3e64361f35cedf6317f74575a9998ed766afdbdd6459d56a5a719404
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # apidepth
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/apidepth)](https://rubygems.org/gems/apidepth)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-ruby)](https://rubygems.org/gems/apidepth)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
3
7
  Most API monitoring tools measure latency from their servers to the vendor. That's not what your users feel. Apidepth instruments `Net::HTTP` directly — every outbound call your app makes to Stripe, OpenAI, or Twilio is timed at the socket level, from your server. Then it benchmarks your numbers against anonymized fleet data, so when Stripe is slow you can tell if it's you or everyone.
4
8
 
5
9
  No payload capture. No credentials touch our infrastructure. No changes to your application code beyond a one-time initializer.
@@ -92,16 +92,21 @@ module Apidepth
92
92
  end
93
93
  end
94
94
 
95
+ # Canonical test cases live in apidepth-collector/tests/fixtures/private_host_cases.json.
96
+ # All SDK implementations load that fixture and must pass every case. Any change here
97
+ # must be accompanied by a fixture update and a matching change in every other SDK.
95
98
  PRIVATE_HOST_PATTERN = /
96
99
  \Alocalhost\z |
97
100
  \A127\. |
98
101
  \A0\.0\.0\.0\z |
102
+ \A0\z |
103
+
99
104
  \A169\.254\. |
100
105
  \A10\. |
101
106
  \A172\.(1[6-9]|2\d|3[01])\. |
102
107
  \A192\.168\. |
103
108
  \A\[?::1\]?\z |
104
- \A\[?fc |
109
+ \A\[?f[cd] |
105
110
  \A\[?fe80:
106
111
  /xi.freeze
107
112
 
@@ -184,6 +189,8 @@ module Apidepth
184
189
  end
185
190
 
186
191
  def drain_queue
192
+ return [].freeze if @queue.empty?
193
+
187
194
  events = []
188
195
  events << @queue.pop(true) while events.size < MAX_BATCH_SIZE
189
196
  events
@@ -235,13 +242,7 @@ module Apidepth
235
242
 
236
243
  key = Apidepth.configuration.api_key
237
244
  if key.nil? || key.empty?
238
- unless @warned_no_key
239
- @warned_no_key = true
240
- Apidepth.logger&.warn(
241
- "[Apidepth] No API key configured — events are being dropped. " \
242
- "Visit www.apidepth.io to create an account and get your key."
243
- )
244
- end
245
+ warn_no_api_key!
245
246
  return
246
247
  end
247
248
 
@@ -287,7 +288,7 @@ module Apidepth
287
288
 
288
289
  if host.match?(/\A\d+\z/)
289
290
  int = host.to_i
290
- if int.positive? && int <= 0xFFFFFFFF
291
+ if int.between?(0, 0xFFFFFFFF)
291
292
  host = [int >> 24, (int >> 16) & 0xFF, (int >> 8) & 0xFF,
292
293
  int & 0xFF].join(".")
293
294
  end
@@ -300,6 +301,18 @@ module Apidepth
300
301
  "addresses (got #{url.host.inspect})."
301
302
  end
302
303
 
304
+ def warn_no_api_key!
305
+ @stats_mutex.synchronize do
306
+ unless @warned_no_key
307
+ @warned_no_key = true
308
+ Apidepth.logger&.warn(
309
+ "[Apidepth] No API key configured — events are being dropped. " \
310
+ "Visit www.apidepth.io to create an account and get your key."
311
+ )
312
+ end
313
+ end
314
+ end
315
+
303
316
  def validate_api_key!(key)
304
317
  return if key.nil? || key.empty?
305
318
  return unless key.match?(/[\r\n]/)
@@ -88,7 +88,8 @@ module Apidepth
88
88
  }.merge(rl || {})
89
89
  )
90
90
  )
91
- rescue StandardError
91
+ rescue StandardError => e
92
+ Apidepth.logger&.debug("[Apidepth] Instrumentation error: #{e.class}: #{e.message}")
92
93
  nil
93
94
  end
94
95
 
@@ -110,7 +111,8 @@ module Apidepth
110
111
  ts: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
111
112
  )
112
113
  )
113
- rescue StandardError
114
+ rescue StandardError => e
115
+ Apidepth.logger&.debug("[Apidepth] Instrumentation error: #{e.class}: #{e.message}")
114
116
  nil
115
117
  end
116
118
  end
@@ -72,6 +72,7 @@ module Apidepth
72
72
  headers.each do |name|
73
73
  val = response[name]
74
74
  next unless val
75
+ next unless val.strip.match?(/\A\d+\z/)
75
76
 
76
77
  n = val.strip.to_i
77
78
  return n if n >= 0
@@ -12,14 +12,32 @@ module Apidepth
12
12
  # registry (remote → disk cache → bundled baseline already loaded by
13
13
  # VendorRegistry.initialize_registry) and starts the background
14
14
  # refresh thread.
15
+ #
16
+ # Startup strategy: apply the on-disk cache synchronously so the in-process
17
+ # registry is populated immediately (no blocking network call on the boot
18
+ # thread). The initial remote fetch is dispatched to a background thread so
19
+ # that slow or unreachable endpoints (CI, air-gapped environments) do not
20
+ # block Rails boot for up to 8 seconds (open_timeout + read_timeout).
21
+ # The background refresh loop (start_refresh_thread) is unchanged.
15
22
  def self.load_and_start
16
- registry = fetch_remote || load_from_disk
17
- VendorRegistry.replace(registry) if registry
23
+ # Apply disk cache immediately — zero network latency, registry is ready
24
+ # before the application begins serving requests.
25
+ disk_registry = load_from_disk
26
+ VendorRegistry.replace(disk_registry) if disk_registry
27
+
28
+ # Fetch the freshest registry from the network in the background so the
29
+ # boot thread is never blocked by the remote request.
30
+ Thread.new do
31
+ registry = fetch_remote
32
+ VendorRegistry.replace(registry) if registry
33
+ end.tap do |t|
34
+ t.abort_on_exception = false
35
+ t.name = "apidepth-registry-init"
36
+ end
37
+
18
38
  start_refresh_thread
19
39
  end
20
40
 
21
- private
22
-
23
41
  def self.start_refresh_thread
24
42
  Thread.new do
25
43
  loop do
@@ -57,6 +75,11 @@ module Apidepth
57
75
 
58
76
  registry = JSON.parse(res.body)
59
77
 
78
+ # Apply registry-managed customer vendors and emit developer warnings.
79
+ # Must run before replace() so the vendor list is complete when it lands.
80
+ apply_customer_vendors(registry)
81
+ emit_warnings(registry)
82
+
60
83
  # Warm the disk cache so the next cold-start skips the network fetch.
61
84
  begin
62
85
  validate_cache_path!(Apidepth.configuration.registry_cache_path)
@@ -79,6 +102,103 @@ module Apidepth
79
102
  Thread.current[:apidepth_skip] = false
80
103
  end
81
104
 
105
+ # Apply the collector-managed customer_vendors from the registry response.
106
+ #
107
+ # The collector is the source of truth after first declaration. Registry
108
+ # vendors are loaded on top of locally-declared extra_vendors; registry wins
109
+ # on any host conflict. Conflict warnings are emitted once per vendor per
110
+ # process lifetime (see emit_warnings).
111
+ def self.apply_customer_vendors(registry)
112
+ remote = registry["customer_vendors"]
113
+ return unless remote.is_a?(Hash) && !remote.empty?
114
+
115
+ local = Apidepth.configuration.extra_vendors || {}
116
+
117
+ # Filter to string key-value pairs before passing to load_extra_vendors.
118
+ # The server response is trusted but load_extra_vendors calls .to_s on
119
+ # everything — a non-string key like 42 would silently register as "42".
120
+ clean = {}
121
+ remote.each do |name, remote_host|
122
+ next unless name.is_a?(String) && remote_host.is_a?(String)
123
+
124
+ clean[name] = remote_host
125
+ local_host = local[name]
126
+ # Track conflicts before overwriting — emit_warnings reads this later.
127
+ @mutex.synchronize do
128
+ if local_host && local_host != remote_host
129
+ @conflict_vendors ||= {}
130
+ @conflict_vendors[name] = { local: local_host, remote: remote_host }
131
+ end
132
+ end
133
+ end
134
+
135
+ VendorRegistry.load_extra_vendors(clean)
136
+ end
137
+
138
+ # Emit developer-facing warnings from the registry response.
139
+ #
140
+ # Stale vendor warning: vendor exists in registry but no events in 7+ days.
141
+ # Conflict warning: local extra_vendors host differs from registry host.
142
+ #
143
+ # Both follow the warn-once pattern — an instance flag per vendor prevents
144
+ # log spam in long-running processes. Warnings fire on registry fetch, not
145
+ # on every event.
146
+ def self.emit_warnings(registry)
147
+ # Stale vendor warnings — sourced from the registry warnings block.
148
+ # Only present in responses from collector v0.3+; older cached responses skip.
149
+ warnings = registry["warnings"]
150
+ emit_stale_warnings(warnings["stale_vendors"]) if warnings.is_a?(Hash)
151
+
152
+ # Conflict warnings — collected by apply_customer_vendors, emitted here.
153
+ # Fires regardless of whether the registry has a warnings block, so that
154
+ # conflicts detected against a cached/older registry are still surfaced.
155
+ emit_conflict_warnings
156
+ end
157
+
158
+ def self.emit_stale_warnings(stale)
159
+ return unless stale.is_a?(Array)
160
+
161
+ to_warn = []
162
+ @mutex.synchronize do
163
+ @warned_stale ||= {}
164
+ stale.each do |name|
165
+ next unless name.is_a?(String)
166
+ next if @warned_stale[name]
167
+
168
+ @warned_stale[name] = true
169
+ to_warn << name
170
+ end
171
+ end
172
+
173
+ to_warn.each do |name|
174
+ Apidepth.logger&.warn(
175
+ "[Apidepth] No events received from '#{name}' in 7+ days — " \
176
+ "is it still declared in extra_vendors? If intentional, remove " \
177
+ "it at www.apidepth.io."
178
+ )
179
+ end
180
+ end
181
+
182
+ def self.emit_conflict_warnings
183
+ conflicts, = @mutex.synchronize do
184
+ c = @conflict_vendors || {}
185
+ @conflict_vendors = {}
186
+ @warned_conflict ||= {}
187
+ to_warn = c.reject { |name, _| @warned_conflict[name] }
188
+ to_warn.each_key { |name| @warned_conflict[name] = true }
189
+ [to_warn]
190
+ end
191
+
192
+ conflicts.each do |name, hosts|
193
+ Apidepth.logger&.warn(
194
+ "[Apidepth] extra_vendors conflict: '#{name}' is configured as " \
195
+ "'#{hosts[:local]}' locally but the registry has '#{hosts[:remote]}' " \
196
+ "— registry takes precedence. Update your initializer or remove " \
197
+ "the entry from your dashboard at www.apidepth.io."
198
+ )
199
+ end
200
+ end
201
+
82
202
  def self.load_from_disk
83
203
  path = Apidepth.configuration.registry_cache_path
84
204
 
@@ -111,10 +231,27 @@ module Apidepth
111
231
  raise ArgumentError, "registry_cache_path must not contain '..' traversal segments (got #{path.inspect})"
112
232
  end
113
233
 
234
+ # Reset mutable class-level warn state under @mutex.
235
+ # Called by tests instead of raw instance_variable_set so that state
236
+ # changes go through the same lock used in production code paths.
237
+ def self.reset_state!
238
+ @mutex.synchronize do
239
+ @conflict_vendors = {}
240
+ @warned_stale = {}
241
+ @warned_conflict = {}
242
+ end
243
+ end
244
+
114
245
  # Ruby's `private` keyword does not apply to `def self.method` — those remain
115
246
  # public class methods regardless of placement inside a private block.
116
247
  # private_class_method is the correct idiom.
117
248
  private_class_method :start_refresh_thread, :fetch_remote,
118
- :load_from_disk, :validate_cache_path!
249
+ :load_from_disk, :validate_cache_path!,
250
+ :apply_customer_vendors, :emit_warnings,
251
+ :emit_stale_warnings, :emit_conflict_warnings
252
+
253
+ # Mutex protecting @conflict_vendors, @warned_stale, and @warned_conflict.
254
+ # Initialized at require time like VendorRegistry's own @mutex.
255
+ @mutex = Mutex.new
119
256
  end
120
257
  end
@@ -64,6 +64,10 @@ module Apidepth
64
64
  [%r{/[a-z0-9]{24,}}, "/:token"]
65
65
  ].freeze
66
66
 
67
+ # True when the runtime supports Regexp.timeout (introduced in Ruby 3.2).
68
+ # Used by apply_vendor_normalizers to enable ReDoS protection when available.
69
+ RUBY_GTE_3_2 = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
70
+
67
71
  class << self
68
72
  def identify(host, raw_path)
69
73
  hosts, patterns = @mutex.synchronize { [@hosts, @patterns] }
@@ -169,11 +173,28 @@ module Apidepth
169
173
  path.split("?").first
170
174
  end
171
175
 
176
+ # First-match-wins: iteration stops at the first pattern that matches the
177
+ # path. Vendor authors must order rules from most-specific to least-specific
178
+ # to ensure that narrower patterns (e.g. /v1/charges/:id) are tested before
179
+ # broader catch-alls (e.g. /v1/:resource/:id). A less-specific rule placed
180
+ # earlier will shadow any more-specific rules that follow it.
181
+ #
182
+ # ReDoS protection: on Ruby >= 3.2 we apply a per-match timeout of 1ms so
183
+ # that a pathological pattern from a compromised or misconfigured registry
184
+ # cannot stall the request thread indefinitely. On older Ruby, Regexp.timeout
185
+ # is not available — use a trusted, internally-reviewed registry source.
172
186
  def apply_vendor_normalizers(rules, path)
187
+ if RUBY_GTE_3_2
188
+ saved_timeout = Regexp.timeout
189
+ Regexp.timeout = 0.001
190
+ end
191
+
173
192
  rules.each do |pattern, replacement|
174
193
  return path.gsub(pattern, replacement) if path.match?(pattern)
175
194
  end
176
195
  path
196
+ ensure
197
+ Regexp.timeout = saved_timeout if RUBY_GTE_3_2
177
198
  end
178
199
 
179
200
  def apply_generic_normalizers(path)
@@ -1,5 +1,5 @@
1
1
  # lib/apidepth/version.rb
2
2
 
3
3
  module Apidepth
4
- VERSION = "0.2.2".freeze
4
+ VERSION = "0.3.0".freeze
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apidepth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Apidepth
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: json
@@ -93,8 +94,21 @@ dependencies:
93
94
  - - "~>"
94
95
  - !ruby/object:Gem::Version
95
96
  version: '1.65'
96
- description: |
97
- Know if your API slowness is your code or the vendor's. Apidepth instruments
97
+ - !ruby/object:Gem::Dependency
98
+ name: simplecov
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.22'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.22'
111
+ description: Know if your API slowness is your code or the vendor's. Apidepth instruments
98
112
  Net::HTTP to track real production latency to Stripe, OpenAI, Twilio and others
99
113
  — then benchmarks your p95 against anonymized fleet data so you can see if it's
100
114
  you, or everyone.
@@ -121,10 +135,11 @@ licenses:
121
135
  - MIT
122
136
  metadata:
123
137
  homepage_uri: https://apidepth.io
124
- source_code_uri: https://github.com/apidepth/apidepth-ruby
125
- changelog_uri: https://github.com/apidepth/apidepth-ruby/blob/main/CHANGELOG.md
126
- bug_tracker_uri: https://github.com/apidepth/apidepth-ruby/issues
138
+ source_code_uri: https://github.com/apidepth-io/apidepth-ruby
139
+ changelog_uri: https://github.com/apidepth-io/apidepth-ruby/blob/main/CHANGELOG.md
140
+ bug_tracker_uri: https://github.com/apidepth-io/apidepth-ruby/issues
127
141
  rubygems_mfa_required: 'true'
142
+ post_install_message:
128
143
  rdoc_options: []
129
144
  require_paths:
130
145
  - lib
@@ -139,7 +154,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
154
  - !ruby/object:Gem::Version
140
155
  version: '0'
141
156
  requirements: []
142
- rubygems_version: 4.0.11
157
+ rubygems_version: 3.5.22
158
+ signing_key:
143
159
  specification_version: 4
144
160
  summary: Know if your API slowness is your code or the vendor's
145
161
  test_files: []