rspec-buildkite-analytics 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in rspec-buildkite-analytics.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.0"
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/buildkite.yaml ADDED
@@ -0,0 +1,8 @@
1
+ steps:
2
+ - label: ":rspec: Tests"
3
+ command:
4
+ - "bundle"
5
+ - "bundle exec rake"
6
+ plugins:
7
+ - docker#v3.7.0:
8
+ image: ruby:latest
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Buildkite
5
+ module Analytics
6
+ VERSION = "0.2.0"
7
+ end
8
+ end
9
+ 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: []