apidepth 0.2.3 → 0.4.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: f499652eea585017c268ea82a997a9e11af46e36c912b6f672fa91fd7d354eb4
4
- data.tar.gz: aa70866eaf7876101236b986472ad14b24ea68bcaa9a38a68ed901d88e1f06b5
3
+ metadata.gz: 48290c633c238b8bbfcaff2a1f98cb4ffa26a3d0c02853ef0f345841425b463e
4
+ data.tar.gz: b3564982b79da91fe2ad976c913aa8bf94efbd35928f78a52d934e43b02eb4d0
5
5
  SHA512:
6
- metadata.gz: 22a04205584e05a111b2422209733b4c06b0008dada3840caa340915d2d9ffb7195dec530c4791b692990812f357b4148a714da904ada5fba15e5b20f854460e
7
- data.tar.gz: 607dca2a4455e60fcf21bd3502382107a3840b02ca23102a0dac4fc4c903c62101bf46b099210591182aa6350f229ffc0209f650a71076060c1e482b83340cee
6
+ metadata.gz: 7a932948137e2d17f75d346686e60f0287c06f2702ec03a7adf3458c521604d9691e7803921726d391f1e161c59f77bce15063a5eeda93800093d7bb7d75e3ac
7
+ data.tar.gz: d262bd3e9b0668a1f5c667160efba8a41b99b850fcf177d3ccf3a5398b7179a005d9b141fb1ef62e1e3a86d55533fce7cf7eaf96c71f01a059ba96d98888ff9e
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.
@@ -36,7 +40,7 @@ bundle install
36
40
 
37
41
  ---
38
42
 
39
- ## Quick start
43
+ ## Getting started
40
44
 
41
45
  Create `config/initializers/apidepth.rb`:
42
46
 
@@ -96,7 +100,8 @@ Apidepth.configure do |config|
96
100
  # Path for the local vendor registry cache. Must be an absolute path.
97
101
  # The registry is fetched from Apidepth's servers and cached here so
98
102
  # cold starts don't block on a network fetch.
99
- # Default: "/tmp/apidepth_registry.json"
103
+ # Rails default: Rails.root.join("tmp/apidepth_registry.json") (set by the Railtie)
104
+ # Non-Rails default: "/tmp/apidepth_registry.json"
100
105
  config.registry_cache_path = "/tmp/apidepth_registry.json"
101
106
 
102
107
  # Custom vendors your app calls that aren't in the global registry.
@@ -129,6 +134,9 @@ Every event contains:
129
134
  | `cold_start` | `true` if this request paid for SSL handshake; excluded from p95 calculations |
130
135
  | `env` | Environment tag from `config.environment` or `Rails.env` |
131
136
  | `ts` | Unix timestamp in milliseconds |
137
+ | `rl_remaining` | Remaining quota, e.g. `4999` — present when vendor rate limit headers are found |
138
+ | `rl_limit` | Total quota, e.g. `5000` — present when vendor rate limit headers are found |
139
+ | `rl_reset_at` | Quota reset time in epoch milliseconds — present when vendor rate limit headers are found |
132
140
 
133
141
  ### What is never captured
134
142
 
