buildkite-test_collector 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +19 -0
- data/CODE_OF_CONDUCT.md +133 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +103 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/buildkite-test_collector.gemspec +32 -0
- data/buildkite.yaml +8 -0
- data/lib/buildkite/test_collector/ci.rb +86 -0
- data/lib/buildkite/test_collector/http_client.rb +35 -0
- data/lib/buildkite/test_collector/library_hooks/minitest.rb +16 -0
- data/lib/buildkite/test_collector/library_hooks/rspec.rb +35 -0
- data/lib/buildkite/test_collector/logger.rb +19 -0
- data/lib/buildkite/test_collector/minitest_plugin/reporter.rb +34 -0
- data/lib/buildkite/test_collector/minitest_plugin/trace.rb +102 -0
- data/lib/buildkite/test_collector/minitest_plugin.rb +27 -0
- data/lib/buildkite/test_collector/network.rb +77 -0
- data/lib/buildkite/test_collector/object.rb +20 -0
- data/lib/buildkite/test_collector/rspec_plugin/reporter.rb +95 -0
- data/lib/buildkite/test_collector/rspec_plugin/trace.rb +87 -0
- data/lib/buildkite/test_collector/session.rb +331 -0
- data/lib/buildkite/test_collector/socket_connection.rb +157 -0
- data/lib/buildkite/test_collector/tracer.rb +65 -0
- data/lib/buildkite/test_collector/uploader.rb +73 -0
- data/lib/buildkite/test_collector/version.rb +8 -0
- data/lib/buildkite/test_collector.rb +84 -0
- data/lib/minitest/buildkite_collector_plugin.rb +7 -0
- metadata +139 -0
@@ -0,0 +1,331 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "socket_connection"
|
4
|
+
|
5
|
+
module Buildkite::TestCollector
|
6
|
+
class Session
|
7
|
+
# Picked 75 as the magic timeout number as it's longer than the TCP timeout of 60s 🤷♀️
|
8
|
+
CONFIRMATION_TIMEOUT = ENV.fetch("BUILDKITE_ANALYTICS_CONFIRMATION_TIMEOUT") { 75 }.to_i
|
9
|
+
MAX_RECONNECTION_ATTEMPTS = ENV.fetch("BUILDKITE_ANALYTICS_RECONNECTION_ATTEMPTS") { 3 }.to_i
|
10
|
+
WAIT_BETWEEN_RECONNECTIONS = ENV.fetch("BUILDKITE_ANALYTICS_RECONNECTION_WAIT") { 5 }.to_i
|
11
|
+
|
12
|
+
class RejectedSubscription < StandardError; end
|
13
|
+
class InitialConnectionFailure < StandardError; end
|
14
|
+
|
15
|
+
DISCONNECTED_EXCEPTIONS = [
|
16
|
+
SocketConnection::HandshakeError,
|
17
|
+
RejectedSubscription,
|
18
|
+
TimeoutError,
|
19
|
+
InitialConnectionFailure,
|
20
|
+
SocketConnection::SocketError
|
21
|
+
]
|
22
|
+
|
23
|
+
def initialize(url, authorization_header, channel)
|
24
|
+
@establish_subscription_queue = Queue.new
|
25
|
+
@channel = channel
|
26
|
+
|
27
|
+
@unconfirmed_idents = {}
|
28
|
+
@idents_mutex = Mutex.new
|
29
|
+
@send_queue = Queue.new
|
30
|
+
@empty = ConditionVariable.new
|
31
|
+
@closing = false
|
32
|
+
@eot_queued = false
|
33
|
+
@eot_queued_mutex = Mutex.new
|
34
|
+
@reconnection_mutex = Mutex.new
|
35
|
+
|
36
|
+
@url = url
|
37
|
+
@authorization_header = authorization_header
|
38
|
+
|
39
|
+
reconnection_count = 0
|
40
|
+
|
41
|
+
begin
|
42
|
+
reconnection_count += 1
|
43
|
+
connect
|
44
|
+
rescue TimeoutError, InitialConnectionFailure => e
|
45
|
+
Buildkite::TestCollector.logger.warn("rspec-buildkite-analytics could not establish an initial connection with Buildkite due to #{e}. Attempting retry #{reconnection_count} of #{MAX_RECONNECTION_ATTEMPTS}...")
|
46
|
+
if reconnection_count > MAX_RECONNECTION_ATTEMPTS
|
47
|
+
Buildkite::TestCollector.logger.error "rspec-buildkite-analytics could not establish an initial connection with Buildkite due to #{e.message} after #{MAX_RECONNECTION_ATTEMPTS} attempts. You may be missing some data for this test suite, please contact support if this issue persists."
|
48
|
+
else
|
49
|
+
sleep(WAIT_BETWEEN_RECONNECTIONS)
|
50
|
+
Buildkite::TestCollector.logger.warn("retrying reconnection")
|
51
|
+
retry
|
52
|
+
end
|
53
|
+
end
|
54
|
+
init_write_thread
|
55
|
+
end
|
56
|
+
|
57
|
+
def disconnected(connection)
|
58
|
+
@reconnection_mutex.synchronize do
|
59
|
+
# When the first thread detects a disconnection, it calls the disconnect method
|
60
|
+
# with the current connection. This thread grabs the reconnection mutex and does the
|
61
|
+
# reconnection, which then updates the value of @connection.
|
62
|
+
#
|
63
|
+
# At some point in that process, the second thread would have detected the
|
64
|
+
# disconnection too, and it also calls it with the current connection. However, the
|
65
|
+
# second thread can't run the reconnection code because of the mutex. By the
|
66
|
+
# time the mutex is released, the value of @connection has been refreshed, and so
|
67
|
+
# the second thread returns early and does not reattempt the reconnection.
|
68
|
+
return unless connection == @connection
|
69
|
+
Buildkite::TestCollector.logger.debug("starting reconnection")
|
70
|
+
|
71
|
+
reconnection_count = 0
|
72
|
+
|
73
|
+
begin
|
74
|
+
reconnection_count += 1
|
75
|
+
connect
|
76
|
+
init_write_thread
|
77
|
+
rescue *DISCONNECTED_EXCEPTIONS => e
|
78
|
+
Buildkite::TestCollector.logger.warn("failed reconnection attempt #{reconnection_count} due to #{e}")
|
79
|
+
if reconnection_count > MAX_RECONNECTION_ATTEMPTS
|
80
|
+
Buildkite::TestCollector.logger.error "rspec-buildkite-analytics experienced a disconnection and could not reconnect to Buildkite due to #{e.message}. Please contact support."
|
81
|
+
raise e
|
82
|
+
else
|
83
|
+
sleep(WAIT_BETWEEN_RECONNECTIONS)
|
84
|
+
Buildkite::TestCollector.logger.warn("retrying reconnection")
|
85
|
+
retry
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
retransmit
|
90
|
+
end
|
91
|
+
|
92
|
+
def close(examples_count)
|
93
|
+
@closing = true
|
94
|
+
@examples_count = examples_count
|
95
|
+
Buildkite::TestCollector.logger.debug("closing socket connection")
|
96
|
+
|
97
|
+
# Because the server only sends us confirmations after every 10mb of
|
98
|
+
# data it uploads to S3, we'll never get confirmation of the
|
99
|
+
# identifiers of the last upload part unless we send an explicit finish,
|
100
|
+
# to which the server will respond with the last bits of data
|
101
|
+
send_eot
|
102
|
+
|
103
|
+
# After EOT, we wait for 75 seconds for the send queue to be drained and for the
|
104
|
+
# server to confirm the last idents. If everything has already been confirmed we can
|
105
|
+
# proceed without waiting.
|
106
|
+
@idents_mutex.synchronize do
|
107
|
+
if @unconfirmed_idents.any?
|
108
|
+
Buildkite::TestCollector.logger.debug "Waiting for Buildkite Test Analytics to send results..."
|
109
|
+
Buildkite::TestCollector.logger.debug("waiting for last confirm")
|
110
|
+
|
111
|
+
@empty.wait(@idents_mutex, CONFIRMATION_TIMEOUT)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Then we always disconnect cos we can't wait forever? 🤷♀️
|
116
|
+
@connection.close
|
117
|
+
# We kill the write thread cos it's got a while loop in it, so it won't finish otherwise
|
118
|
+
@write_thread&.kill
|
119
|
+
|
120
|
+
Buildkite::TestCollector.logger.info "Buildkite Test Analytics completed"
|
121
|
+
Buildkite::TestCollector.logger.debug("socket connection closed")
|
122
|
+
end
|
123
|
+
|
124
|
+
def handle(_connection, data)
|
125
|
+
data = JSON.parse(data)
|
126
|
+
case data["type"]
|
127
|
+
when "ping"
|
128
|
+
# In absence of other message, the server sends us a ping every 3 seconds
|
129
|
+
# We are currently not doing anything with these
|
130
|
+
Buildkite::TestCollector.logger.debug("received ping")
|
131
|
+
when "welcome", "confirm_subscription"
|
132
|
+
# Push these two messages onto the queue, so that we block on waiting for the
|
133
|
+
# initializing phase to complete
|
134
|
+
@establish_subscription_queue.push(data)
|
135
|
+
Buildkite::TestCollector.logger.debug("received #{data['type']}")
|
136
|
+
when "reject_subscription"
|
137
|
+
Buildkite::TestCollector.logger.debug("received rejected_subscription")
|
138
|
+
raise RejectedSubscription
|
139
|
+
else
|
140
|
+
process_message(data)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def write_result(result)
|
145
|
+
queue_and_track_result(result.id, result.as_hash)
|
146
|
+
|
147
|
+
Buildkite::TestCollector.logger.debug("added #{result.id} to send queue")
|
148
|
+
end
|
149
|
+
|
150
|
+
def unconfirmed_idents_count
|
151
|
+
@idents_mutex.synchronize do
|
152
|
+
@unconfirmed_idents.count
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
private
|
157
|
+
|
158
|
+
def connect
|
159
|
+
Buildkite::TestCollector.logger.debug("starting socket connection process")
|
160
|
+
|
161
|
+
@connection = SocketConnection.new(self, @url, {
|
162
|
+
"Authorization" => @authorization_header,
|
163
|
+
})
|
164
|
+
|
165
|
+
wait_for_welcome
|
166
|
+
|
167
|
+
@connection.transmit({
|
168
|
+
"command" => "subscribe",
|
169
|
+
"identifier" => @channel
|
170
|
+
})
|
171
|
+
|
172
|
+
wait_for_confirm
|
173
|
+
|
174
|
+
Buildkite::TestCollector.logger.info "Connected to Buildkite Test Analytics!"
|
175
|
+
Buildkite::TestCollector.logger.debug("connected")
|
176
|
+
end
|
177
|
+
|
178
|
+
def init_write_thread
|
179
|
+
# As this method can be called multiple times in the
|
180
|
+
# reconnection process, kill prev write threads (if any) before
|
181
|
+
# setting up the new one
|
182
|
+
@write_thread&.kill
|
183
|
+
|
184
|
+
@write_thread = Thread.new do
|
185
|
+
Buildkite::TestCollector.logger.debug("hello from write thread")
|
186
|
+
# Pretty sure this eternal loop is fine cos the call to queue.pop is blocking
|
187
|
+
loop do
|
188
|
+
data = @send_queue.pop
|
189
|
+
message_type = data["action"]
|
190
|
+
|
191
|
+
if message_type == "end_of_transmission"
|
192
|
+
# Because of the unpredictable sequencing between the test suite finishing
|
193
|
+
# (EOT gets queued) and disconnections happening (retransmit results gets
|
194
|
+
# queued), we don't want to send an EOT before any retransmits are sent.
|
195
|
+
if @send_queue.length > 0
|
196
|
+
@send_queue << data
|
197
|
+
Buildkite::TestCollector.logger.debug("putting eot at back of queue")
|
198
|
+
next
|
199
|
+
end
|
200
|
+
@eot_queued_mutex.synchronize do
|
201
|
+
@eot_queued = false
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
@connection.transmit({
|
206
|
+
"identifier" => @channel,
|
207
|
+
"command" => "message",
|
208
|
+
"data" => data.to_json
|
209
|
+
})
|
210
|
+
|
211
|
+
if Buildkite::TestCollector.debug_enabled
|
212
|
+
ids = if message_type == "record_results"
|
213
|
+
data["results"].map { |result| result["id"] }
|
214
|
+
end
|
215
|
+
Buildkite::TestCollector.logger.debug("transmitted #{message_type} #{ids}")
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def pop_with_timeout(message_type)
|
222
|
+
Timeout.timeout(30, Buildkite::TestCollector::TimeoutError, "Timeout: Waited 30 seconds for #{message_type}") do
|
223
|
+
@establish_subscription_queue.pop
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def wait_for_welcome
|
228
|
+
welcome = pop_with_timeout("welcome")
|
229
|
+
|
230
|
+
if welcome && welcome != { "type" => "welcome" }
|
231
|
+
raise InitialConnectionFailure.new("Wrong message received, expected a welcome, but received: #{welcome.inspect}")
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def wait_for_confirm
|
236
|
+
confirm = pop_with_timeout("confirm")
|
237
|
+
|
238
|
+
if confirm && confirm != { "type" => "confirm_subscription", "identifier" => @channel }
|
239
|
+
raise InitialConnectionFailure.new("Wrong message received, expected a confirm, but received: #{confirm.inspect}")
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def queue_and_track_result(ident, result_as_hash)
|
244
|
+
@idents_mutex.synchronize do
|
245
|
+
@unconfirmed_idents[ident] = result_as_hash
|
246
|
+
|
247
|
+
@send_queue << {
|
248
|
+
"action" => "record_results",
|
249
|
+
"results" => [result_as_hash]
|
250
|
+
}
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
def confirm_idents(idents)
|
255
|
+
retransmit_required = @closing
|
256
|
+
|
257
|
+
@idents_mutex.synchronize do
|
258
|
+
# Remove received idents from unconfirmed_idents
|
259
|
+
idents.each { |key| @unconfirmed_idents.delete(key) }
|
260
|
+
|
261
|
+
Buildkite::TestCollector.logger.debug("received confirm for indentifiers: #{idents}")
|
262
|
+
|
263
|
+
# This @empty ConditionVariable broadcasts every time that @unconfirmed_idents is
|
264
|
+
# empty, which will happen about every 10mb of data as that's when the server
|
265
|
+
# sends back confirmations.
|
266
|
+
#
|
267
|
+
# However, there aren't any threads waiting on this signal until after we
|
268
|
+
# send the EOT message, so the prior broadcasts shouldn't do anything.
|
269
|
+
if @unconfirmed_idents.empty?
|
270
|
+
@empty.broadcast
|
271
|
+
|
272
|
+
retransmit_required = false
|
273
|
+
|
274
|
+
Buildkite::TestCollector.logger.debug("all identifiers have been confirmed")
|
275
|
+
else
|
276
|
+
Buildkite::TestCollector.logger.debug("still waiting on confirm for identifiers: #{@unconfirmed_idents.keys}")
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# If we're closing, any unconfirmed results need to be retransmitted.
|
281
|
+
retransmit if retransmit_required
|
282
|
+
end
|
283
|
+
|
284
|
+
def send_eot
|
285
|
+
@eot_queued_mutex.synchronize do
|
286
|
+
return if @eot_queued
|
287
|
+
|
288
|
+
@send_queue << {
|
289
|
+
"action" => "end_of_transmission",
|
290
|
+
"examples_count" => @examples_count.to_json
|
291
|
+
}
|
292
|
+
@eot_queued = true
|
293
|
+
|
294
|
+
Buildkite::TestCollector.logger.debug("added EOT to send queue")
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def process_message(data)
|
299
|
+
# Check we're getting the data we expect
|
300
|
+
return unless data["identifier"] == @channel
|
301
|
+
|
302
|
+
case
|
303
|
+
when data["message"].key?("confirm")
|
304
|
+
confirm_idents(data["message"]["confirm"])
|
305
|
+
else
|
306
|
+
# unhandled message
|
307
|
+
Buildkite::TestCollector.logger.debug("received unhandled message #{data["message"]}")
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
def retransmit
|
312
|
+
@idents_mutex.synchronize do
|
313
|
+
results = @unconfirmed_idents.values
|
314
|
+
|
315
|
+
# queue the contents of the buffer, unless it's empty
|
316
|
+
if results.any?
|
317
|
+
@send_queue << {
|
318
|
+
"action" => "record_results",
|
319
|
+
"results" => results
|
320
|
+
}
|
321
|
+
|
322
|
+
Buildkite::TestCollector.logger.debug("queueing up retransmitted results #{@unconfirmed_idents.keys}")
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# if we were disconnected in the closing phase, then resend the EOT
|
327
|
+
# message so the server can persist the last upload part
|
328
|
+
send_eot if @closing
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "socket"
|
4
|
+
require "openssl"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
module Buildkite::TestCollector
|
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
|
+
Buildkite::TestCollector.logger.debug("listening in on socket")
|
70
|
+
frame = WebSocket::Frame::Incoming::Client.new
|
71
|
+
|
72
|
+
while @socket
|
73
|
+
frame << @socket.readpartial(4096)
|
74
|
+
|
75
|
+
while data = frame.next
|
76
|
+
@session.handle(self, data.data)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
# These get re-raise from session, we should fail gracefully
|
80
|
+
rescue *Buildkite::TestCollector::Session::DISCONNECTED_EXCEPTIONS => e
|
81
|
+
Buildkite::TestCollector.logger.error("We could not establish a connection with Buildkite Test Analytics. The error was: #{e.message}. If this is a problem, please contact support.")
|
82
|
+
rescue EOFError => e
|
83
|
+
Buildkite::TestCollector.logger.warn("#{e}")
|
84
|
+
if @socket
|
85
|
+
Buildkite::TestCollector.logger.warn("attempting disconnected flow")
|
86
|
+
@session.disconnected(self)
|
87
|
+
disconnect
|
88
|
+
end
|
89
|
+
rescue Errno::ECONNRESET => e
|
90
|
+
Buildkite::TestCollector.logger.error("#{e}")
|
91
|
+
if @socket
|
92
|
+
Buildkite::TestCollector.logger.error("attempting disconnected flow")
|
93
|
+
@session.disconnected(self)
|
94
|
+
disconnect
|
95
|
+
end
|
96
|
+
rescue IOError
|
97
|
+
# This is fine to ignore
|
98
|
+
Buildkite::TestCollector.logger.error("IOError")
|
99
|
+
rescue IndexError
|
100
|
+
# I don't like that we're doing this but I think it's the best of the options
|
101
|
+
#
|
102
|
+
# This relates to this issue https://github.com/ruby/openssl/issues/452
|
103
|
+
# A fix for it has been released but the repercussions of overriding
|
104
|
+
# the OpenSSL version in the stdlib seem worse than catching this error here.
|
105
|
+
Buildkite::TestCollector.logger.error("IndexError")
|
106
|
+
if @socket
|
107
|
+
Buildkite::TestCollector.logger.error("attempting disconnected flow")
|
108
|
+
@session.disconnected(self)
|
109
|
+
disconnect
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def transmit(data, type: :text)
|
115
|
+
# this line prevents us from calling disconnect twice
|
116
|
+
return if @socket.nil?
|
117
|
+
|
118
|
+
raw_data = data.to_json
|
119
|
+
frame = WebSocket::Frame::Outgoing::Client.new(data: raw_data, type: :text, version: @version)
|
120
|
+
@socket.write(frame.to_s)
|
121
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, OpenSSL::SSL::SSLError => e
|
122
|
+
return unless @socket
|
123
|
+
return if type == :close
|
124
|
+
Buildkite::TestCollector.logger.error("got #{e}, attempting disconnected flow")
|
125
|
+
@session.disconnected(self)
|
126
|
+
disconnect
|
127
|
+
rescue IndexError
|
128
|
+
# I don't like that we're doing this but I think it's the best of the options
|
129
|
+
#
|
130
|
+
# This relates to this issue https://github.com/ruby/openssl/issues/452
|
131
|
+
# A fix for it has been released but the repercussions of overriding
|
132
|
+
# the OpenSSL version in the stdlib seem worse than catching this error here.
|
133
|
+
Buildkite::TestCollector.logger.error("IndexError")
|
134
|
+
if @socket
|
135
|
+
Buildkite::TestCollector.logger.error("attempting disconnected flow")
|
136
|
+
@session.disconnected(self)
|
137
|
+
disconnect
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def close
|
142
|
+
Buildkite::TestCollector.logger.debug("socket close")
|
143
|
+
transmit(nil, type: :close)
|
144
|
+
disconnect
|
145
|
+
end
|
146
|
+
|
147
|
+
private
|
148
|
+
|
149
|
+
def disconnect
|
150
|
+
Buildkite::TestCollector.logger.debug("socket disconnect")
|
151
|
+
socket = @socket
|
152
|
+
@socket = nil
|
153
|
+
socket&.close
|
154
|
+
@thread&.join unless @thread == Thread.current
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
4
|
+
|
5
|
+
module Buildkite::TestCollector
|
6
|
+
class Tracer
|
7
|
+
class Span
|
8
|
+
attr_accessor :section, :start_at, :end_at, :detail, :children
|
9
|
+
|
10
|
+
def initialize(section, start_at, end_at, detail)
|
11
|
+
@section = section
|
12
|
+
@start_at = start_at
|
13
|
+
@end_at = end_at
|
14
|
+
@detail = detail
|
15
|
+
@children = []
|
16
|
+
end
|
17
|
+
|
18
|
+
def as_hash
|
19
|
+
{
|
20
|
+
section: section,
|
21
|
+
start_at: start_at,
|
22
|
+
end_at: end_at,
|
23
|
+
duration: end_at - start_at,
|
24
|
+
detail: detail,
|
25
|
+
children: children.map(&:as_hash),
|
26
|
+
}.with_indifferent_access
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
@top = Span.new(:top, Concurrent.monotonic_time, nil, {})
|
32
|
+
@stack = [@top]
|
33
|
+
end
|
34
|
+
|
35
|
+
def enter(section, **detail)
|
36
|
+
new_entry = Span.new(section, Concurrent.monotonic_time, nil, detail)
|
37
|
+
current_span.children << new_entry
|
38
|
+
@stack << new_entry
|
39
|
+
end
|
40
|
+
|
41
|
+
def leave
|
42
|
+
current_span.end_at = Concurrent.monotonic_time
|
43
|
+
@stack.pop
|
44
|
+
end
|
45
|
+
|
46
|
+
def backfill(section, duration, **detail)
|
47
|
+
new_entry = Span.new(section, Concurrent.monotonic_time - duration, Concurrent.monotonic_time, detail)
|
48
|
+
current_span.children << new_entry
|
49
|
+
end
|
50
|
+
|
51
|
+
def current_span
|
52
|
+
@stack.last
|
53
|
+
end
|
54
|
+
|
55
|
+
def finalize
|
56
|
+
raise "Stack not empty" unless @stack.size == 1
|
57
|
+
@top.end_at = Concurrent.monotonic_time
|
58
|
+
self
|
59
|
+
end
|
60
|
+
|
61
|
+
def history
|
62
|
+
@top.as_hash
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "websocket"
|
5
|
+
|
6
|
+
require_relative "tracer"
|
7
|
+
require_relative "network"
|
8
|
+
require_relative "object"
|
9
|
+
require_relative "session"
|
10
|
+
require_relative "ci"
|
11
|
+
require_relative "http_client"
|
12
|
+
|
13
|
+
require "active_support"
|
14
|
+
require "active_support/notifications"
|
15
|
+
|
16
|
+
require "securerandom"
|
17
|
+
|
18
|
+
module Buildkite::TestCollector
|
19
|
+
class Uploader
|
20
|
+
def self.traces
|
21
|
+
@traces ||= {}
|
22
|
+
end
|
23
|
+
|
24
|
+
REQUEST_EXCEPTIONS = [
|
25
|
+
URI::InvalidURIError,
|
26
|
+
Net::HTTPBadResponse,
|
27
|
+
Net::HTTPHeaderSyntaxError,
|
28
|
+
Net::ReadTimeout,
|
29
|
+
Net::OpenTimeout,
|
30
|
+
OpenSSL::SSL::SSLError,
|
31
|
+
OpenSSL::SSL::SSLErrorWaitReadable,
|
32
|
+
EOFError
|
33
|
+
]
|
34
|
+
|
35
|
+
def self.configure
|
36
|
+
Buildkite::TestCollector.logger.debug("hello from main thread")
|
37
|
+
|
38
|
+
if Buildkite::TestCollector.api_token
|
39
|
+
http = Buildkite::TestCollector::HTTPClient.new(Buildkite::TestCollector.url)
|
40
|
+
|
41
|
+
response = begin
|
42
|
+
http.post
|
43
|
+
rescue *Buildkite::TestCollector::Uploader::REQUEST_EXCEPTIONS => e
|
44
|
+
Buildkite::TestCollector.logger.error "Buildkite Test Analytics: Error communicating with the server: #{e.message}"
|
45
|
+
end
|
46
|
+
|
47
|
+
return unless response
|
48
|
+
|
49
|
+
case response.code
|
50
|
+
when "401"
|
51
|
+
Buildkite::TestCollector.logger.info "Buildkite Test Analytics: Invalid Suite API key. Please double check your Suite API key."
|
52
|
+
when "200"
|
53
|
+
json = JSON.parse(response.body)
|
54
|
+
|
55
|
+
if (socket_url = json["cable"]) && (channel = json["channel"])
|
56
|
+
Buildkite::TestCollector.session = Buildkite::TestCollector::Session.new(socket_url, http.authorization_header, channel)
|
57
|
+
end
|
58
|
+
else
|
59
|
+
request_id = response.to_hash["x-request-id"]
|
60
|
+
Buildkite::TestCollector.logger.info "rspec-buildkite-analytics could not establish an initial connection with Buildkite. You may be missing some data for this test suite, please contact support."
|
61
|
+
end
|
62
|
+
else
|
63
|
+
if !!ENV["BUILDKITE_BUILD_ID"]
|
64
|
+
Buildkite::TestCollector.logger.info "Buildkite Test Analytics: No Suite API key provided. You can get the API key from your Suite settings page."
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.tracer
|
70
|
+
Thread.current[:_buildkite_tracer]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|