apidepth 0.3.0 → 0.5.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: ee27b15f831798b976f293eda67c90c9cccab735e75cb82a225d3ea511331622
4
- data.tar.gz: 2ed0a290e8acab029c51eb12a116d295f8dfaa1390eef934cb61e5f9344b63ce
3
+ metadata.gz: d59e0f9e95678afd4a0f9ce173de1b63ac893ab01cbc68fc1d6bbcedc44bfaad
4
+ data.tar.gz: 29524e4a1687cafb241caa6c07dbb2c1139ef2e44bc92f8ded25dc15c1305a92
5
5
  SHA512:
6
- metadata.gz: b32de3265dc5a4add529dd10741d21aa935ba18fccf669614fb74b9b06ed5d8125200d0ec26be7cf0c9349b8f6cc9a56d173ed4b2ba89acd551b5684bd411aa8
7
- data.tar.gz: bf7106cbfdf0a003824b625680a047978f1f8a27c862ce8df52551f316405e47dbd4444e3e64361f35cedf6317f74575a9998ed766afdbdd6459d56a5a719404
6
+ metadata.gz: a4a88ac5df80e6af2987cfa29d354c67c5275c02e30caacbae086558d2a0ee1fe1304a42c11b679b2ec6a1358a1cd0e69c3969a5d9d400c246723592df448599
7
+ data.tar.gz: 2005a0c7164ed0b20da0e1013cdc250be7d09f66fdfdd65f465168f38f4a105bfa6370e3a98bef015502f7cb2ba961a99189a6232e0098eb47ca11c588d90ed4
data/README.md CHANGED
@@ -40,7 +40,7 @@ bundle install
40
40
 
41
41
  ---
42
42
 
43
- ## Quick start
43
+ ## Getting started
44
44
 
45
45
  Create `config/initializers/apidepth.rb`:
46
46
 
@@ -56,6 +56,46 @@ Get your API key at [apidepth.io](https://apidepth.io).
56
56
 
57
57
  ---
58
58
 
59
+ ## CLI
60
+
61
+ The gem ships two subcommands for setup and connectivity verification.
62
+
63
+ ### `bundle exec apidepth setup`
64
+
65
+ Interactive wizard that detects your framework (Rails, Sinatra, or generic), generates the correct initializer snippet, and optionally writes it to disk.
66
+
67
+ ```bash
68
+ bundle exec apidepth setup
69
+ ```
70
+
71
+ For CI/CD pipelines, skip all prompts:
72
+
73
+ ```bash
74
+ bundle exec apidepth setup --api-key $APIDEPTH_API_KEY --no-prompt
75
+ ```
76
+
77
+ | Flag | Description |
78
+ |---|---|
79
+ | `--api-key <key>` | Inject your API key into the generated snippet. |
80
+ | `--no-prompt` | Non-interactive mode — print snippet to stdout and exit. |
81
+ | `--framework <name>` | Override auto-detection (`rails`, `sinatra`, `generic`). |
82
+ | `--ignored-hosts <patterns>` | Comma-separated host patterns to add to `ignored_hosts` (glob wildcards supported). |
83
+ | `--collector-url <url>` | Override the collector URL in the generated snippet. |
84
+
85
+ ### `bundle exec apidepth test`
86
+
87
+ Sends a synthetic test event to the collector and confirms the pipeline is working end-to-end. Reads `APIDEPTH_API_KEY` (and optionally `APIDEPTH_COLLECTOR_URL`) from the environment. Prints the round-trip time on success, or a per-failure-mode error message with next steps on failure.
88
+
89
+ ```bash
90
+ bundle exec apidepth test
91
+ # ✓ received in 142ms
92
+ # Visit your dashboard: https://apidepth.io/dashboard
93
+ ```
94
+
95
+ Exits with code 1 on any error (bad key, unreachable, SSL failure, timeout).
96
+
97
+ ---
98
+
59
99
  ## Configuration
60
100
 
61
101
  All options with their defaults:
@@ -100,7 +140,8 @@ Apidepth.configure do |config|
100
140
  # Path for the local vendor registry cache. Must be an absolute path.
101
141
  # The registry is fetched from Apidepth's servers and cached here so
102
142
  # cold starts don't block on a network fetch.
103
- # Default: "/tmp/apidepth_registry.json"
143
+ # Rails default: Rails.root.join("tmp/apidepth_registry.json") (set by the Railtie)
144
+ # Non-Rails default: "/tmp/apidepth_registry.json"
104
145
  config.registry_cache_path = "/tmp/apidepth_registry.json"
105
146
 
106
147
  # Custom vendors your app calls that aren't in the global registry.
@@ -133,6 +174,9 @@ Every event contains:
133
174
  | `cold_start` | `true` if this request paid for SSL handshake; excluded from p95 calculations |
134
175
  | `env` | Environment tag from `config.environment` or `Rails.env` |
135
176
  | `ts` | Unix timestamp in milliseconds |
177
+ | `rl_remaining` | Remaining quota, e.g. `4999` — present when vendor rate limit headers are found |
178
+ | `rl_limit` | Total quota, e.g. `5000` — present when vendor rate limit headers are found |
179
+ | `rl_reset_at` | Quota reset time in epoch milliseconds — present when vendor rate limit headers are found |
136
180
 
137
181
  ### What is never captured
138
182
 
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
@@ -291,6 +291,10 @@ module Apidepth
291
291
  if int.between?(0, 0xFFFFFFFF)
292
292
  host = [int >> 24, (int >> 16) & 0xFF, (int >> 8) & 0xFF,
293
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})."
294
298
  end