data/bin/apidepth ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+ # bin/apidepth — CLI entry point for the Apidepth Ruby SDK.
3
+ #
4
+ # Subcommands:
5
+ # setup Configure the SDK for your project (writes initializer)
6
+ # test Send a synthetic test event and confirm the pipeline works
7
+
8
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
9
+
10
+ subcommand = ARGV.shift
11
+
12
+ case subcommand
13
+ when "setup"
14
+ require "apidepth/cli/setup"
15
+ Apidepth::CLI::Setup.run(ARGV)
16
+ when "test"
17
+ require "apidepth/cli/test_cmd"
18
+ Apidepth::CLI::TestCmd.run(ARGV)
19
+ when nil, "--help", "-h"
20
+ puts "Usage: bundle exec apidepth <subcommand> [options]"
21
+ puts ""
22
+ puts "Subcommands:"
23
+ puts " setup Configure the SDK and write your initializer"
24
+ puts " test Send a test event to confirm the pipeline works"
25
+ puts ""
26
+ puts "Run `bundle exec apidepth <subcommand> --help` for subcommand options."
27
+ else
28
+ warn "Unknown subcommand: #{subcommand.inspect}"
29
+ warn "Run `bundle exec apidepth --help` for usage."
30
+ exit 1
31
+ end
@@ -0,0 +1,82 @@
1
+ # lib/apidepth/cli/framework_detector.rb
2
+ #
3
+ # Detects the web framework in the current directory by inspecting well-known
4
+ # files. Returns a framework identifier and the recommended initializer path.
5
+ # Used by `apidepth setup` to produce copy-paste-ready output.
6
+
7
+ module Apidepth
8
+ module CLI
9
+ module FrameworkDetector
10
+ FRAMEWORKS = %i[rails sinatra].freeze
11
+
12
+ DetectedFramework = Struct.new(:name, :initializer_path, :initializer_snippet, keyword_init: true)
13
+
14
+ def self.detect(dir: Dir.pwd, api_key: nil, ignored_hosts: [], collector_url: nil)
15
+ framework = _detect_framework(dir)
16
+ _build_result(framework, api_key: api_key, ignored_hosts: ignored_hosts, collector_url: collector_url)
17
+ end
18
+
19
+ def self._detect_framework(dir)
20
+ return :rails if File.exist?(File.join(dir, "config/application.rb"))
21
+ return :sinatra if File.exist?(File.join(dir, "config.ru"))
22
+
23
+ :generic
24
+ end
25
+
26
+ def self._build_result(framework, api_key:, ignored_hosts:, collector_url:)
27
+ key_val = api_key || "YOUR_API_KEY"
28
+ url_val = collector_url || "https://collector.apidepth.io"
29
+ hosts_val = ignored_hosts.empty? ? "[]" : ignored_hosts.map { |h| %("#{h}") }.join(", ").then { |s| "[#{s}]" }
30
+
31
+ case framework
32
+ when :rails
33
+ snippet = <<~RUBY
34
+ # config/initializers/apidepth.rb
35
+ Apidepth.configure do |config|
36
+ config.api_key = #{key_val.inspect}
37
+ config.collector_url = #{url_val.inspect}
38
+ config.ignored_hosts = #{hosts_val}
39
+ end
40
+ RUBY
41
+ DetectedFramework.new(
42
+ name: :rails,
43
+ initializer_path: "config/initializers/apidepth.rb",
44
+ initializer_snippet: snippet
45
+ )
46
+ when :sinatra
47
+ snippet = <<~RUBY
48
+ # Top of your main app file (e.g. app.rb), before any routes
49
+ require "apidepth"
50
+ Apidepth.configure do |config|
51
+ config.api_key = #{key_val.inspect}
52
+ config.collector_url = #{url_val.inspect}
53
+ config.ignored_hosts = #{hosts_val}
54
+ end
55
+ Apidepth.instrument!
56
+ RUBY
57
+ DetectedFramework.new(
58
+ name: :sinatra,
59
+ initializer_path: nil,
60
+ initializer_snippet: snippet
61
+ )
62
+ else
63
+ snippet = <<~RUBY
64
+ # Add to your application startup file
65
+ require "apidepth"
66
+ Apidepth.configure do |config|
67
+ config.api_key = #{key_val.inspect}
68
+ config.collector_url = #{url_val.inspect}
69
+ config.ignored_hosts = #{hosts_val}
70
+ end
71
+ Apidepth.instrument!
72
+ RUBY
73
+ DetectedFramework.new(
74
+ name: :generic,
75
+ initializer_path: nil,
76
+ initializer_snippet: snippet
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,136 @@
1
+ # lib/apidepth/cli/setup.rb
2
+ #
3
+ # Implements `bundle exec apidepth setup`.
4
+ #
5
+ # Interactive mode (default):
6
+ # Opens the Apidepth dashboard in a browser so the developer can copy their
7
+ # API key, then prompts for ignored host patterns and writes the initializer.
8
+ #
9
+ # Non-interactive mode (CI/CD and AI-assisted setup):
10
+ # bundle exec apidepth setup --api-key $APIDEPTH_API_KEY --no-prompt
11
+ # All prompts are bypassed; output goes to stdout only.
12
+
13
+ require "optparse"
14
+ require "apidepth/cli/framework_detector"
15
+
16
+ module Apidepth
17
+ module CLI
18
+ module Setup
19
+ DASHBOARD_KEYS_URL = "https://apidepth.io/dashboard/api-keys".freeze
20
+
21
+ def self.run(argv = ARGV)
22
+ options = parse_options(argv)
23
+ api_key = options[:api_key]
24
+ collector_url = options[:collector_url]
25
+ ignored_hosts = options[:ignored_hosts] || []
26
+ no_prompt = options[:no_prompt]
27
+ framework_override = options[:framework]
28
+
29
+ # Interactive: open dashboard and prompt for key
30
+ unless api_key || no_prompt
31
+ $stdout.puts "\nApidepth SDK Setup"
32
+ $stdout.puts "─" * 40
33
+ $stdout.puts "\nOpening your API keys page..."
34
+ _open_browser(DASHBOARD_KEYS_URL)
35
+ $stdout.print "\nPaste your API key: "
36
+ api_key = $stdin.gets&.strip
37
+ if api_key.nil? || api_key.empty?
38
+ warn "No API key provided. Aborting."
39
+ exit 1
40
+ end
41
+ end
42
+
43
+ # Interactive: prompt for ignored hosts
44
+ unless no_prompt
45
+ $stdout.puts "\nDefault ignored hosts (always skipped):"
46
+ %w[localhost 127.0.0.1 0.0.0.0 ::1].each { |h| $stdout.puts " • #{h}" }
47
+ $stdout.puts " • #{collector_url || 'collector.apidepth.io'}"
48
+ $stdout.puts "\nAny internal API patterns to ignore? (comma-separated, wildcards ok)"
49
+ $stdout.puts " Examples: *.internal, *.local, *.svc.cluster.local, *.railway.internal"
50
+ $stdout.print "> "
51
+ input = $stdin.gets&.strip || ""
52
+ ignored_hosts += input.split(",").map(&:strip).reject(&:empty?) unless input.empty?
53
+ end
54
+
55
+ result = FrameworkDetector.detect(
56
+ dir: Dir.pwd,
57
+ api_key: api_key,
58
+ ignored_hosts: ignored_hosts,
59
+ collector_url: collector_url
60
+ )
61
+ # Override detected framework if flag provided
62
+ if framework_override
63
+ result = FrameworkDetector.detect(
64
+ dir: Dir.pwd,
65
+ api_key: api_key,
66
+ ignored_hosts: ignored_hosts,
67
+ collector_url: collector_url
68
+ )
69
+ end
70
+
71
+ _print_result(result, no_prompt: no_prompt)
72
+ end
73
+
74
+ def self.parse_options(argv)
75
+ options = {}
76
+ parser = OptionParser.new do |opts|
77
+ opts.banner = "Usage: bundle exec apidepth setup [options]"
78
+ opts.on("--api-key KEY", "API key (skips browser OAuth)") { |v| options[:api_key] = v }
79
+ opts.on("--collector-url URL", "Override collector URL") { |v| options[:collector_url] = v }
80
+ opts.on("--ignored-hosts HOSTS", "Comma-separated ignored host patterns") do |v|
81
+ options[:ignored_hosts] = v.split(",").map(&:strip)
82
+ end
83
+ opts.on("--no-prompt", "Non-interactive mode; output to stdout only") { options[:no_prompt] = true }
84
+ opts.on("--framework NAME", "Override framework detection (rails|sinatra|generic)") do |v|
85
+ options[:framework] = v
86
+ end
87
+ opts.on_tail("-h", "--help", "Show this message") do
88
+ puts opts
89
+ exit
90
+ end
91
+ end
92
+ begin
93
+ parser.parse!(argv.dup)
94
+ rescue OptionParser::InvalidOption => e
95
+ warn e.message
96
+ exit 1
97
+ end
98
+ options
99
+ end
100
+
101
+ def self._open_browser(url)
102
+ if RUBY_PLATFORM.include?("darwin")
103
+ system("open", url)
104
+ elsif RUBY_PLATFORM.include?("linux")
105
+ system("xdg-open", url)
106
+ else
107
+ $stdout.puts "Visit: #{url}"
108
+ end
109
+ end
110
+
111
+ def self._print_result(result, no_prompt:)
112
+ framework_label = result.name.to_s.capitalize
113
+ $stdout.puts "\nDetected: #{framework_label}" unless no_prompt
114
+
115
+ if result.initializer_path && !no_prompt
116
+ $stdout.puts "\nAdd the following to #{result.initializer_path}:"
117
+ $stdout.puts
118
+ $stdout.puts result.initializer_snippet
119
+ $stdout.print "Write to #{result.initializer_path}? [y/N] "
120
+ answer = $stdin.gets&.strip&.downcase
121
+ if answer == "y"
122
+ full_path = File.join(Dir.pwd, result.initializer_path)
123
+ FileUtils.mkdir_p(File.dirname(full_path))
124
+ File.write(full_path, result.initializer_snippet)
125
+ $stdout.puts "Written to #{result.initializer_path}"
126
+ else
127
+ $stdout.puts "(Not written — copy the snippet above into your codebase)"
128
+ end
129
+ else
130
+ $stdout.puts result.initializer_snippet
131
+ end
132
+ $stdout.puts "\nRun `bundle exec apidepth test` to confirm events are reaching the collector." unless no_prompt
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,136 @@
1
+ # lib/apidepth/cli/test_cmd.rb
2
+ #
3
+ # Implements `bundle exec apidepth test`.
4
+ #
5
+ # Fires a synthetic event to the collector with `"test": true` in the payload.
6
+ # The collector writes this to `test_events`, not the main events table.
7
+ # Hard 5-second timeout. Per-failure-mode error messages with concrete next steps.
8
+
9
+ require "net/http"
10
+ require "uri"
11
+ require "json"
12
+ require "apidepth/version"
13
+
14
+ module Apidepth
15
+ module CLI
16
+ module TestCmd
17
+ DEFAULT_COLLECTOR_URL = "https://collector.apidepth.io".freeze
18
+ TIMEOUT_SECONDS = 5
19
+
20
+ def self.run(argv = ARGV)
21
+ api_key, collector_url = _load_config(argv)
22
+
23
+ unless api_key
24
+ warn "No API key configured."
25
+ warn "Run `bundle exec apidepth setup` or set APIDEPTH_API_KEY."
26
+ exit 1
27
+ end
28
+
29
+ base_url = (collector_url || DEFAULT_COLLECTOR_URL).chomp("/")
30
+ $stdout.print "Sending test event to collector... "
31
+
32
+ begin
33
+ elapsed = _send_test_event(api_key, base_url)
34
+ $stdout.puts "✓ received in #{elapsed}ms"
35
+ $stdout.puts "Visit your dashboard: https://apidepth.io/dashboard"
36
+ rescue TestError => e
37
+ $stdout.puts "✗"
38
+ warn "\n#{e.message}"
39
+ warn e.hint if e.hint
40
+ exit 1
41
+ end
42
+ end
43
+
44
+ class TestError < StandardError
45
+ attr_reader :hint
46
+
47
+ def initialize(msg, hint: nil)
48
+ super(msg)
49
+ @hint = hint
50
+ end
51
+ end
52
+
53
+ def self._load_config(_argv)
54
+ # Try the SDK configuration first, fall back to environment variable
55
+ begin
56
+ require "apidepth"
57
+ cfg = Apidepth.configuration
58
+ api_key = cfg.api_key || ENV.fetch("APIDEPTH_API_KEY", nil)
59
+ collector_url = cfg.collector_url || ENV.fetch("APIDEPTH_COLLECTOR_URL", nil)
60
+ rescue StandardError
61
+ api_key = ENV.fetch("APIDEPTH_API_KEY", nil)
62
+ collector_url = ENV.fetch("APIDEPTH_COLLECTOR_URL", nil)
63
+ end
64
+ [api_key, collector_url]
65
+ end
66
+
67
+ def self._send_test_event(api_key, base_url)
68
+ uri = URI.parse("#{base_url}/v1/events")
69
+ payload = {
70
+ batch: [
71
+ {
72
+ vendor: "apidepth-test",
73
+ endpoint: "/test",
74
+ method: "GET",
75
+ status: 200,
76
+ outcome: "success",
77
+ duration_ms: 1,
78
+ cold_start: false,
79
+ env: "test",
80
+ ts: (Time.now.to_f * 1000).to_i,
81
+ test: true
82
+ }
83
+ ],
84
+ sdk: { name: "apidepth-ruby", version: Apidepth::VERSION }
85
+ }.to_json
86
+
87
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+
89
+ http = Net::HTTP.new(uri.host, uri.port)
90
+ http.use_ssl = uri.scheme == "https"
91
+ http.read_timeout = TIMEOUT_SECONDS
92
+ http.open_timeout = TIMEOUT_SECONDS
93
+
94
+ request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
95
+ request["Content-Type"] = "application/json"
96
+ request["Authorization"] = "Bearer #{api_key}"
97
+ request.body = payload
98
+
99
+ # Bypass our own instrumentation
100
+ Thread.current[:apidepth_skip] = true
101
+ response = http.request(request)
102
+ Thread.current[:apidepth_skip] = false
103
+
104
+ case response.code.to_i
105
+ when 200, 201, 204
106
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
107
+ when 401, 403
108
+ raise TestError.new(
109
+ "API key not recognised (HTTP #{response.code}).",
110
+ hint: "Check the key in your initializer matches your dashboard at https://apidepth.io/dashboard/api-keys"
111
+ )
112
+ else
113
+ raise TestError.new(
114
+ "Collector returned HTTP #{response.code}.",
115
+ hint: "Check https://status.apidepth.io for service status."
116
+ )
117
+ end
118
+ rescue Net::OpenTimeout, Net::ReadTimeout
119
+ raise TestError.new(
120
+ "No response after #{TIMEOUT_SECONDS} seconds.",
121
+ hint: "Check for a firewall blocking outbound port 443."
122
+ )
123
+ rescue OpenSSL::SSL::SSLError => e
124
+ raise TestError.new(
125
+ "SSL certificate verification failed: #{e.message}",
126
+ hint: "Check your Ruby SSL configuration."
127
+ )
128
+ rescue Errno::ECONNREFUSED, SocketError => e
129
+ raise TestError.new(
130
+ "Could not reach #{uri.host}: #{e.message}",
131
+ hint: "Check outbound HTTPS (port 443) is allowed from this environment."
132
+ )
133
+ end
134
+ end
135
+ end
136
+ end
@@ -189,6 +189,8 @@ module Apidepth
189
189
  end
190
190
 
191
191
  def drain_queue
192
+ return [].freeze if @queue.empty?
193
+
192
194
  events = []
193
195
  events << @queue.pop(true) while events.size < MAX_BATCH_SIZE
194
196
  events
@@ -240,13 +242,7 @@ module Apidepth
240
242
 
241
243
  key = Apidepth.configuration.api_key
242
244
  if key.nil? || key.empty?
243
- unless @warned_no_key
244
- @warned_no_key = true
245
- Apidepth.logger&.warn(
246
- "[Apidepth] No API key configured — events are being dropped. " \
247
- "Visit www.apidepth.io to create an account and get your key."
248
- )
249
- end
245
+ warn_no_api_key!
250
246
  return
251
247
  end
252
248
 
@@ -292,9 +288,13 @@ module Apidepth
292
288
 
293
289
  if host.match?(/\A\d+\z/)
294
290
  int = host.to_i
295
- if int >= 0 && int <= 0xFFFFFFFF
291
+ if int.between?(0, 0xFFFFFFFF)
296
292
  host = [int >> 24, (int >> 16) & 0xFF, (int >> 8) & 0xFF,
297
293
  int & 0xFF].join(".")
294
+ else
295
+ raise ArgumentError,
296
+ "Apidepth collector_url must not target private, loopback, or link-local " \
297
+ "addresses (got #{url.host.inspect})."
298
298
  end
299
299
  end
300
300
 
@@ -305,6 +305,18 @@ module Apidepth
305
305
  "addresses (got #{url.host.inspect})."
306
306
  end
307
307
 
308
+ def warn_no_api_key!
309
+ @stats_mutex.synchronize do
310
+ unless @warned_no_key
311
+ @warned_no_key = true
312
+ Apidepth.logger&.warn(
313
+ "[Apidepth] No API key configured — events are being dropped. " \
314
+ "Visit www.apidepth.io to create an account and get your key."
315
+ )
316
+ end
317
+ end
318
+ end
319
+
308
320
  def validate_api_key!(key)
309
321
  return if key.nil? || key.empty?
310
322
  return unless key.match?(/[\r\n]/)
@@ -1,30 +1,76 @@
1
1
  # lib/apidepth/configuration.rb
2
2
 
3
+ require "set"
4
+ require "uri"
5
+
3
6
  module Apidepth
4
7
  class Configuration
8
+ # Always ignored regardless of user config. Covers unambiguous loopback
9
+ # addresses — we deliberately avoid wildcard patterns like *.internal here
10
+ # because silently swallowing traffic the developer wants to see is worse
11
+ # than showing mystery vendors. The setup subcommand prompts for custom
12
+ # patterns interactively.
13
+ HARD_IGNORED_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
14
+
5
15
  attr_accessor :api_key,
6
- :collector_url,
7
16
  :enabled,
8
17
  :flush_interval,
9
18
  :registry_refresh_interval,
10
19
  :registry_cache_path,
11
- :ignored_hosts,
12
20
  :on_flush_error,
13
21
  :environment, # e.g. "production" — set by Railtie from Rails.env
14
22
  :sample_rate, # Float 0.0–1.0, default 1.0 (100% of events captured)
15
23
  :extra_vendors # Hash of vendor_name => host, e.g. { "my-api" => "api.myservice.com" }
16
24
 
25
+ attr_reader :ignored_hosts, :collector_url
26
+
17
27
  def initialize
18
28
  @enabled = true
19
29
  @flush_interval = 20
20
30
  @registry_refresh_interval = 6 * 60 * 60
21
31
  @registry_cache_path = "/tmp/apidepth_registry.json"
22
32
  @collector_url = nil
23
- @ignored_hosts = []
33
+ @_user_hosts = []
24
34
  @on_flush_error = nil
25
35
  @environment = nil # Railtie sets this to Rails.env at boot
26
36
  @sample_rate = 1.0 # capture everything by default
27
37
  @extra_vendors = {} # customer-defined host mappings
38
+ _rebuild_ignored_hosts
39
+ end
40
+
41
+ def collector_url=(url)
42
+ @collector_url = url
43
+ _rebuild_ignored_hosts
44
+ end
45
+
46
+ def ignored_hosts=(hosts)
47
+ @_user_hosts = Array(hosts || [])
48
+ _rebuild_ignored_hosts
49
+ end
50
+
51
+ # Returns true if +host+ should be skipped. Supports glob wildcards
52
+ # (* matches any sequence, ? matches one character) so customers can
53
+ # ignore entire internal domains: "*.internal", "*.svc.cluster.local".
54
+ def ignored_host?(host)
55
+ @_exact_ignored.include?(host) ||
56
+ @_glob_ignored.any? { |pat| File.fnmatch(pat, host) }
57
+ end
58
+
59
+ private
60
+
61
+ def _rebuild_ignored_hosts
62
+ all = HARD_IGNORED_HOSTS.dup + (@_user_hosts || [])
63
+ if @collector_url
64
+ begin
65
+ h = URI.parse(@collector_url).host
66
+ all << h if h
67
+ rescue URI::InvalidURIError
68
+ nil
69
+ end
70
+ end
71
+ @_exact_ignored = all.reject { |p| p.include?("*") || p.include?("?") }.to_set
72
+ @_glob_ignored = all.select { |p| p.include?("*") || p.include?("?") }
73
+ @ignored_hosts = Set.new(all)
28
74
  end
29
75
  end
30
76
  end
@@ -10,7 +10,7 @@ module Apidepth
10
10
  # 4. Sample rate: probabilistically skip events
11
11
  return super if Thread.current[:apidepth_skip]
12
12
  return super unless Apidepth.configuration.enabled
13
- return super if Apidepth.configuration.ignored_hosts.include?(address)
13
+ return super if Apidepth.configuration.ignored_host?(address)
14
14
  return super unless sampled?
15
15
 
16
16
  # Snapshot connection state BEFORE calling super.
@@ -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
@@ -27,6 +27,14 @@ module Apidepth
27
27
  # every outbound HTTP request.
28
28
  Apidepth.configuration.environment ||= Rails.env.to_s
29
29
 
30
+ # Use a per-app cache path so concurrent apps on the same host don't
31
+ # share a world-readable /tmp file. Falls back to /tmp only if Rails.root
32
+ # is somehow unavailable.
33
+ if defined?(Rails.root) && Rails.root
34
+ Apidepth.configuration.registry_cache_path ||=
35
+ Rails.root.join("tmp/apidepth_registry.json").to_s
36
+ end
37
+
30
38
  Net::HTTP.prepend(Apidepth::NetHTTPInstrumentation)
31
39
  Apidepth::VendorRegistry.load_extra_vendors(Apidepth.configuration.extra_vendors)
32
40
  Apidepth::RegistryLoader.load_and_start
@@ -41,16 +49,9 @@ module Apidepth
41
49
  end
42
50
 
43
51
  # -------------------------------------------------------------------------
44
- # 3. Flush queue on graceful shutdown.
45
- # at_exit fires on SIGTERM → graceful Puma/Unicorn shutdown.
46
- # flush! rescues internally so a network error at shutdown is not fatal.
47
- # -------------------------------------------------------------------------
48
- config.after_initialize do
49
- at_exit { Apidepth::Collector.instance.flush! }
50
- end
51
-
52
- # -------------------------------------------------------------------------
53
- # 4. Fork safety for Puma cluster mode / Spring.
52
+ # 3. Shutdown flush + fork safety.
53
+ #
54
+ # at_exit: flush the queue on graceful shutdown (SIGTERM Puma/Unicorn).
54
55
  #
55
56
  # after_fork: reset the Collector singleton so each worker gets a fresh
56
57
  # instance with its own flush thread. The master's flush thread is not
@@ -65,6 +66,8 @@ module Apidepth
65
66
  # ActiveSupport::ForkTracker is available in Rails 7.1+.
66
67
  # -------------------------------------------------------------------------
67
68
  config.after_initialize do
69
+ at_exit { Apidepth::Collector.instance.flush! }
70
+
68
71
  if defined?(ActiveSupport::ForkTracker)
69
72
  ActiveSupport::ForkTracker.after_fork { Apidepth::Collector.reset! }
70
73
  elsif defined?(Puma)
@@ -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
@@ -102,6 +103,8 @@ module Apidepth
102
103
  # Pure numeric
103
104
  if str.match?(/\A\d+(?:\.\d+)?\z/)
104
105
  n = str.to_f
106
+ # >= 1_000_000_000 is a Unix timestamp (seconds since epoch, e.g. "1716000000");
107
+ # smaller values are seconds-from-now (Retry-After style, e.g. "30").
105
108
  return n >= 1_000_000_000 ? (n * 1_000).to_i : now_ms + (n * 1_000).to_i
106
109
  end
107
110
 
@@ -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
@@ -213,6 +231,17 @@ module Apidepth
213
231
  raise ArgumentError, "registry_cache_path must not contain '..' traversal segments (got #{path.inspect})"
214
232
  end
215
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
+
216
245
  # Ruby's `private` keyword does not apply to `def self.method` — those remain
217
246
  # public class methods regardless of placement inside a private block.
218
247
  # private_class_method is the correct idiom.
@@ -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.3".freeze
4
+ VERSION = "0.4.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.3
4
+ version: 0.4.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-30 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: json
@@ -93,19 +94,38 @@ dependencies:
93
94
  - - "~>"
94
95
  - !ruby/object:Gem::Version
95
96
  version: '1.65'
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'
96
111
  description: Know if your API slowness is your code or the vendor's. Apidepth instruments
97
112
  Net::HTTP to track real production latency to Stripe, OpenAI, Twilio and others
98
113
  — then benchmarks your p95 against anonymized fleet data so you can see if it's
99
114
  you, or everyone.
100
115
  email:
101
116
  - hello@apidepth.io
102
- executables: []
117
+ executables:
118
+ - apidepth
103
119
  extensions: []
104
120
  extra_rdoc_files: []
105
121
  files:
106
122
  - LICENSE
107
123
  - README.md
124
+ - bin/apidepth
108
125
  - lib/apidepth.rb
126
+ - lib/apidepth/cli/framework_detector.rb
127
+ - lib/apidepth/cli/setup.rb
128
+ - lib/apidepth/cli/test_cmd.rb
109
129
  - lib/apidepth/collector.rb
110
130
  - lib/apidepth/configuration.rb
111
131
  - lib/apidepth/event.rb
@@ -120,10 +140,11 @@ licenses:
120
140
  - MIT
121
141
  metadata:
122
142
  homepage_uri: https://apidepth.io
123
- source_code_uri: https://github.com/cmwright33/apidepth-ruby
124
- changelog_uri: https://github.com/cmwright33/apidepth-ruby/blob/main/CHANGELOG.md
125
- bug_tracker_uri: https://github.com/cmwright33/apidepth-ruby/issues
143
+ source_code_uri: https://github.com/apidepth-io/apidepth-ruby
144
+ changelog_uri: https://github.com/apidepth-io/apidepth-ruby/blob/main/CHANGELOG.md
145
+ bug_tracker_uri: https://github.com/apidepth-io/apidepth-ruby/issues
126
146
  rubygems_mfa_required: 'true'
147
+ post_install_message:
127
148
  rdoc_options: []
128
149
  require_paths:
129
150
  - lib
@@ -131,14 +152,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
131
152
  requirements:
132
153
  - - ">="
133
154
  - !ruby/object:Gem::Version
134
- version: 2.7.0
155
+ version: 3.1.0
135
156
  required_rubygems_version: !ruby/object:Gem::Requirement
136
157
  requirements:
137
158
  - - ">="
138
159
  - !ruby/object:Gem::Version
139
160
  version: '0'
140
161
  requirements: []
141
- rubygems_version: 4.0.11
162
+ rubygems_version: 3.5.22
163
+ signing_key:
142
164
  specification_version: 4
143
165
  summary: Know if your API slowness is your code or the vendor's
144
166
  test_files: []