rspec-buildkite-analytics 0.2.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 +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +40 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/buildkite.yaml +8 -0
- data/lib/rspec/buildkite/analytics/ci.rb +25 -0
- data/lib/rspec/buildkite/analytics/network.rb +77 -0
- data/lib/rspec/buildkite/analytics/object.rb +20 -0
- data/lib/rspec/buildkite/analytics/reporter.rb +25 -0
- data/lib/rspec/buildkite/analytics/session.rb +222 -0
- data/lib/rspec/buildkite/analytics/socket_connection.rb +115 -0
- data/lib/rspec/buildkite/analytics/tracer.rb +63 -0
- data/lib/rspec/buildkite/analytics/uploader.rb +177 -0
- data/lib/rspec/buildkite/analytics/version.rb +9 -0
- data/lib/rspec/buildkite/analytics.rb +30 -0
- data/rspec-buildkite-analytics.gemspec +31 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 69d1703e8bd5ea4f0d2d9d69daccbb6257b07074199c08c65ca50072f1994059
|
4
|
+
data.tar.gz: 6e52630ae12cf265a9ebc395e36c6c6638bc327a97c2a955eaa48e4583a08a8f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 28cfe62690f686ebb102a88165d6bff91c306305599b80937448d0a1d5823b576a50db9ae5329d0b3ce3d53d2237e5063dc22953c2cd7e2c05d6964975d1ae77
|
7
|
+
data.tar.gz: 22a611d02f90eb43d942374cb7586b68f817da8194d4d722385dddab070173266a1ff28be28184302956c1843df2d3d8cc915fd1094ff264dbd2954ac9440e24
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
rspec-buildkite-analytics (0.2.0)
|
5
|
+
activesupport
|
6
|
+
rspec-core
|
7
|
+
rspec-expectations
|
8
|
+
websocket
|
9
|
+
|
10
|
+
GEM
|
11
|
+
remote: https://rubygems.org/
|
12
|
+
specs:
|
13
|
+
activesupport (6.1.4)
|
14
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
15
|
+
i18n (>= 1.6, < 2)
|
16
|
+
minitest (>= 5.1)
|
17
|
+
tzinfo (~> 2.0)
|
18
|
+
zeitwerk (~> 2.3)
|
19
|
+
concurrent-ruby (1.1.9)
|
20
|
+
diff-lcs (1.4.4)
|
21
|
+
i18n (1.8.10)
|
22
|
+
concurrent-ruby (~> 1.0)
|
23
|
+
minitest (5.14.4)
|
24
|
+
rake (13.0.3)
|
25
|
+
rspec (3.10.0)
|
26
|
+
rspec-core (~> 3.10.0)
|
27
|
+
rspec-expectations (~> 3.10.0)
|
28
|
+
rspec-mocks (~> 3.10.0)
|
29
|
+
rspec-core (3.10.1)
|
30
|
+
rspec-support (~> 3.10.0)
|
31
|
+
rspec-expectations (3.10.1)
|
32
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
33
|
+
rspec-support (~> 3.10.0)
|
34
|
+
rspec-mocks (3.10.1)
|
35
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
36
|
+
rspec-support (~> 3.10.0)
|
37
|
+
rspec-support (3.10.1)
|
38
|
+
tzinfo (2.0.4)
|
39
|
+
concurrent-ruby (~> 1.0)
|
40
|
+
websocket (1.2.9)
|
41
|
+
zeitwerk (2.4.2)
|
42
|
+
|
43
|
+
PLATFORMS
|
44
|
+
ruby
|
45
|
+
|
46
|
+
DEPENDENCIES
|
47
|
+
rake (~> 13.0)
|
48
|
+
rspec (~> 3.0)
|
49
|
+
rspec-buildkite-analytics!
|
50
|
+
|
51
|
+
BUNDLED WITH
|
52
|
+
2.2.20
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Buildkite
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# RSpec Buildkite Analytics
|
2
|
+
|
3
|
+
This gem collects data about your test suite's performance and reliability, and allows you to see trends and insights about your test suite over time ✨
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add the gem to your Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
group :test do
|
11
|
+
# ...
|
12
|
+
gem "rspec-buildkite-analytics"
|
13
|
+
end
|
14
|
+
```
|
15
|
+
|
16
|
+
Configure your API key:
|
17
|
+
```ruby
|
18
|
+
RSpec::Buildkite::Analytics.configure do |config|
|
19
|
+
config.suite_key = "........"
|
20
|
+
# other config
|
21
|
+
end
|
22
|
+
```
|
23
|
+
|
24
|
+
Run bundler to install the gem and update your `Gemfile.lock`:
|
25
|
+
```
|
26
|
+
$ bundle
|
27
|
+
```
|
28
|
+
|
29
|
+
Lastly, commit and push your changes to start analysing your tests:
|
30
|
+
```
|
31
|
+
$ git commit -m "Add Buildkite Test Analytics client"
|
32
|
+
$ git push
|
33
|
+
```
|
34
|
+
|
35
|
+
## Contributing
|
36
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/buildkite/rspec-buildkite-analytics.
|
37
|
+
|
38
|
+
## License
|
39
|
+
|
40
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "rspec/buildkite/analytics"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/buildkite.yaml
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
|
5
|
+
module RSpec::Buildkite::Analytics::CI
|
6
|
+
def self.env
|
7
|
+
if ENV["BUILDKITE"]
|
8
|
+
{
|
9
|
+
"CI" => "buildkite",
|
10
|
+
"key" => ENV["BUILDKITE_BUILD_ID"],
|
11
|
+
"url" => ENV["BUILDKITE_BUILD_URL"],
|
12
|
+
"branch" => ENV["BUILDKITE_BRANCH"],
|
13
|
+
"commit_sha" => ENV["BUILDKITE_COMMIT"],
|
14
|
+
"number" => ENV["BUILDKITE_BUILD_NUMBER"],
|
15
|
+
"job_id" => ENV["BUILDKITE_JOB_ID"],
|
16
|
+
"message" => ENV["BUILDKITE_MESSAGE"]
|
17
|
+
}
|
18
|
+
else
|
19
|
+
{
|
20
|
+
"CI" => nil,
|
21
|
+
"key" => SecureRandom.uuid
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpec::Buildkite::Analytics
|
4
|
+
class Network
|
5
|
+
module NetHTTPPatch
|
6
|
+
def request(request, *args, &block)
|
7
|
+
unless uri = request.uri
|
8
|
+
protocol = use_ssl? ? "https" : "http"
|
9
|
+
uri = URI.join("#{protocol}://#{address}:#{port}", request.path)
|
10
|
+
end
|
11
|
+
|
12
|
+
detail = { method: request.method.upcase, url: uri.to_s, lib: "net-http" }
|
13
|
+
|
14
|
+
http_tracer = RSpec::Buildkite::Analytics::Uploader.tracer
|
15
|
+
http_tracer&.enter("http", **detail)
|
16
|
+
|
17
|
+
super
|
18
|
+
ensure
|
19
|
+
http_tracer&.leave
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module VCRPatch
|
24
|
+
def handle
|
25
|
+
if request_type == :stubbed_by_vcr && tracer = RSpec::Buildkite::Analytics::Uploader.tracer
|
26
|
+
tracer.current_span.detail.merge!(stubbed: "vcr")
|
27
|
+
end
|
28
|
+
|
29
|
+
super
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
module HTTPPatch
|
34
|
+
def perform(request, options)
|
35
|
+
detail = { method: request.verb.to_s.upcase, url: request.uri.to_s, lib: "http" }
|
36
|
+
|
37
|
+
http_tracer = RSpec::Buildkite::Analytics::Uploader.tracer
|
38
|
+
http_tracer&.enter("http", **detail)
|
39
|
+
|
40
|
+
super
|
41
|
+
ensure
|
42
|
+
http_tracer&.leave
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
module WebMockPatch
|
47
|
+
def response_for_request(request_signature)
|
48
|
+
response_from_webmock = super
|
49
|
+
|
50
|
+
if response_from_webmock && tracer = RSpec::Buildkite::Analytics::Uploader.tracer
|
51
|
+
tracer.current_span.detail.merge!(stubbed: "webmock")
|
52
|
+
end
|
53
|
+
|
54
|
+
response_from_webmock
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.configure
|
59
|
+
if defined?(VCR)
|
60
|
+
require "vcr/request_handler"
|
61
|
+
VCR::RequestHandler.prepend(VCRPatch)
|
62
|
+
end
|
63
|
+
|
64
|
+
if defined?(WebMock)
|
65
|
+
WebMock::StubRegistry.prepend(WebMockPatch)
|
66
|
+
end
|
67
|
+
|
68
|
+
if defined?(Net) && defined?(Net::HTTP)
|
69
|
+
Net::HTTP.prepend(NetHTTPPatch)
|
70
|
+
end
|
71
|
+
|
72
|
+
if defined?(HTTP) && defined?(HTTP::Client)
|
73
|
+
HTTP::Client.prepend(HTTPPatch)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpec::Buildkite::Analytics
|
4
|
+
class Object
|
5
|
+
module CustomObjectSleep
|
6
|
+
def sleep(duration)
|
7
|
+
tracer = RSpec::Buildkite::Analytics::Uploader.tracer
|
8
|
+
tracer&.enter("sleep")
|
9
|
+
|
10
|
+
super
|
11
|
+
ensure
|
12
|
+
tracer&.leave
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.configure
|
17
|
+
::Object.prepend(CustomObjectSleep)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module RSpec::Buildkite::Analytics
|
2
|
+
class Reporter
|
3
|
+
RSpec::Core::Formatters.register self, :example_passed, :example_failed, :example_pending
|
4
|
+
|
5
|
+
def initialize(output)
|
6
|
+
@output = output
|
7
|
+
end
|
8
|
+
|
9
|
+
def handle_example(notification)
|
10
|
+
example = notification.example
|
11
|
+
trace = RSpec::Buildkite::Analytics.uploader.traces.find do |trace|
|
12
|
+
example.id == trace.example.id
|
13
|
+
end
|
14
|
+
|
15
|
+
if trace
|
16
|
+
trace.example = example
|
17
|
+
RSpec::Buildkite::Analytics.session&.write_result(trace)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
alias_method :example_passed, :handle_example
|
22
|
+
alias_method :example_failed, :handle_example
|
23
|
+
alias_method :example_pending, :handle_example
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "socket_connection"
|
4
|
+
|
5
|
+
module RSpec::Buildkite::Analytics
|
6
|
+
class Session
|
7
|
+
# Picked 75 as the magic timeout number as it's longer than the TCP timeout of 60s 🤷♀️
|
8
|
+
CONFIRMATION_TIMEOUT = 75
|
9
|
+
MAX_RECONNECTION_ATTEMPTS = 3
|
10
|
+
WAIT_BETWEEN_RECONNECTIONS = 5
|
11
|
+
|
12
|
+
class RejectedSubscription < StandardError; end
|
13
|
+
|
14
|
+
def initialize(url, authorization_header, channel)
|
15
|
+
@queue = Queue.new
|
16
|
+
@channel = channel
|
17
|
+
|
18
|
+
@unconfirmed_idents = {}
|
19
|
+
@idents_mutex = Mutex.new
|
20
|
+
@empty = ConditionVariable.new
|
21
|
+
@closing = false
|
22
|
+
@reconnection_mutex = Mutex.new
|
23
|
+
|
24
|
+
@url = url
|
25
|
+
@authorization_header = authorization_header
|
26
|
+
|
27
|
+
connect
|
28
|
+
rescue TimeoutError => e
|
29
|
+
$stderr.puts "rspec-buildkite-analytics could not establish an initial connection with Buildkite. Please contact support."
|
30
|
+
end
|
31
|
+
|
32
|
+
def disconnected(connection)
|
33
|
+
reconnection_count = 0
|
34
|
+
@reconnection_mutex.synchronize do
|
35
|
+
# When the first thread detects a disconnection, it calls the disconnect method
|
36
|
+
# with the current connection. This thread grabs the reconnection mutex and does the
|
37
|
+
# reconnection, which then updates the value of @connection.
|
38
|
+
#
|
39
|
+
# At some point in that process, the second thread would have detected the
|
40
|
+
# disconnection too, and it also calls it with the current connection. However, the
|
41
|
+
# second thread can't run the reconnection code because of the mutex. By the
|
42
|
+
# time the mutex is released, the value of @connection has been refreshed, and so
|
43
|
+
# the second thread returns early and does not reattempt the reconnection.
|
44
|
+
return unless connection == @connection
|
45
|
+
|
46
|
+
begin
|
47
|
+
reconnection_count += 1
|
48
|
+
connect
|
49
|
+
rescue SocketConnection::HandshakeError, RejectedSubscription, TimeoutError, SocketConnection::SocketError => e
|
50
|
+
if reconnection_count > MAX_RECONNECTION_ATTEMPTS
|
51
|
+
$stderr.puts "rspec-buildkite-analytics experienced a disconnection and could not reconnect to Buildkite due to #{e.message}. Please contact support."
|
52
|
+
raise e
|
53
|
+
else
|
54
|
+
sleep(WAIT_BETWEEN_RECONNECTIONS)
|
55
|
+
retry
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
retransmit
|
60
|
+
end
|
61
|
+
|
62
|
+
def close()
|
63
|
+
@closing = true
|
64
|
+
|
65
|
+
# Because the server only sends us confirmations after every 10mb of
|
66
|
+
# data it uploads to S3, we'll never get confirmation of the
|
67
|
+
# identifiers of the last upload part unless we send an explicit finish,
|
68
|
+
# to which the server will respond with the last bits of data
|
69
|
+
send_eot
|
70
|
+
|
71
|
+
@idents_mutex.synchronize do
|
72
|
+
# Here, we sleep for 75 seconds while waiting for the server to confirm the last idents.
|
73
|
+
# We are woken up when the unconfirmed_idents is empty, and given back the mutex to
|
74
|
+
# continue operation.
|
75
|
+
@empty.wait(@idents_mutex, CONFIRMATION_TIMEOUT) unless @unconfirmed_idents.empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
# Then we always disconnect cos we can't wait forever? 🤷♀️
|
79
|
+
@connection.close
|
80
|
+
end
|
81
|
+
|
82
|
+
def handle(_connection, data)
|
83
|
+
data = JSON.parse(data)
|
84
|
+
case data["type"]
|
85
|
+
when "ping"
|
86
|
+
# In absence of other message, the server sends us a ping every 3 seconds
|
87
|
+
# We are currently not doing anything with these
|
88
|
+
when "welcome", "confirm_subscription"
|
89
|
+
# Push these two messages onto the queue, so that we block on waiting for the
|
90
|
+
# initializing phase to complete
|
91
|
+
@queue.push(data)
|
92
|
+
when "reject_subscription"
|
93
|
+
raise RejectedSubscription
|
94
|
+
else
|
95
|
+
process_message(data)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def write_result(result)
|
100
|
+
result_as_json = result.as_json
|
101
|
+
|
102
|
+
add_unconfirmed_idents(result.id, result_as_json)
|
103
|
+
|
104
|
+
transmit_results([result_as_json])
|
105
|
+
end
|
106
|
+
|
107
|
+
def unconfirmed_idents_count
|
108
|
+
@idents_mutex.synchronize do
|
109
|
+
@unconfirmed_idents.count
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def transmit_results(results_as_json)
|
116
|
+
@connection.transmit({
|
117
|
+
"identifier" => @channel,
|
118
|
+
"command" => "message",
|
119
|
+
"data" => {
|
120
|
+
"action" => "record_results",
|
121
|
+
"results" => results_as_json
|
122
|
+
}.to_json
|
123
|
+
})
|
124
|
+
end
|
125
|
+
|
126
|
+
def connect
|
127
|
+
@connection = SocketConnection.new(self, @url, {
|
128
|
+
"Authorization" => @authorization_header,
|
129
|
+
})
|
130
|
+
|
131
|
+
wait_for_welcome
|
132
|
+
|
133
|
+
@connection.transmit({
|
134
|
+
"command" => "subscribe",
|
135
|
+
"identifier" => @channel
|
136
|
+
})
|
137
|
+
|
138
|
+
wait_for_confirm
|
139
|
+
end
|
140
|
+
|
141
|
+
def pop_with_timeout
|
142
|
+
Timeout.timeout(30, RSpec::Buildkite::Analytics::TimeoutError, "Waited 30 seconds") do
|
143
|
+
@queue.pop
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def wait_for_welcome
|
148
|
+
welcome = pop_with_timeout
|
149
|
+
|
150
|
+
if welcome && welcome != { "type" => "welcome" }
|
151
|
+
raise "Not a welcome: #{welcome.inspect}"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def wait_for_confirm
|
156
|
+
confirm = pop_with_timeout
|
157
|
+
|
158
|
+
if confirm && confirm != { "type" => "confirm_subscription", "identifier" => @channel }
|
159
|
+
raise "Not a confirm: #{confirm.inspect}"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def add_unconfirmed_idents(ident, data)
|
164
|
+
@idents_mutex.synchronize do
|
165
|
+
@unconfirmed_idents[ident] = data
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def remove_unconfirmed_idents(idents)
|
170
|
+
return if idents.empty?
|
171
|
+
|
172
|
+
@idents_mutex.synchronize do
|
173
|
+
# Remove received idents from unconfirmed_idents
|
174
|
+
idents.each { |key| @unconfirmed_idents.delete(key) }
|
175
|
+
|
176
|
+
# This @empty ConditionVariable broadcasts every time that @unconfirmed_idents is
|
177
|
+
# empty, which will happen about every 10mb of data as that's when the server
|
178
|
+
# sends back confirmations.
|
179
|
+
#
|
180
|
+
# However, there aren't any threads waiting on this signal until after we
|
181
|
+
# send the EOT message, so the prior broadcasts shouldn't do anything.
|
182
|
+
@empty.broadcast if @unconfirmed_idents.empty?
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def send_eot
|
187
|
+
# Expect server to respond with data of indentifiers last upload part
|
188
|
+
|
189
|
+
@connection.transmit({
|
190
|
+
"identifier" => @channel,
|
191
|
+
"command" => "message",
|
192
|
+
"data" => {
|
193
|
+
"action" => "end_of_transmission"
|
194
|
+
}.to_json
|
195
|
+
})
|
196
|
+
end
|
197
|
+
|
198
|
+
def process_message(data)
|
199
|
+
# Check we're getting the data we expect
|
200
|
+
return unless data["identifier"] == @channel
|
201
|
+
|
202
|
+
case
|
203
|
+
when data["message"].key?("confirm")
|
204
|
+
remove_unconfirmed_idents(data["message"]["confirm"])
|
205
|
+
else
|
206
|
+
# unhandled message
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def retransmit
|
211
|
+
data = @idents_mutex.synchronize do
|
212
|
+
@unconfirmed_idents.values
|
213
|
+
end
|
214
|
+
|
215
|
+
# send the contents of the buffer, unless it's empty
|
216
|
+
transmit_results(data) unless data.empty?
|
217
|
+
# if we were disconnected in the closing phase, then resend the EOT
|
218
|
+
# message so the server can persist the last upload part
|
219
|
+
send_eot if @closing
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "socket"
|
4
|
+
require "openssl"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
module RSpec::Buildkite::Analytics
|
8
|
+
class SocketConnection
|
9
|
+
class HandshakeError < StandardError; end
|
10
|
+
class SocketError < StandardError; end
|
11
|
+
|
12
|
+
def initialize(session, url, headers)
|
13
|
+
uri = URI.parse(url)
|
14
|
+
@session = session
|
15
|
+
protocol = "http"
|
16
|
+
|
17
|
+
begin
|
18
|
+
socket = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
|
19
|
+
|
20
|
+
if uri.scheme == "wss"
|
21
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
22
|
+
protocol = "https"
|
23
|
+
|
24
|
+
ctx.min_version = :TLS1_2
|
25
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
26
|
+
ctx.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
27
|
+
|
28
|
+
socket = OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
29
|
+
socket.connect
|
30
|
+
end
|
31
|
+
rescue
|
32
|
+
# We are rescuing all here, as there are a range of Errno errors that could be
|
33
|
+
# raised when we fail to establish a TCP connection
|
34
|
+
raise SocketError
|
35
|
+
end
|
36
|
+
|
37
|
+
@socket = socket
|
38
|
+
|
39
|
+
headers = { "Origin" => "#{protocol}://#{uri.host}" }.merge(headers)
|
40
|
+
handshake = WebSocket::Handshake::Client.new(url: url, headers: headers)
|
41
|
+
|
42
|
+
@socket.write handshake.to_s
|
43
|
+
|
44
|
+
until handshake.finished?
|
45
|
+
if byte = @socket.getc
|
46
|
+
handshake << byte
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# The errors below are raised when we establish the TCP connection, but get back
|
51
|
+
# an error, i.e. in dev we can still connect to puma-dev while nginx isn't
|
52
|
+
# running, or in prod we can hit a load balancer while app is down
|
53
|
+
unless handshake.valid?
|
54
|
+
case handshake.error
|
55
|
+
when Exception, String
|
56
|
+
raise HandshakeError.new(handshake.error)
|
57
|
+
when nil
|
58
|
+
raise HandshakeError.new("Invalid handshake")
|
59
|
+
else
|
60
|
+
raise HandshakeError.new(handshake.error.inspect)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
@version = handshake.version
|
65
|
+
|
66
|
+
# Setting up a new thread that listens on the socket, and processes incoming
|
67
|
+
# comms from the server
|
68
|
+
@thread = Thread.new do
|
69
|
+
frame = WebSocket::Frame::Incoming::Client.new
|
70
|
+
|
71
|
+
while @socket
|
72
|
+
frame << @socket.readpartial(4096)
|
73
|
+
|
74
|
+
while data = frame.next
|
75
|
+
@session.handle(self, data.data)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
rescue EOFError
|
79
|
+
if @socket
|
80
|
+
@session.disconnected(self)
|
81
|
+
disconnect
|
82
|
+
end
|
83
|
+
rescue IOError
|
84
|
+
# This is fine to ignore
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def transmit(data, type: :text)
|
89
|
+
# this line prevents us from calling disconnect twice
|
90
|
+
return if @socket.nil?
|
91
|
+
|
92
|
+
raw_data = data.to_json
|
93
|
+
frame = WebSocket::Frame::Outgoing::Client.new(data: raw_data, type: :text, version: @version)
|
94
|
+
@socket.write(frame.to_s)
|
95
|
+
rescue Errno::EPIPE, OpenSSL::SSL::SSLError => e
|
96
|
+
return unless @socket
|
97
|
+
@session.disconnected(self)
|
98
|
+
disconnect
|
99
|
+
end
|
100
|
+
|
101
|
+
def close
|
102
|
+
transmit(nil, type: :close)
|
103
|
+
disconnect
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def disconnect
|
109
|
+
socket = @socket
|
110
|
+
@socket = nil
|
111
|
+
socket&.close
|
112
|
+
@thread&.join unless @thread == Thread.current
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpec::Buildkite::Analytics
|
4
|
+
class Tracer
|
5
|
+
class Span
|
6
|
+
attr_accessor :section, :start_at, :end_at, :detail, :children
|
7
|
+
|
8
|
+
def initialize(section, start_at, end_at, detail)
|
9
|
+
@section = section
|
10
|
+
@start_at = start_at
|
11
|
+
@end_at = end_at
|
12
|
+
@detail = detail
|
13
|
+
@children = []
|
14
|
+
end
|
15
|
+
|
16
|
+
def as_json
|
17
|
+
{
|
18
|
+
section: section,
|
19
|
+
start_at: start_at,
|
20
|
+
end_at: end_at,
|
21
|
+
duration: end_at - start_at,
|
22
|
+
detail: detail,
|
23
|
+
children: children.map(&:as_json),
|
24
|
+
}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
@top = Span.new(:top, Concurrent.monotonic_time, nil, {})
|
30
|
+
@stack = [@top]
|
31
|
+
end
|
32
|
+
|
33
|
+
def enter(section, **detail)
|
34
|
+
new_entry = Span.new(section, Concurrent.monotonic_time, nil, detail)
|
35
|
+
current_span.children << new_entry
|
36
|
+
@stack << new_entry
|
37
|
+
end
|
38
|
+
|
39
|
+
def leave
|
40
|
+
current_span.end_at = Concurrent.monotonic_time
|
41
|
+
@stack.pop
|
42
|
+
end
|
43
|
+
|
44
|
+
def backfill(section, duration, **detail)
|
45
|
+
new_entry = Span.new(section, Concurrent.monotonic_time - duration, Concurrent.monotonic_time, detail)
|
46
|
+
current_span.children << new_entry
|
47
|
+
end
|
48
|
+
|
49
|
+
def current_span
|
50
|
+
@stack.last
|
51
|
+
end
|
52
|
+
|
53
|
+
def finalize
|
54
|
+
raise "Stack not empty" unless @stack.size == 1
|
55
|
+
@top.end_at = Concurrent.monotonic_time
|
56
|
+
self
|
57
|
+
end
|
58
|
+
|
59
|
+
def history
|
60
|
+
@top.as_json
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rspec/core"
|
4
|
+
require "rspec/expectations"
|
5
|
+
|
6
|
+
require "net/http"
|
7
|
+
require "openssl"
|
8
|
+
require "websocket"
|
9
|
+
|
10
|
+
require_relative "tracer"
|
11
|
+
require_relative "network"
|
12
|
+
require_relative "object"
|
13
|
+
require_relative "session"
|
14
|
+
require_relative "reporter"
|
15
|
+
require_relative "ci"
|
16
|
+
|
17
|
+
require "active_support"
|
18
|
+
require "active_support/notifications"
|
19
|
+
|
20
|
+
require "securerandom"
|
21
|
+
|
22
|
+
module RSpec::Buildkite::Analytics
|
23
|
+
class Uploader
|
24
|
+
class Trace
|
25
|
+
attr_accessor :example
|
26
|
+
attr_reader :id, :history
|
27
|
+
|
28
|
+
def initialize(example, history)
|
29
|
+
@id = SecureRandom.uuid
|
30
|
+
@example = example
|
31
|
+
@history = history
|
32
|
+
end
|
33
|
+
|
34
|
+
def failure_message
|
35
|
+
case example.exception
|
36
|
+
when RSpec::Expectations::ExpectationNotMetError
|
37
|
+
example.exception.message
|
38
|
+
when Exception
|
39
|
+
"#{example.exception.class}: #{example.exception.message}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def result_state
|
44
|
+
case example.execution_result.status
|
45
|
+
when :passed; "passed"
|
46
|
+
when :failed; "failed"
|
47
|
+
when :pending; "skipped"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def as_json
|
52
|
+
{
|
53
|
+
id: @id,
|
54
|
+
scope: example.example_group.metadata[:full_description],
|
55
|
+
name: example.description,
|
56
|
+
identifier: example.id,
|
57
|
+
location: example.location,
|
58
|
+
file_name: generate_file_name(example),
|
59
|
+
result: result_state,
|
60
|
+
failure: failure_message,
|
61
|
+
history: history,
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def generate_file_name(example)
|
68
|
+
file_path_regex = /^(.*?\.rb)/
|
69
|
+
identifier_file_name = example.id[file_path_regex]
|
70
|
+
location_file_name = example.location[file_path_regex]
|
71
|
+
|
72
|
+
if identifier_file_name != location_file_name
|
73
|
+
# If the identifier and location files are not the same, we assume
|
74
|
+
# that the test was run as part of a shared example. If this isn't the
|
75
|
+
# case, then there's something we haven't accounted for
|
76
|
+
if example.metadata[:shared_group_inclusion_backtrace].any?
|
77
|
+
# Taking the last frame in this backtrace will give us the original
|
78
|
+
# entry point for the shared example
|
79
|
+
example.metadata[:shared_group_inclusion_backtrace].last.inclusion_location[file_path_regex]
|
80
|
+
else
|
81
|
+
"Unknown"
|
82
|
+
end
|
83
|
+
else
|
84
|
+
identifier_file_name
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.traces
|
90
|
+
@traces ||= []
|
91
|
+
end
|
92
|
+
|
93
|
+
REQUEST_EXCEPTIONS = [
|
94
|
+
URI::InvalidURIError,
|
95
|
+
Net::HTTPBadResponse,
|
96
|
+
Net::HTTPHeaderSyntaxError,
|
97
|
+
Net::ReadTimeout,
|
98
|
+
Net::OpenTimeout,
|
99
|
+
OpenSSL::SSL::SSLError,
|
100
|
+
OpenSSL::SSL::SSLErrorWaitReadable,
|
101
|
+
EOFError
|
102
|
+
]
|
103
|
+
|
104
|
+
def self.configure
|
105
|
+
RSpec::Buildkite::Analytics.uploader = self
|
106
|
+
|
107
|
+
RSpec.configure do |config|
|
108
|
+
config.before(:suite) do
|
109
|
+
config.add_formatter RSpec::Buildkite::Analytics::Reporter
|
110
|
+
|
111
|
+
if RSpec::Buildkite::Analytics.api_token
|
112
|
+
contact_uri = URI.parse(RSpec::Buildkite::Analytics.url)
|
113
|
+
|
114
|
+
http = Net::HTTP.new(contact_uri.host, contact_uri.port)
|
115
|
+
http.use_ssl = contact_uri.scheme == "https"
|
116
|
+
|
117
|
+
authorization_header = "Token token=\"#{RSpec::Buildkite::Analytics.api_token}\""
|
118
|
+
|
119
|
+
contact = Net::HTTP::Post.new(contact_uri.path, {
|
120
|
+
"Authorization" => authorization_header,
|
121
|
+
"Content-Type" => "application/json",
|
122
|
+
})
|
123
|
+
contact.body = {
|
124
|
+
run_env: CI.env
|
125
|
+
}.to_json
|
126
|
+
|
127
|
+
response = begin
|
128
|
+
http.request(contact)
|
129
|
+
rescue *REQUEST_EXCEPTIONS => e
|
130
|
+
puts "Error communicating with the server: #{e.message}"
|
131
|
+
end
|
132
|
+
|
133
|
+
if response.is_a?(Net::HTTPSuccess)
|
134
|
+
json = JSON.parse(response.body)
|
135
|
+
|
136
|
+
if (socket_url = json["cable"]) && (channel = json["channel"])
|
137
|
+
RSpec::Buildkite::Analytics.session = Session.new(socket_url, authorization_header, channel)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
config.around(:each) do |example|
|
144
|
+
tracer = RSpec::Buildkite::Analytics::Tracer.new
|
145
|
+
|
146
|
+
# The _buildkite prefix here is added as a safeguard against name collisions
|
147
|
+
# as we are in the main thread
|
148
|
+
Thread.current[:_buildkite_tracer] = tracer
|
149
|
+
example.run
|
150
|
+
Thread.current[:_buildkite_tracer] = nil
|
151
|
+
|
152
|
+
tracer.finalize
|
153
|
+
|
154
|
+
trace = RSpec::Buildkite::Analytics::Uploader::Trace.new(example, tracer.history)
|
155
|
+
RSpec::Buildkite::Analytics.uploader.traces << trace
|
156
|
+
end
|
157
|
+
|
158
|
+
config.after(:suite) do
|
159
|
+
# This needs the lonely operater as the session will be nil
|
160
|
+
# if auth against the API token fails
|
161
|
+
RSpec::Buildkite::Analytics.session&.close
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
RSpec::Buildkite::Analytics::Network.configure
|
166
|
+
RSpec::Buildkite::Analytics::Object.configure
|
167
|
+
|
168
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |name, start, finish, id, payload|
|
169
|
+
tracer&.backfill(:sql, finish - start, **{ query: payload[:sql] })
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def self.tracer
|
174
|
+
Thread.current[:_buildkite_tracer]
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "timeout"
|
4
|
+
|
5
|
+
require_relative "analytics/version"
|
6
|
+
|
7
|
+
module RSpec::Buildkite::Analytics
|
8
|
+
class Error < StandardError; end
|
9
|
+
class TimeoutError < ::Timeout::Error; end
|
10
|
+
|
11
|
+
DEFAULT_URL = "https://analytics-api.buildkite.com/v1/uploads"
|
12
|
+
|
13
|
+
class << self
|
14
|
+
attr_accessor :api_token
|
15
|
+
attr_accessor :filename
|
16
|
+
attr_accessor :url
|
17
|
+
attr_accessor :uploader
|
18
|
+
attr_accessor :session
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.configure(token: nil, url: nil, filename: nil)
|
22
|
+
self.api_token = token || ENV["BUILDKITE_ANALYTICS_TOKEN"]
|
23
|
+
self.url = url || DEFAULT_URL
|
24
|
+
self.filename = filename
|
25
|
+
|
26
|
+
require_relative "analytics/uploader"
|
27
|
+
|
28
|
+
self::Uploader.configure
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/rspec/buildkite/analytics/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "rspec-buildkite-analytics"
|
7
|
+
spec.version = RSpec::Buildkite::Analytics::VERSION
|
8
|
+
spec.authors = ["Buildkite"]
|
9
|
+
spec.email = ["hello@buildkite.com"]
|
10
|
+
|
11
|
+
spec.summary = "Track execution of specs and report to Buildkite Analytics"
|
12
|
+
spec.homepage = "https://github.com/buildkite/rspec-buildkite-analytics"
|
13
|
+
spec.license = "MIT"
|
14
|
+
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
16
|
+
spec.metadata["source_code_uri"] = "https://github.com/buildkite/rspec-buildkite-analytics"
|
17
|
+
|
18
|
+
# Specify which files should be added to the gem when it is released.
|
19
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
21
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
22
|
+
end
|
23
|
+
spec.require_paths = ["lib"]
|
24
|
+
|
25
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
26
|
+
|
27
|
+
spec.add_dependency "activesupport", '~> 6.1'
|
28
|
+
spec.add_dependency "rspec-core", '~> 3.10'
|
29
|
+
spec.add_dependency "rspec-expectations", '~> 3.10'
|
30
|
+
spec.add_dependency "websocket", '~> 1.2'
|
31
|
+
end
|
metadata
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rspec-buildkite-analytics
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Buildkite
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-08-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '6.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec-core
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.10'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.10'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec-expectations
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.10'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.10'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: websocket
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.2'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.2'
|
69
|
+
description:
|
70
|
+
email:
|
71
|
+
- hello@buildkite.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".gitignore"
|
77
|
+
- ".rspec"
|
78
|
+
- Gemfile
|
79
|
+
- Gemfile.lock
|
80
|
+
- LICENSE.txt
|
81
|
+
- README.md
|
82
|
+
- Rakefile
|
83
|
+
- bin/console
|
84
|
+
- bin/setup
|
85
|
+
- buildkite.yaml
|
86
|
+
- lib/rspec/buildkite/analytics.rb
|
87
|
+
- lib/rspec/buildkite/analytics/ci.rb
|
88
|
+
- lib/rspec/buildkite/analytics/network.rb
|
89
|
+
- lib/rspec/buildkite/analytics/object.rb
|
90
|
+
- lib/rspec/buildkite/analytics/reporter.rb
|
91
|
+
- lib/rspec/buildkite/analytics/session.rb
|
92
|
+
- lib/rspec/buildkite/analytics/socket_connection.rb
|
93
|
+
- lib/rspec/buildkite/analytics/tracer.rb
|
94
|
+
- lib/rspec/buildkite/analytics/uploader.rb
|
95
|
+
- lib/rspec/buildkite/analytics/version.rb
|
96
|
+
- rspec-buildkite-analytics.gemspec
|
97
|
+
homepage: https://github.com/buildkite/rspec-buildkite-analytics
|
98
|
+
licenses:
|
99
|
+
- MIT
|
100
|
+
metadata:
|
101
|
+
homepage_uri: https://github.com/buildkite/rspec-buildkite-analytics
|
102
|
+
source_code_uri: https://github.com/buildkite/rspec-buildkite-analytics
|
103
|
+
post_install_message:
|
104
|
+
rdoc_options: []
|
105
|
+
require_paths:
|
106
|
+
- lib
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: 2.3.0
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '0'
|
117
|
+
requirements: []
|
118
|
+
rubygems_version: 3.1.4
|
119
|
+
signing_key:
|
120
|
+
specification_version: 4
|
121
|
+
summary: Track execution of specs and report to Buildkite Analytics
|
122
|
+
test_files: []
|