295
299
  end
296
300
 
@@ -1,18 +1,29 @@
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
- :environment, # e.g. "production" — set by Railtie from Rails.env
14
- :sample_rate, # Float 0.0–1.0, default 1.0 (100% of events captured)
15
- :extra_vendors # Hash of vendor_name => host, e.g. { "my-api" => "api.myservice.com" }
21
+ :environment, # e.g. "production" — set by Railtie from Rails.env
22
+ :sample_rate, # Float 0.0–1.0, default 1.0 (100% of events captured)
23
+ :extra_vendors, # Hash of vendor_name => host, e.g. { "my-api" => "api.myservice.com" }
24
+ :capture_model_names # Boolean — read model field from AI vendor JSON responses
25
+
26
+ attr_reader :ignored_hosts, :collector_url
16
27
 
17
28
  def initialize
18
29
  @enabled = true
@@ -20,11 +31,48 @@ module Apidepth
20
31
  @registry_refresh_interval = 6 * 60 * 60
21
32
  @registry_cache_path = "/tmp/apidepth_registry.json"
22
33
  @collector_url = nil
23
- @ignored_hosts = []
34
+ @_user_hosts = []
24
35
  @on_flush_error = nil
25
36
  @environment = nil # Railtie sets this to Rails.env at boot
26
37
  @sample_rate = 1.0 # capture everything by default
27
38
  @extra_vendors = {} # customer-defined host mappings
39
+ @capture_model_names = true # read model field from AI vendor JSON responses
40
+ _rebuild_ignored_hosts
41
+ end
42
+
43
+ def collector_url=(url)
44
+ @collector_url = url
45
+ _rebuild_ignored_hosts
46
+ end
47
+
48
+ def ignored_hosts=(hosts)
49
+ @_user_hosts = Array(hosts || [])
50
+ _rebuild_ignored_hosts
51
+ end
52
+
53
+ # Returns true if +host+ should be skipped. Supports glob wildcards
54
+ # (* matches any sequence, ? matches one character) so customers can
55
+ # ignore entire internal domains: "*.internal", "*.svc.cluster.local".
56
+ def ignored_host?(host)
57
+ @_exact_ignored.include?(host) ||
58
+ @_glob_ignored.any? { |pat| File.fnmatch(pat, host) }
59
+ end
60
+
61
+ private
62
+
63
+ def _rebuild_ignored_hosts
64
+ all = HARD_IGNORED_HOSTS.dup + (@_user_hosts || [])
65
+ if @collector_url
66
+ begin
67
+ h = URI.parse(@collector_url).host
68
+ all << h if h
69
+ rescue URI::InvalidURIError
70
+ nil
71
+ end
72
+ end
73
+ @_exact_ignored = all.reject { |p| p.include?("*") || p.include?("?") }.to_set
74
+ @_glob_ignored = all.select { |p| p.include?("*") || p.include?("?") }
75
+ @ignored_hosts = Set.new(all)
28
76
  end
