apidepth 0.3.0 → 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 +4 -4
- data/README.md +6 -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 +49 -3
- data/lib/apidepth/net_http_instrumentation.rb +1 -1
- data/lib/apidepth/railtie.rb +13 -10
- data/lib/apidepth/rate_limit_headers.rb +2 -0
- data/lib/apidepth/version.rb +1 -1
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48290c633c238b8bbfcaff2a1f98cb4ffa26a3d0c02853ef0f345841425b463e
|
|
4
|
+
data.tar.gz: b3564982b79da91fe2ad976c913aa8bf94efbd35928f78a52d934e43b02eb4d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a932948137e2d17f75d346686e60f0287c06f2702ec03a7adf3458c521604d9691e7803921726d391f1e161c59f77bce15063a5eeda93800093d7bb7d75e3ac
|
|
7
|
+
data.tar.gz: d262bd3e9b0668a1f5c667160efba8a41b99b850fcf177d3ccf3a5398b7179a005d9b141fb1ef62e1e3a86d55533fce7cf7eaf96c71f01a059ba96d98888ff9e
|
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
|
|
|
@@ -100,7 +100,8 @@ Apidepth.configure do |config|
|
|
|
100
100
|
# Path for the local vendor registry cache. Must be an absolute path.
|
|
101
101
|
# The registry is fetched from Apidepth's servers and cached here so
|
|
102
102
|
# cold starts don't block on a network fetch.
|
|
103
|
-
#
|
|
103
|
+
# Rails default: Rails.root.join("tmp/apidepth_registry.json") (set by the Railtie)
|
|
104
|
+
# Non-Rails default: "/tmp/apidepth_registry.json"
|
|
104
105
|
config.registry_cache_path = "/tmp/apidepth_registry.json"
|
|
105
106
|
|
|
106
107
|
# Custom vendors your app calls that aren't in the global registry.
|
|
@@ -133,6 +134,9 @@ Every event contains:
|
|
|
133
134
|
| `cold_start` | `true` if this request paid for SSL handshake; excluded from p95 calculations |
|
|
134
135
|
| `env` | Environment tag from `config.environment` or `Rails.env` |
|
|
135
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 |
|
|
136
140
|
|
|
137
141
|
### What is never captured
|
|
138
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
|
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,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
|
-
@
|
|
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.
|
|
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.
|
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
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.4.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-
|
|
11
|
+
date: 2026-05-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -114,13 +114,18 @@ 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
|
|
@@ -147,7 +152,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
147
152
|
requirements:
|
|
148
153
|
- - ">="
|
|
149
154
|
- !ruby/object:Gem::Version
|
|
150
|
-
version:
|
|
155
|
+
version: 3.1.0
|
|
151
156
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
152
157
|
requirements:
|
|
153
158
|
- - ">="
|