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 +4 -4
- data/README.md +10 -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 +20 -8
- data/lib/apidepth/configuration.rb +49 -3
- data/lib/apidepth/net_http_instrumentation.rb +5 -3
- data/lib/apidepth/railtie.rb +13 -10
- data/lib/apidepth/rate_limit_headers.rb +3 -0
- data/lib/apidepth/registry_loader.rb +33 -4
- data/lib/apidepth/vendor_registry.rb +21 -0
- data/lib/apidepth/version.rb +1 -1
- metadata +30 -8
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
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# apidepth
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/apidepth)
|
|
4
|
+
[](https://rubygems.org/gems/apidepth)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
Most API monitoring tools measure latency from their servers to the vendor. That's not what your users feel. Apidepth instruments `Net::HTTP` directly — every outbound call your app makes to Stripe, OpenAI, or Twilio is timed at the socket level, from your server. Then it benchmarks your numbers against anonymized fleet data, so when Stripe is slow you can tell if it's you or everyone.
|
|
4
8
|
|
|
5
9
|
No payload capture. No credentials touch our infrastructure. No changes to your application code beyond a one-time initializer.
|
|
@@ -36,7 +40,7 @@ bundle install
|
|
|
36
40
|
|
|
37
41
|
---
|
|
38
42
|
|
|
39
|
-
##
|
|
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
|
-
#
|
|
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
|
data/lib/apidepth/collector.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
@
|
|
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.
|
|
@@ -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
|
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)
|
|
@@ -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
|
-
|
|
17
|
-
|
|
23
|
+
# Apply disk cache immediately — zero network latency, registry is ready
|
|
24
|
+
# before the application begins serving requests.
|
|
25
|
+
disk_registry = load_from_disk
|
|
26
|
+
VendorRegistry.replace(disk_registry) if disk_registry
|
|
27
|
+
|
|
28
|
+
# Fetch the freshest registry from the network in the background so the
|
|
29
|
+
# boot thread is never blocked by the remote request.
|
|
30
|
+
Thread.new do
|
|
31
|
+
registry = fetch_remote
|
|
32
|
+
VendorRegistry.replace(registry) if registry
|
|
33
|
+
end.tap do |t|
|
|
34
|
+
t.abort_on_exception = false
|
|
35
|
+
t.name = "apidepth-registry-init"
|
|
36
|
+
end
|
|
37
|
+
|
|
18
38
|
start_refresh_thread
|
|
19
39
|
end
|
|
20
40
|
|
|
21
|
-
private
|
|
22
|
-
|
|
23
41
|
def self.start_refresh_thread
|
|
24
42
|
Thread.new do
|
|
25
43
|
loop do
|
|
@@ -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)
|
data/lib/apidepth/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: apidepth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Apidepth
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-05-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/
|
|
124
|
-
changelog_uri: https://github.com/
|
|
125
|
-
bug_tracker_uri: https://github.com/
|
|
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:
|
|
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:
|
|
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: []
|