29
77
  end
30
78
  end
@@ -0,0 +1,51 @@
1
+ # lib/apidepth/model_name_extractor.rb
2
+ require "json"
3
+ require "set"
4
+ #
5
+ # Extracts the model name from AI vendor JSON response bodies.
6
+ #
7
+ # WHY response body rather than headers?
8
+ # AI vendors (OpenAI, Anthropic, Gemini, Mistral, Cohere) return the active
9
+ # model in the response body ({"model":"claude-3-opus-20240229",...}), not in
10
+ # headers. This is the only reliable source.
11
+ #
12
+ # WHY only for known AI vendor hosts?
13
+ # Body reads add a tiny overhead. Scoping to a hard-coded allowlist keeps the
14
+ # hot path for non-AI vendors completely unaffected.
15
+ #
16
+ # Body safety: Net::HTTP::HTTPResponse#body memoizes after the first read.
17
+ # Calling it here and returning the response to the application is safe — the
18
+ # application receives the same cached body bytes.
19
+ #
20
+ # Streaming safety: streamed responses have Content-Type: text/event-stream, not
21
+ # application/json. The content-type guard exits early before any body read.
22
+ # The 8KB truncation is a belt-and-suspenders guard against unusually large bodies.
23
+
24
+ module Apidepth
25
+ module ModelNameExtractor
26
+ AI_VENDOR_HOSTS = %w[
27
+ api.openai.com
28
+ api.anthropic.com
29
+ generativelanguage.googleapis.com
30
+ api.mistral.ai
31
+ api.cohere.com
32
+ ].to_set.freeze
33
+
34
+ MAX_BODY_BYTES = 8_192
35
+
36
+ def self.extract(host, response)
37
+ return nil unless Apidepth.configuration.capture_model_names
38
+ return nil unless AI_VENDOR_HOSTS.include?(host)
39
+ return nil unless response["content-type"]&.include?("application/json")
40
+
41
+ body = response.body
42
+ return nil if body.nil? || body.empty?
43
+
44
+ parsed = JSON.parse(body.byteslice(0, MAX_BODY_BYTES), symbolize_names: true)
45
+ model = parsed[:model]
46
+ model.is_a?(String) && !model.empty? ? model : nil
47
+ rescue JSON::ParserError, Encoding::UndefinedConversionError, TypeError
48
+ nil
49
+ end
50
+ end
51
+ 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.
@@ -72,21 +72,23 @@ module Apidepth
72
72
 
73
73
  now_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
74
74
  rl = Apidepth::RateLimitHeaders.extract(response, now_ms)
75
+ model_name = Apidepth::ModelNameExtractor.extract(address, response)
76
+
77
+ event_attrs = {
78
+ vendor: vendor,
79
+ endpoint: normalized_path,
80
+ method: req.method,
81
+ status: status,
82
+ outcome: outcome,
83
+ duration_ms: duration_ms,
84
+ cold_start: cold_start,
85
+ env: resolve_env,
86
+ ts: now_ms
87
+ }.merge(rl || {})
88
+ event_attrs[:model_name] = model_name if model_name
75
89
 
76
90
  Apidepth::Collector.instance.record(
77
- Apidepth::Event.build(
78
- {
79
- vendor: vendor,
80
- endpoint: normalized_path,
81
- method: req.method,
82
- status: status,
83
- outcome: outcome,
84
- duration_ms: duration_ms,
85
- cold_start: cold_start,
86
- env: resolve_env,
87
- ts: now_ms
88
- }.merge(rl || {})
89
- )
91
+ Apidepth::Event.build(event_attrs)
90
92
  )
