rspec-mergify 0.2.0-x86_64-linux → 0.3.0-x86_64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f780ead2142231a815ead11c224dfaa6755bf705face5454b912a0f6411f3711
4
- data.tar.gz: ca7701151c7015707c8a1e9d784844e7a8c939f21dd3c3afd01b61141e1038c5
3
+ metadata.gz: 4dabe88c5f64038181d8a4939b92178c44f3f06e8d83afdaa80a35d3683d8afd
4
+ data.tar.gz: 2de7f3aec3374465de5425ef58d7325f084626696b185f0e0d31011f82525c4f
5
5
  SHA512:
6
- metadata.gz: f45dc1da3c292a7a5f05a8531ee63bf83fb804d0051beb7667075742522451bb6c36e1f2d99f4e0cdb56bd66e445860c5d9387331ae7ff8ae5b97fd15df70f69
7
- data.tar.gz: 4b6295d176645780f74f2aebf3e6162110136163520624c4a274c12e7a06bd089103fc1f9d11712d5a6422e998fb3a4597f46a45830f3c8a74f463420dda3c0e
6
+ metadata.gz: 0a90d80f864b1a122e576f76475f2d91e46246a175e922da180dee5bb6205b7dd3e4efba5182747341676c6bd6aec5b523c556621910a6e5a65ad0935b0aadc2
7
+ data.tar.gz: d67e1e86f964c57c25aaf4afc0a45636c7f8e40279eccb9f3b7c2c0f9e8ab5cfe565c4fb5f10f69d7e5af9b0ca2af61e0061aadb964a5b8d9993f2b33b5ab436
data/README.md CHANGED
@@ -18,6 +18,23 @@ gem 'rspec-mergify'
18
18
 
19
19
  Then run `bundle install`.
20
20
 
21
+ ### Supported platforms
22
+
23
+ The gem ships precompiled, so nothing is built on your machine. Each platform gem carries an extension for Ruby 3.1 through 4.0:
24
+
25
+ | Platform | Requirement |
26
+ |---|---|
27
+ | Linux x86_64 / aarch64 (glibc) | **glibc 2.30 or newer** — Debian 11+, Ubuntu 20.04+, RHEL 9+, Amazon Linux 2023 |
28
+ | Linux x86_64 / aarch64 (musl) | Alpine; on Ruby 3.1 also `apk add libgcc` |
29
+ | macOS arm64 / x86_64 | — |
30
+ | Windows x64 (UCRT) | — |
31
+
32
+ Anywhere else, and on glibc older than 2.30 — RHEL, Rocky, AlmaLinux and Oracle Linux 8, Amazon Linux 2, CentOS 7 — the extension cannot load. The gem still installs and your suite still runs, but nothing is reported to Mergify. Pin the last pure-Ruby release on those systems:
33
+
34
+ ```ruby
35
+ gem 'rspec-mergify', '0.1.4'
36
+ ```
37
+
21
38
  ## Configuration
22
39
 
23
40
  Set the `MERGIFY_TOKEN` environment variable with your Mergify API token.
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -1,20 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
- require 'opentelemetry-sdk'
4
+ require_relative 'trace'
5
5
  require_relative 'utils'
6
6
  require_relative 'native'
7
- require_relative 'synchronous_batch_span_processor'
8
7
  require_relative 'resources/rspec'
9
8
 
10
9
  module Mergify
11
10
  module RSpec
12
- # Central orchestrator for Mergify Test Insights: sets up OpenTelemetry tracing,
13
- # manages the tracer provider, and coordinates flaky detection and quarantine.
14
- # rubocop:disable-next Metrics/ClassLength
11
+ # Central orchestrator for Mergify Test Insights: records the run's spans,
12
+ # uploads them, and coordinates flaky detection and quarantine.
15
13
  class CIInsights
16
14
  attr_reader :token, :repo_name, :api_url, :test_run_id,
17
- :tracer_provider, :tracer, :exporter,
15
+ :recorder,
18
16
  :branch_name,
19
17
  :flaky_detector, :flaky_detector_error_message, :quarantined_tests
20
18
 
@@ -24,9 +22,8 @@ module Mergify
24
22
  @repo_name = Native.detect_repository_name
25
23
  @api_url = ENV.fetch('MERGIFY_API_URL', 'https://api.mergify.com')
