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 +4 -4
- data/README.md +4 -0
- data/lib/apidepth/collector.rb +22 -9
- data/lib/apidepth/net_http_instrumentation.rb +4 -2
- data/lib/apidepth/rate_limit_headers.rb +1 -0
- data/lib/apidepth/registry_loader.rb +142 -5
- data/lib/apidepth/vendor_registry.rb +21 -0
- data/lib/apidepth/version.rb +1 -1
- metadata +24 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee27b15f831798b976f293eda67c90c9cccab735e75cb82a225d3ea511331622
|
|
4
|
+
data.tar.gz: 2ed0a290e8acab029c51eb12a116d295f8dfaa1390eef934cb61e5f9344b63ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b32de3265dc5a4add529dd10741d21aa935ba18fccf669614fb74b9b06ed5d8125200d0ec26be7cf0c9349b8f6cc9a56d173ed4b2ba89acd551b5684bd411aa8
|
|
7
|
+
data.tar.gz: bf7106cbfdf0a003824b625680a047978f1f8a27c862ce8df52551f316405e47dbd4444e3e64361f35cedf6317f74575a9998ed766afdbdd6459d56a5a719404
|
data/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# apidepth
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/apidepth)
|
|
4
|
+
[](https://rubygems.org/gems/apidepth)
|
|
5
|
+
[](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.
|
data/lib/apidepth/collector.rb
CHANGED
|
@@ -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\[?
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
17
|
-
|
|
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)
|
data/lib/apidepth/version.rb
CHANGED
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.
|
|
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:
|
|
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
|
-
|
|
97
|
-
|
|
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:
|
|
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: []
|