91
93
  rescue StandardError => e
92
94
  Apidepth.logger&.debug("[Apidepth] Instrumentation error: #{e.class}: #{e.message}")
@@ -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)
@@ -103,6 +103,8 @@ module Apidepth
103
103
  # Pure numeric
104
104
  if str.match?(/\A\d+(?:\.\d+)?\z/)
105
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").
106
108
  return n >= 1_000_000_000 ? (n * 1_000).to_i : now_ms + (n * 1_000).to_i
107
109
  end
108
110
 
@@ -1,5 +1,5 @@
1
1
  # lib/apidepth/version.rb
2
2
 
3
3
  module Apidepth
4
- VERSION = "0.3.0".freeze
4
+ VERSION = "0.5.0".freeze
5
5
  end
data/lib/apidepth.rb CHANGED
@@ -1,14 +1,15 @@
1
1
  # lib/apidepth.rb
2
2
  #
3
3
  # Main entry point. Require order matters:
4
- # 1. version — no dependencies
5
- # 2. configuration — no dependencies
6
- # 3. vendor_registry — no dependencies, boots from BUNDLED_BASELINE immediately
7
- # 4. rate_limit_headers — no dependencies; used by net_http_instrumentation
8
- # 5. net_http_instrumentationdepends on vendor_registry + collector (via lazy reference)
9
- # 5. collector — depends on configuration
10
- # 6. registry_loader — depends on collector + vendor_registry
11
- # 7. railtie — depends on all of the above; only loaded in a Rails context
4
+ # 1. version — no dependencies
5
+ # 2. configuration — no dependencies
6
+ # 3. vendor_registry — no dependencies, boots from BUNDLED_BASELINE immediately
7
+ # 4. rate_limit_headers — no dependencies; used by net_http_instrumentation
8
+ # 5. model_name_extractorno dependencies; used by net_http_instrumentation
9
+ # 6. net_http_instrumentation — depends on vendor_registry + collector (via lazy reference)
10
+ # 7. collector — depends on configuration
11
+ # 8. registry_loader — depends on collector + vendor_registry
12
+ # 9. railtie — depends on all of the above; only loaded in a Rails context
12
13
 
13
14
  require "logger"
14
15
  require "apidepth/version"
@@ -16,6 +17,7 @@ require "apidepth/configuration"
16
17
  require "apidepth/event"
17
18
  require "apidepth/vendor_registry"
18
19
  require "apidepth/rate_limit_headers"
20
+ require "apidepth/model_name_extractor"
19
21
  require "apidepth/net_http_instrumentation"
20
22
  require "apidepth/collector"
21
23
  require "apidepth/registry_loader"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apidepth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Apidepth
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -114,16 +114,22 @@ description: Know if your API slowness is your code or the vendor's. Apidepth in
114
114
  you, or everyone.
115
115
  email:
116
116
  - hello@apidepth.io
117
- executables: []
117
+ executables:
118
+ - apidepth
118
119
  extensions: []
119
120
  extra_rdoc_files: []
120
121
  files:
121
122
  - LICENSE
122
123
  - README.md
124
+ - bin/apidepth
123
125
  - lib/apidepth.rb
126
+ - lib/apidepth/cli/framework_detector.rb
127
+ - lib/apidepth/cli/setup.rb
128
+ - lib/apidepth/cli/test_cmd.rb
124
129
  - lib/apidepth/collector.rb
125
130
  - lib/apidepth/configuration.rb
126
131
  - lib/apidepth/event.rb
132
+ - lib/apidepth/model_name_extractor.rb
127
133
  - lib/apidepth/net_http_instrumentation.rb
128
134
  - lib/apidepth/railtie.rb
129
135
  - lib/apidepth/rate_limit_headers.rb
@@ -147,7 +153,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
147
153
  requirements:
148
154
  - - ">="
149
155
  - !ruby/object:Gem::Version
150
- version: 2.7.0
156
+ version: 3.1.0
151
157
  required_rubygems_version: !ruby/object:Gem::Requirement
152
158
  requirements:
153
159
  - - ">="