26
24
  @test_run_id = SecureRandom.hex(8)
27
- @tracer_provider = nil
28
- @tracer = nil
29
- @exporter = nil
25
+ @recorder = nil
26
+ @uploads = false
30
27
  @branch_name = nil
31
28
  @flaky_detector = nil
32
29
  @flaky_detector_error_message = nil
@@ -35,6 +32,21 @@ module Mergify
35
32
  setup_tracing if Utils.in_ci?
36
33
  end
37
34
 
35
+ # Send the run, once, at the end. Failing to report a run is worth
36
+ # saying out loud but never worth failing a suite that just passed,
37
+ # so this answers with a message instead of raising.
38
+ def flush
39
+ return nil unless @recorder && @uploads
40
+ return nil if @recorder.finished_spans.empty?
41
+
42
+ owner, repo = Utils.split_full_repo_name(@repo_name)
43
+ client = Native::Client.new(@api_url, @token, owner, repo, Mergify::RSpec::VERSION)
44
+ client.upload_trace(@recorder.resource_attributes, @recorder.finished_spans.map(&:to_h))
45
+ nil
46
+ rescue Native::ApiError, Utils::InvalidRepositoryFullNameError => e
47
+ e.message
48
+ end
49
+
38
50
  def mark_test_as_quarantined_if_needed(example_id) # rubocop:disable Naming/PredicateMethod
39
51
  return false unless @quarantined_tests&.include?(example_id)
40
52
 
@@ -44,30 +56,25 @@ module Mergify
44
56
 
45
57
  private
46
58
 
59
+ # Recording is unconditional; whether the run is *uploaded* is what the
60
+ # token and repository decide. Debug and test runs keep their spans and
61
+ # send nothing, which is what they always did -- the difference is that
62
+ # the collector is the same object either way instead of two processors.
47
63
  def setup_tracing
48
- processor, exp = build_processor
49
- return unless processor
50
-
51
- @exporter = exp
52
64
  resource = build_resource
53
- @tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
54
- @tracer_provider.add_span_processor(processor)
55
- @tracer = @tracer_provider.tracer('rspec-mergify', Mergify::RSpec::VERSION)
56
- @branch_name = extract_branch_name(resource)
65
+ @recorder = Trace::Recorder.new(resource_attributes: resource,
66
+ traceparent: ENV.fetch('MERGIFY_TRACEPARENT', nil))
67
+ @uploads = uploadable?
68
+ # Only a pull request has a base branch, and that is what puts flaky
69
+ # detection in 'new' mode. GitHub Actions still sets GITHUB_BASE_REF on
70
+ # every other event, to an empty string, so empty counts as absent.
71
+ base_branch_name = resource['vcs.ref.base.name']
72
+ @base_branch_name = base_branch_name unless base_branch_name.to_s.empty?
73
+ @branch_name = @base_branch_name || resource['vcs.ref.head.name']
57
74
  load_flaky_detector
58
75
  load_quarantine
59
76
  end
60
77
 
61
- def build_processor
62
- if debug_mode? || test_mode?
63
- build_in_memory_processor
64
- elsif @token && @repo_name
65
- build_otlp_processor
66
- else
67
- [nil, nil]
68
- end
69
- end
70
-
71
78
  def debug_mode?
72
79
  ENV.key?('RSPEC_MERGIFY_DEBUG')
73
80
  end
@@ -76,59 +83,23 @@ module Mergify
76
83
  ENV['_RSPEC_MERGIFY_TEST'] == 'true'
77
84
  end
78
85
 
79
- def build_in_memory_processor
80
- exp = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
81
- processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exp)
82
- [processor, exp]
83
- end
86
+ # A run is uploaded when there is somewhere to upload it to and nothing
87
+ # asking us not to: debug and test runs record and keep.
88
+ def uploadable?
89
+ return false if debug_mode? || test_mode?
90
+ return false unless @token && @repo_name && Native.available?
84
91
 
85
- def build_otlp_processor
86
- owner, repo = Utils.split_full_repo_name(@repo_name)
87
- endpoint = "#{@api_url}/v1/ci/#{owner}/repositories/#{repo}/traces"
88
- exp = create_otlp_exporter(endpoint)
89
- processor = SynchronousBatchSpanProcessor.new(exp)
90
- [processor, exp]
92
+ true
91
93
  end
