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 +4 -4
- data/README.md +46 -2
- data/bin/apidepth +31 -0
- data/lib/apidepth/cli/framework_detector.rb +82 -0
- data/lib/apidepth/cli/setup.rb +136 -0
- data/lib/apidepth/cli/test_cmd.rb +136 -0
- data/lib/apidepth/collector.rb +4 -0
- data/lib/apidepth/configuration.rb +54 -6
- data/lib/apidepth/model_name_extractor.rb +51 -0
- data/lib/apidepth/net_http_instrumentation.rb +16 -14
- data/lib/apidepth/railtie.rb +13 -10
- data/lib/apidepth/rate_limit_headers.rb +2 -0
- data/lib/apidepth/version.rb +1 -1
- data/lib/apidepth.rb +10 -8
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d59e0f9e95678afd4a0f9ce173de1b63ac893ab01cbc68fc1d6bbcedc44bfaad
|
|
4
|
+
data.tar.gz: 29524e4a1687cafb241caa6c07dbb2c1139ef2e44bc92f8ded25dc15c1305a92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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
|
-
#
|
|
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
|
data/lib/apidepth/collector.rb
CHANGED
|
@@ -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,
|
|
14
|
-
:sample_rate,
|
|
15
|
-
:extra_vendors
|
|
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
|
-
@
|
|
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.
|
|
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}")
|
data/lib/apidepth/railtie.rb
CHANGED
|
@@ -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.
|
|
45
|
-
#
|
|
46
|
-
# flush
|
|
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
|
|
data/lib/apidepth/version.rb
CHANGED
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
|
|
5
|
-
# 2. configuration
|
|
6
|
-
# 3. vendor_registry
|
|
7
|
-
# 4. rate_limit_headers
|
|
8
|
-
# 5.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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_extractor — no 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.
|
|
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-
|
|
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:
|
|
156
|
+
version: 3.1.0
|
|
151
157
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
152
158
|
requirements:
|
|
153
159
|
- - ">="
|