92
94
 
93
95
  # The cicd.* and vcs.* attributes come from the Rust core, which every
94
- # Mergify test client shares, so a provider gains them everywhere at once.
95
- # What stays here is what only Ruby knows: the test framework, and the id
96
- # this run invented for itself.
96
+ # Mergify test client shares, so a CI provider added there reaches every
97
+ # client at once. What stays here is what only Ruby knows: the test
98
+ # framework and its language, and the id this run invented for itself.
97
99
  def build_resource
98
- resources = [
99
- OpenTelemetry::SDK::Resources::Resource.create(Native.detect_attributes),
100
- Resources::RSpec.detect,
101
- OpenTelemetry::SDK::Resources::Resource.create('test.run.id' => @test_run_id)
102
- ]
103
- resources.reduce(OpenTelemetry::SDK::Resources::Resource.create({})) do |merged, r|
104
- merged.merge(r)
105
- end
106
- end
107
-
108
- def extract_branch_name(resource)
109
- attrs = resource.attribute_enumerator.to_h
110
- @base_branch_name = attrs['vcs.ref.base.name']
111
- @base_branch_name || attrs['vcs.ref.head.name']
112
- end
113
-
114
- # rubocop:disable-next Metrics/MethodLength
115
- def create_otlp_exporter(endpoint)
116
- require 'opentelemetry-exporter-otlp'
117
- original_env = ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)
118
- ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = endpoint
119
- begin
120
- OpenTelemetry::Exporter::OTLP::Exporter.new(
121
- endpoint: endpoint,
122
- headers: { 'Authorization' => "Bearer #{@token}" },
123
- compression: 'gzip'
124
- )
125
- ensure
126
- if original_env
127
- ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = original_env
128
- else
129
- ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')
130
- end
131
- end
100
+ Native.detect_attributes
101
+ .merge(Resources::RSpec.detect)
102
+ .merge('test.run.id' => @test_run_id)
132
103
  end
133
104
 
134
105
  # rubocop:disable-next Metrics/MethodLength
@@ -126,8 +126,7 @@ module Mergify
126
126
  def set_test_deadline(test_id, timeout: nil)
127
127
  return unless @metrics.key?(test_id)
128
128
 
129
- remaining_tests = [remaining_tests_count, 1].max
130
- per_test_budget = remaining_budget / remaining_tests
129
+ per_test_budget = next_test_share
131
130
 
132
131
  allocated =
133
132
  if timeout
@@ -198,17 +197,21 @@ module Mergify
198
197
  context
199
198
  end
200
199
 
201
- def remaining_budget
202
- used = budget_used
203
- [@budget - used, 0.0].max
204
- end
205
-
206
200
  def budget_used
207
201
  @metrics.sum { |_, m| m.total_duration }
208
202
  end
209
203
 
210
- def remaining_tests_count
211
- @tests_to_process.count { |id| !@metrics.key?(id) || @metrics[id].deadline.nil? }
204
+ # What is left of the budget, divided over the tests still waiting for a
205
+ # share of it. The engine works in milliseconds; RSpec durations are
206
+ # seconds.
207
+ def next_test_share
208
+ Native::Budget.dynamic_share_ms(
209
+ @budget * 1000, budget_used * 1000, @tests_to_process.size, processed_tests_count
210
+ ) / 1000.0
211
+ end
212
+
213
+ def processed_tests_count
214
+ @tests_to_process.count { |id| @metrics.key?(id) && !@metrics[id].deadline.nil? }
212
215
  end
213
216
  end
214
217
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rspec/core/formatters/base_formatter'
4
- require 'opentelemetry-sdk'
4
+ require_relative 'trace'
5
+
6
+ require 'mergify/rspec/native'
5
7
 
6
8
  module Mergify
7
9
  module RSpec
8
- # RSpec formatter that creates OpenTelemetry spans for Mergify Test Insights and
10
+ # RSpec formatter that records spans for Mergify Test Insights and
9
11
  # prints a terminal report. It is purely observational and does not modify
10
12
  # test execution.
11
13
  # rubocop:disable-next Metrics/ClassLength
@@ -17,18 +19,14 @@ module Mergify
17
19
  :example_pending,
18
20
  :stop
19
21
 
20
- # rubocop:disable-next Metrics/MethodLength
21
22
  def start(notification)
22
23
  super
23
24
 
24
25
  @ci_insights = Mergify::RSpec.ci_insights
25
- return unless @ci_insights&.tracer
26
-
27
- extract_distributed_trace_context
26
+ return unless @ci_insights&.recorder
28
27
 
29
- @session_span = @ci_insights.tracer.start_span(
28
+ @session_span = @ci_insights.recorder.start_span(
30
29
  'rspec session start',
31
- with_parent: @parent_context,
32
30
  attributes: { 'test.scope' => 'session' }
33
31
  )
34
32
  @has_error = false
@@ -36,15 +34,14 @@ module Mergify
36
34
  end
37
35
 
38
36
  def example_started(notification)
39
- return unless @ci_insights&.tracer && @session_span
37
+ return unless @ci_insights&.recorder && @session_span
40
38
 
41
39
  example = notification.example
42
- parent_context = OpenTelemetry::Trace.context_with_span(@session_span)
43
40
  quarantined = @ci_insights.mark_test_as_quarantined_if_needed(example.id)
44
41
 
45
- span = @ci_insights.tracer.start_span(
42
+ span = @ci_insights.recorder.start_span(
46
43
  example.id,
47
- with_parent: parent_context,
44
+ parent: @session_span,
48
45
  attributes: build_example_attributes(example, quarantined)
49
46
  )
50
47
  @example_spans[example.id] = span
@@ -67,10 +64,10 @@ module Mergify
67
64
  set_error_attributes(span, result.exception)
68
65
  @has_error = true
69
66
  else
70
- span.status = OpenTelemetry::Trace::Status.ok
67
+ span.ok!
71
68
  end
72
69
 
73
- span.finish
70
+ @ci_insights.recorder.record(span)
74
71
  end
75
72
 
76
73
  def example_pending(notification)
@@ -81,7 +78,7 @@ module Mergify
81
78
  return unless span
82
79
 
83
80
  span.set_attribute('test.case.result.status', 'skipped')
84
- span.finish
81
+ @ci_insights.recorder.record(span)
85
82
  end
86
83
 
87
84
  def stop(_notification)
@@ -92,14 +89,6 @@ module Mergify
92
89
 
93
90
  private
94
91
 
95
- def extract_distributed_trace_context
96
- traceparent = ENV.fetch('MERGIFY_TRACEPARENT', nil)
97
- @parent_context = if traceparent
98
- propagator = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
99
- propagator.extract({ 'traceparent' => traceparent })
100
- end
101
- end
102
-
103
92
  def build_example_attributes(example, quarantined)
104
93
  {
105
94
  'test.scope' => 'case',
@@ -133,18 +122,18 @@ module Mergify
133
122
  span.set_attribute('exception.type', exception.class.to_s)
134
123
  span.set_attribute('exception.message', exception.message)
135
124
  span.set_attribute('exception.stacktrace', exception.backtrace&.join("\n") || '')
136
- span.status = OpenTelemetry::Trace::Status.error(exception.message)
125
+ span.error!(exception.message)
137
126
  end
138
127
 
139
128
  def finish_session_span
140
129
  return unless @session_span
141
130
 
142
- @session_span.status = if @has_error
143
- OpenTelemetry::Trace::Status.error('One or more tests failed')
144
- else
145
- OpenTelemetry::Trace::Status.ok
146
- end
147
- @session_span.finish
131
+ if @has_error
132
+ @session_span.error!('One or more tests failed')
133
+ else
134
+ @session_span.ok!
135
+ end
136
+ @ci_insights.recorder.record(@session_span)
148
137
  end
149
138
 
150
139
  # rubocop:disable-next Metrics/MethodLength
@@ -167,12 +156,21 @@ module Mergify
167
156
  def print_configuration_warnings
168
157
  output.puts 'WARNING: MERGIFY_TOKEN is not set. Traces will not be sent to Mergify.' unless @ci_insights.token
169
158
 
159
+ return print_native_warning unless Mergify::RSpec::Native.available?
170
160
  return if @ci_insights.repo_name
171
161
 
172
162
  output.puts 'WARNING: Could not detect repository name. ' \
173
163
  'Please set GITHUB_REPOSITORY or configure a git remote.'
174
164
  end
175
165
 
166
+ # The extension is what detects CI, so when it did not load there is no
167
+ # repository name either, and blaming that would send people hunting for a
168
+ # git remote that is fine. Say what actually happened instead.
169
+ def print_native_warning
170
+ output.puts 'WARNING: the Mergify native extension could not be loaded, so this run reports nothing: ' \
171
+ "#{Mergify::RSpec::Native.load_error&.message}"
172
+ end
173
+
176
174
  def print_flaky_report
177
175
  return unless @ci_insights.flaky_detector.respond_to?(:make_report)
178
176
 
@@ -187,20 +185,13 @@ module Mergify
187
185
  output.puts report if report
188
186
  end
189
187
 
190
- def flush_and_shutdown # rubocop:disable Metrics/MethodLength
191
- return unless @ci_insights&.tracer_provider
188
+ # One upload, at the end. There is nothing to shut down any more: the
189
+ # recorder holds spans in memory and the client owns the connection.
190
+ def flush_and_shutdown
191
+ return unless @ci_insights&.recorder
192
192
 
193
- begin
194
- @ci_insights.tracer_provider.force_flush
195
- rescue StandardError => e
196
- print_export_error(e)
197
- end
198
-
199
- begin
200
- @ci_insights.tracer_provider.shutdown
201
- rescue StandardError => e
202
- output.puts "Error while shutting down the tracer: #{e.message}"
203
- end
193
+ error = @ci_insights.flush
194
+ print_export_error(StandardError.new(error)) if error
204
195
  end
205
196
 
206
197
  def print_export_error(error)
@@ -27,21 +27,42 @@ module Mergify
27
27
  def available?
28
28
  load_error.nil?
29
29
  end
30
+
31
+ # Which of the two failed requires actually explains the absence.
32
+ #
33
+ # A precompiled gem ships lib/mergify/rspec/<ruby>/mergify_ci.<dlext>,
34
+ # so when one is packed for this Ruby that require is the real attempt:
35
+ # it fails with something like "version `GLIBC_2.29' not found", and the
36
+ # fallback that follows only ever adds "cannot load such file", which
37
+ # sends the reader looking for the wrong thing. With no extension for
38
+ # this Ruby -- the source gem, or a platform we publish no gem for --
39
+ # it is the other way round.
40
+ # Require a file next to this one, returning the LoadError instead of
41
+ # raising it. Two attempts read better as values than as nested rescues.
42
+ def attempt_require(path)
43
+ require_relative path
44
+ nil
45
+ rescue LoadError => e
46
+ e
47
+ end
48
+
49
+ def load_failure_reason(versioned, fallback)
50
+ packed = Dir.glob(File.join(__dir__, RUBY_VERSION.to_f.to_s, 'mergify_ci.*')).any?
51
+ packed ? versioned : fallback
52
+ end
30
53
  end
31
54
  end
32
55
  end
33
56
  end
34
57
 
35
- begin
36
- # Precompiled gems ship lib/mergify/rspec/<ruby>/mergify_ci.<dlext>.
37
- require_relative "#{RUBY_VERSION.to_f}/mergify_ci"
38
- rescue LoadError
39
- begin
40
- # Source gem, and any local `rake compile`.
41
- require_relative 'mergify_ci'
42
- rescue LoadError => e
43
- Mergify::RSpec::Native.load_error = e
44
- end
58
+ # Precompiled gems ship lib/mergify/rspec/<ruby>/mergify_ci.<dlext>; the source
59
+ # gem and any local `rake compile` put one beside this file instead.
60
+ versioned_error = Mergify::RSpec::Native.attempt_require("#{RUBY_VERSION.to_f}/mergify_ci")
61
+ fallback_error = versioned_error && Mergify::RSpec::Native.attempt_require('mergify_ci')
62
+
63
+ if fallback_error
64
+ Mergify::RSpec::Native.load_error =
65
+ Mergify::RSpec::Native.load_failure_reason(versioned_error, fallback_error)
45
66
  end
46
67
 
47
68
  unless Mergify::RSpec::Native.available?
@@ -1,20 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'opentelemetry-sdk'
4
3
  require 'rspec/core/version'
5
4
 
6
5
  module Mergify
7
6
  module RSpec
8
7
  module Resources
9
- # Detects OpenTelemetry Resource attributes for RSpec.
8
+ # The resource attributes only Ruby knows: which framework ran the suite,
9
+ # and in which language.
10
10
  module RSpec
11
11
  module_function
12
12
 
13
13
  def detect
14
- OpenTelemetry::SDK::Resources::Resource.create(
14
+ {
15
15
  'test.framework' => 'rspec',
16
- 'test.framework.version' => ::RSpec::Core::Version::STRING
17
- )
16
+ 'test.framework.version' => ::RSpec::Core::Version::STRING,
17
+ # Mergify takes a test's language from here when the span does not
18
+ # name one. The OpenTelemetry SDK sets this on its default resource,
19
+ # but this gem never used that resource, so nothing had ever sent it.
20
+ 'telemetry.sdk.language' => 'ruby'
21
+ }
18
22
  end
19
23
  end
20
24
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Mergify
6
+ module RSpec
7
+ # The little of OpenTelemetry this plugin actually used.
8
+ #
9
+ # A test run needs identifiers, a start and an end, some attributes and a
10
+ # status -- and the shared Rust client does the rest: the wire format, the
11
+ # compression, the retries, the size limit. The SDK brought samplers,
12
+ # context propagation machinery, batch processors and an exporter registry
13
+ # to do the part that fits here, and its dependency tree landed in every
14
+ # consumer's bundle. So the spans are assembled directly.
15
+ module Trace
16
+ # W3C traceparent: version-traceid-spanid-flags, all lowercase hex.
17
+ TRACEPARENT = /\A00-(?<trace_id>[0-9a-f]{32})-(?<span_id>[0-9a-f]{16})-[0-9a-f]{2}\z/
18
+
19
+ # A span being recorded, and once finished, the record itself.
20
+ class Span
21
+ attr_reader :name, :trace_id, :span_id, :parent_span_id, :attributes
22
+ attr_accessor :status, :status_message
23
+
24
+ def initialize(name:, trace_id:, span_id:, parent_span_id: nil, attributes: {})
25
+ @name = name
26
+ @trace_id = trace_id
27
+ @span_id = span_id
28
+ @parent_span_id = parent_span_id
29
+ @attributes = attributes.transform_keys(&:to_s)
30
+ @status = 'unset'
31
+ @status_message = nil
32
+ @start_unix_nano = Trace.now_unix_nano
33
+ @end_unix_nano = nil
34
+ end
35
+
36
+ # Ids travel as bytes and are read as hex -- in a traceparent, in a
37
+ # backend URL, in a log line.
38
+ def hex_trace_id
39
+ @trace_id.unpack1('H*')
40
+ end
41
+
42
+ def hex_span_id
43
+ @span_id.unpack1('H*')
44
+ end
45
+
46
+ def set_attribute(key, value)
47
+ @attributes[key.to_s] = value
48
+ end
49
+
50
+ def error!(message)
51
+ @status = 'error'
52
+ @status_message = message
53
+ end
54
+
55
+ def ok!
56
+ @status = 'ok'
57
+ end
58
+
59
+ def finish
60
+ @end_unix_nano ||= Trace.now_unix_nano
61
+ self
62
+ end
63
+
64
+ # The shape the binding accepts, which is the wire format's own.
65
+ # rubocop:disable-next Metrics/MethodLength
66
+ def to_h
67
+ {
68
+ 'name' => @name,
69
+ 'trace_id' => @trace_id,
70
+ 'span_id' => @span_id,
71
+ 'parent_span_id' => @parent_span_id,
72
+ 'start_unix_nano' => @start_unix_nano,
73
+ 'end_unix_nano' => @end_unix_nano || Trace.now_unix_nano,
74
+ 'attributes' => @attributes,
75
+ 'status' => @status,
76
+ 'status_message' => @status_message
77
+ }.compact
78
+ end
79
+ end
80
+
81
+ # Collects a run's spans and hands them to the client in one upload.
82
+ #
83
+ # Deliberately not streaming: a suite's spans are worth one request at the
84
+ # end, and exporting mid-run would put HTTP in the middle of the thing
85
+ # being timed. That was already why the gem replaced the SDK's batch
86
+ # processor with its own.
87
+ class Recorder
88
+ attr_reader :resource_attributes, :finished_spans, :trace_id
89
+
90
+ # `traceparent` is the W3C header a caller can hand down to put this
91
+ # run inside a trace it already started; the session span then hangs off
92
+ # that caller's span rather than starting a trace of its own.
93
+ def initialize(resource_attributes: {}, traceparent: nil)
94
+ @resource_attributes = resource_attributes.transform_keys(&:to_s)
95
+ inherited = Trace.parse_traceparent(traceparent)
96
+ @trace_id = inherited ? inherited.first : Trace.generate_trace_id
97
+ @root_parent_span_id = inherited&.last
98
+ @finished_spans = []
99
+ end
100
+
101
+ def start_span(name, parent: nil, attributes: {})
102
+ Span.new(
103
+ name: name,
104
+ trace_id: @trace_id,
105
+ span_id: Trace.generate_span_id,
106
+ parent_span_id: parent&.span_id || @root_parent_span_id,
107
+ attributes: attributes
108
+ )
109
+ end
110
+
111
+ def record(span)
112
+ @finished_spans << span.finish
113
+ span
114
+ end
115
+
116
+ def clear
117
+ @finished_spans = []
118
+ end
119
+ end
120
+
121
+ class << self
122
+ def now_unix_nano
123
+ (Time.now.to_r * 1_000_000_000).to_i
124
+ end
125
+
126
+ def generate_trace_id
127
+ SecureRandom.bytes(16)
128
+ end
129
+
130
+ def generate_span_id
131
+ SecureRandom.bytes(8)
132
+ end
133
+
134
+ # The trace this run belongs to, when a parent handed one down.
135
+ # Returns [trace_id, parent_span_id] as raw bytes, or nil.
136
+ def parse_traceparent(header)
137
+ match = TRACEPARENT.match(header.to_s)
138
+ return nil unless match
139
+
140
+ [[match[:trace_id]].pack('H*'), [match[:span_id]].pack('H*')]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -8,6 +8,6 @@ module Mergify
8
8
  # which resolved against whatever repository happened to be the working
9
9
  # directory at load time -- the monorepo's namespaced tags here, the user's
10
10
  # own tags once the gem was installed.
11
- VERSION = '0.2.0'
11
+ VERSION = '0.3.0'
12
12
  end
13
13
  end
metadata CHANGED
@@ -1,43 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-mergify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Mergify
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-11 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: opentelemetry-exporter-otlp
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '0.29'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '0.29'
27
- - !ruby/object:Gem::Dependency
28
- name: opentelemetry-sdk
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '1.4'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '1.4'
41
13
  - !ruby/object:Gem::Dependency
42
14
  name: rspec-core
43
15
  requirement: !ruby/object:Gem::Requirement
@@ -75,7 +47,7 @@ files:
75
47
  - lib/mergify/rspec/native.rb
76
48
  - lib/mergify/rspec/quarantine.rb
77
49
  - lib/mergify/rspec/resources/rspec.rb
78
- - lib/mergify/rspec/synchronous_batch_span_processor.rb
50
+ - lib/mergify/rspec/trace.rb
79
51
  - lib/mergify/rspec/utils.rb
80
52
  - lib/mergify/rspec/version.rb
81
53
  - lib/rspec_mergify.rb
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'opentelemetry-sdk'
4
-
5
- module Mergify
6
- module RSpec
7
- class ExportError < StandardError; end
8
-
9
- # A span processor that queues spans in memory and exports them all in one
10
- # batch when force_flush is called. This avoids HTTP requests during test
11
- # execution.
12
- class SynchronousBatchSpanProcessor < OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor
13
- def initialize(exporter)
14
- super
15
- @queue = []
16
- end
17
-
18
- def on_finish(span)
19
- return unless span.context.trace_flags.sampled?
20
-
21
- @queue << span.to_span_data
22
- end
23
-
24
- def force_flush(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
25
- spans = @queue.dup
26
- @queue.clear
27
- result = @span_exporter.export(spans)
28
- raise ExportError, 'Failed to export traces' unless result == OpenTelemetry::SDK::Trace::Export::SUCCESS
29
-
30
- OpenTelemetry::SDK::Trace::Export::SUCCESS
31
- end
32
- end
33
- end
34
- end