gitlab-labkit 0.40.0 → 0.41.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 570e79f377fc9cfa11a87177437e6f66a6ed78c94958ad999744034922011e31
4
- data.tar.gz: a58252e9bb1792ac0e6ca7e65d565f49803e917eb845de065ffb4921b1bf405c
3
+ metadata.gz: 762df6d0e3348526c7b49929a3a6cdd82db05c80a9d83569a76bd10ddb131ca2
4
+ data.tar.gz: 0affeb965dd17cfc202a0b64419d46bb92419a051a70972cbab5d57e5844d027
5
5
  SHA512:
6
- metadata.gz: eb4718ca04e0c5769180e3e23e3e658ea914a60c12142efc6f26bfc8f25b9a29d467c1c8fb582f23827c4e6cf3c9d72e65d7a7a469ab37465eb479f650e8269a
7
- data.tar.gz: 90c9bff31da38826e4d60d6ac623f97257a78fbea63ea86aba8a15dd22df69e069ed628af48fc5f4695d58f1157c098f22a8e9676f97e8345522073349603bf5
6
+ metadata.gz: c703503bc9cf08155d72a0ffc61f7f8cc2c573353d67382e7b4d7b22ea554c2a14ee2e2d04f8804696fa95584843cd4a342cbcdb5e9cb8ac775bd0d36df006b3
7
+ data.tar.gz: 2a598ffe29d7382689637c605db8ba721c993e3fc4b033176b273b99515033577b16d82961007bb9e99da3feac5ec2419e58c7a45b354dfd820e4d9bfa9baa4b
@@ -1,5 +1,5 @@
1
1
  # DO NOT MANUALLY EDIT; Run ./scripts/update-asdf-version-variables.sh to update this
2
2
  variables:
3
3
  GL_ASDF_RUBY_VERSION: "3.4.5"
4
- GL_ASDF_SHELLCHECK_VERSION: "0.10.0"
4
+ GL_ASDF_SHELLCHECK_VERSION: "0.11"
5
5
  GL_ASDF_SHFMT_VERSION: "3.12"
@@ -5,7 +5,7 @@
5
5
  # exclude: '^fixtures/'
6
6
  repos:
7
7
  - repo: https://github.com/pre-commit/pre-commit-hooks
8
- rev: v5.0.0 # renovate:managed
8
+ rev: v6.0.0 # renovate:managed
9
9
  hooks:
10
10
  - id: trailing-whitespace
11
11
  - id: end-of-file-fixer
@@ -25,7 +25,7 @@ repos:
25
25
  # Documentation available at
26
26
  # https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/docs/pre-commit.md
27
27
  - repo: https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks
28
- rev: v2.85 # renovate:managed
28
+ rev: v2.92 # renovate:managed
29
29
 
30
30
  hooks:
31
31
  - id: shellcheck # Run shellcheck for changed Shell files
data/.rubocop_todo.yml CHANGED
@@ -257,7 +257,7 @@ RSpec/LetBeforeExamples:
257
257
  # Offense count: 20
258
258
  # Configuration parameters: AllowSubject.
259
259
  RSpec/MultipleMemoizedHelpers:
260
- Max: 7
260
+ Max: 8
261
261
 
262
262
  # Offense count: 24
263
263
  # Configuration parameters: EnforcedStyle, IgnoreSharedExamples.
data/.tool-versions CHANGED
@@ -1,3 +1,3 @@
1
1
  ruby 3.4.5
2
2
  shfmt 3.12
3
- shellcheck 0.10.0
3
+ shellcheck 0.11
data/README.md CHANGED
@@ -20,7 +20,7 @@ LabKit-Ruby provides functionality in a number of areas:
20
20
 
21
21
  1. `Labkit::Context` used for providing context information to log messages.
22
22
  1. `Labkit::Correlation` for accessing the correlation id. (Generated and propagated by `Labkit::Context`)
23
- 1. `Labkit::CoveredExperience` for tracking covered experiences. More on the [README](./lib/labkit/covered_experiences/README.md).
23
+ 1. `Labkit::CoveredExperience` for tracking covered experiences. More on the [README](./lib/labkit/covered_experience/README.md).
24
24
  1. `Labkit::FIPS` for checking for FIPS mode and using FIPS-compliant algorithms.
25
25
  1. `Labkit::Logging` for sanitizing log messages.
26
26
  1. `Labkit::Metrics` for metrics. More on the [README](./lib/labkit/metrics/README.md).
@@ -16,6 +16,23 @@ end
16
16
 
17
17
  This configuration affects all Covered Experience instances and their logging output.
18
18
 
19
+ ### Registry Path Configuration
20
+
21
+ By default, covered experience definitions are loaded from the `config/covered_experiences` directory. You can configure a custom registry path:
22
+
23
+ ```ruby
24
+ Labkit::CoveredExperience.configure do |config|
25
+ config.registry_path = "my/custom/path"
26
+ end
27
+ ```
28
+
29
+ This allows you to:
30
+ - Store covered experience definitions in a different directory structure
31
+ - Use different paths for different environments
32
+ - Organize definitions according to your application's needs
33
+
34
+ **Note:** The registry is automatically reset when the configuration changes, so the new path takes effect immediately.
35
+
19
36
  ### Covered Experience Definitions
20
37
 
21
38
  Covered experience definitions will be lazy loaded from the default directory (`config/covered_experiences`).
@@ -100,6 +117,40 @@ experience.checkpoint
100
117
  experience.complete
101
118
  ```
102
119
 
120
+ #### Resuming Experiences
121
+
122
+ You can resume a covered experience that was previously started and stored in the context. This is useful for distributed operations or when work spans multiple processes.
123
+
124
+ Just like the start method, we can use a block to automatically complete a covered experience:
125
+
126
+ ```ruby
127
+ # Resume an experience from context (with block)
128
+ Labkit::CoveredExperience.resume(:merge_request_creation) do |experience|
129
+ # Continue the work from where it left off
130
+ finalize_merge_request
131
+
132
+ # Add checkpoints as needed
133
+ experience.checkpoint
134
+
135
+ send_notifications
136
+ end
137
+ ```
138
+ Or manually:
139
+
140
+ ```ruby
141
+ # Resume an experience from context (manual control)
142
+ experience = Labkit::CoveredExperience.resume(:merge_request_creation)
143
+
144
+ # Continue the work
145
+ finalize_merge_request
146
+ experience.checkpoint
147
+
148
+ # Complete the experience
149
+ experience.complete
150
+ ```
151
+
152
+ **Note:** The `resume` method loads the start time from the Labkit context. If no covered experience data exists in the context, it behaves the same as calling methods on an unstarted experience (raises `UnstartedError` in development/test environments, or safely ignores in other environments).
153
+
103
154
  ### Error Handling
104
155
 
105
156
  When using the block form, errors are automatically captured:
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "labkit/covered_experience/experience"
5
+
6
+ module Labkit
7
+ module CoveredExperience
8
+ # The `Current` class represents a container for the current set
9
+ # of `Labkit::CoveredExperience::Experience` instances started and
10
+ # not yet completed.
11
+ #
12
+ # It uses `ActiveSupport::CurrentAttributes` to provide a thread-safe way to
13
+ # store and access experiences throughout the request and background job lifecycle.
14
+ #
15
+ # Example usage:
16
+ # Labkit::CoveredExperience::Current.active_experiences << my_experience
17
+ # Labkit::CoveredExperience::Current.rehydrate("create_merge_request", "start_time" => "2025-08-22T10:02:15.237Z")
18
+ class Current < ActiveSupport::CurrentAttributes
19
+ AGGREGATION_KEY = 'labkit_covered_experiences'
20
+
21
+ attribute :_active_experiences
22
+
23
+ def active_experiences
24
+ self._active_experiences ||= {}
25
+ end
26
+
27
+ def rehydrate(experience_id, **data)
28
+ instance = Labkit::CoveredExperience.get(experience_id).rehydrate(data)
29
+ active_experiences[instance.id] = instance
30
+ instance
31
+ end
32
+ end
33
+ end
34
+ end
@@ -4,6 +4,8 @@ module Labkit
4
4
  module CoveredExperience
5
5
  CoveredExperienceError = Class.new(StandardError)
6
6
  UnstartedError = Class.new(CoveredExperienceError)
7
+ CompletedError = Class.new(CoveredExperienceError)
7
8
  NotFoundError = Class.new(CoveredExperienceError)
9
+ ReservedKeywordError = Class.new(CoveredExperienceError)
8
10
  end
9
11
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/hash_with_indifferent_access'
4
+ require 'forwardable'
3
5
  require 'labkit/context'
6
+ require 'labkit/covered_experience/current'
4
7
  require 'labkit/covered_experience/error'
5
8
 
6
9
  module Labkit
@@ -12,15 +15,48 @@ module Labkit
12
15
  async_slow: 300
13
16
  }.freeze
14
17
 
18
+ RESERVED_KEYWORDS = %w[
19
+ checkpoint
20
+ covered_experience
21
+ feature_category
22
+ urgency
23
+ start_time
24
+ checkpoint_time
25
+ end_time
26
+ elapsed_time_s
27
+ urgency_threshold_s
28
+ error
29
+ error_message
30
+ success
31
+ ].freeze
32
+
15
33
  # The `Experience` class represents a single Covered Experience
16
34
  # event to be measured and reported.
17
35
  class Experience
18
- attr_reader :error
36
+ extend Forwardable
37
+
38
+ attr_reader :error, :start_time
19
39
 
20
40
  def initialize(definition)
21
41
  @definition = definition
22
42
  end
23
43
 
44
+ def id
45
+ @definition.covered_experience
46
+ end
47
+
48
+ # Rehydrate an Experience instance from serialized data.
49
+ #
50
+ # @param data [Hash] A hash of serialized data.
51
+ # @return [Experience]
52
+ def rehydrate(data = {})
53
+ @start_time = Time.iso8601(data["start_time"]) if data&.has_key?("start_time") && data["start_time"]
54
+ self
55
+ rescue ArgumentError
56
+ warn("Invalid #{id}, start_time: #{data['start_time']}")
57
+ self
58
+ end
59
+
24
60
  # Start the Covered Experience.
25
61
  #
26
62
  # @yield [self] When a block is provided, the experience will be completed automatically.
@@ -41,20 +77,14 @@ module Labkit
41
77
  # experience.complete
42
78
  def start(**extra, &)
43
79
  @start_time = Time.now.utc
44
- checkpoint_counter.increment(checkpoint: "start")
80
+ checkpoint_counter.increment(checkpoint: "start", **base_labels)
45
81
  log_event("start", **extra)
46
82
 
83
+ Labkit::CoveredExperience::Current.active_experiences[id] = self
84
+
47
85
  return self unless block_given?
48
86
 
49
- begin
50
- yield self
51
- self
52
- rescue StandardError => e
53
- error!(e)
54
- raise
55
- ensure
56
- complete(**extra)
57
- end
87
+ completable(**extra, &)
58
88
  end
59
89
 
60
90
  # Checkpoint the Covered Experience.
@@ -66,12 +96,26 @@ module Labkit
66
96
  return unless ensure_started!
67
97
 
68
98
  @checkpoint_time = Time.now.utc
69
- checkpoint_counter.increment(checkpoint: "intermediate")
99
+ checkpoint_counter.increment(checkpoint: "intermediate", **base_labels)
70
100
  log_event("intermediate", **extra)
71
101
 
72
102
  self
73
103
  end
74
104
 
105
+ # Resume the Covered Experience.
106
+ #
107
+ # @yield [self] When a block is provided, the experience will be completed automatically.
108
+ # @param extra [Hash] Additional data to include in the log
109
+ def resume(**extra, &)
110
+ return unless ensure_started!
111
+
112
+ checkpoint(checkpoint_action: 'resume', **extra)
113
+
114
+ return self unless block_given?
115
+
116
+ completable(**extra, &)
117
+ end
118
+
75
119
  # Complete the Covered Experience.
76
120
  #
77
121
  # @param extra [Hash] Additional data to include in the log event
@@ -79,14 +123,16 @@ module Labkit
79
123
  # @return [self]
80
124
  def complete(**extra)
81
125
  return unless ensure_started!
126
+ return unless ensure_incomplete!
82
127
 
83
128
  begin
84
129
  @end_time = Time.now.utc
85
130
  ensure
86
- checkpoint_counter.increment(checkpoint: "end")
87
- total_counter.increment(error: has_error?)
88
- apdex_counter.increment(success: apdex_success?) unless has_error?
131
+ checkpoint_counter.increment(checkpoint: "end", **base_labels)
132
+ total_counter.increment(error: has_error?, **base_labels)
133
+ apdex_counter.increment(success: apdex_success?, **base_labels) unless has_error?
89
134
  log_event("end", **extra)
135
+ Labkit::CoveredExperience::Current.active_experiences.delete(id)
90
136
  end
91
137
 
92
138
  self
@@ -105,6 +151,12 @@ module Labkit
105
151
  !!@error
106
152
  end
107
153
 
154
+ def to_h
155
+ return {} unless ensure_started!
156
+
157
+ { id => { "start_time" => @start_time&.iso8601(3) } }
158
+ end
159
+
108
160
  private
109
161
 
110
162
  def base_labels
@@ -120,6 +172,28 @@ module Labkit
120
172
  raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
121
173
  end
122
174
 
175
+ def completable(**extra, &)
176
+ begin
177
+ yield self
178
+ rescue StandardError => e
179
+ error!(e)
180
+ raise
181
+ ensure
182
+ complete(**extra)
183
+ end
184
+
185
+ self
186
+ end
187
+
188
+ def ensure_incomplete!
189
+ return true if @end_time.nil?
190
+
191
+ err = CompletedError.new("Covered Experience #{@definition.covered_experience} already completed")
192
+
193
+ warn(err)
194
+ raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
195
+ end
196
+
123
197
  def urgency_threshold
124
198
  URGENCY_THRESHOLDS_IN_SECONDS[@definition.urgency.to_sym]
125
199
  end
@@ -136,36 +210,35 @@ module Labkit
136
210
  def checkpoint_counter
137
211
  @checkpoint_counter ||= Labkit::Metrics::Client.counter(
138
212
  :gitlab_covered_experience_checkpoint_total,
139
- 'Total checkpoints for covered experiences',
140
- base_labels
213
+ 'Total checkpoints for covered experiences'
141
214
  )
142
215
  end
143
216
 
144
217
  def total_counter
145
218
  @total_counter ||= Labkit::Metrics::Client.counter(
146
219
  :gitlab_covered_experience_total,
147
- 'Total covered experience events (success/failure)',
148
- base_labels
220
+ 'Total covered experience events (success/failure)'
149
221
  )
150
222
  end
151
223
 
152
224
  def apdex_counter
153
225
  @apdex_counter ||= Labkit::Metrics::Client.counter(
154
226
  :gitlab_covered_experience_apdex_total,
155
- 'Total covered experience apdex events',
156
- base_labels
227
+ 'Total covered experience apdex events'
157
228
  )
158
229
  end
159
230
 
160
231
  def log_event(event_type, **extra)
232
+ validate_extra_parameters!(extra)
233
+
161
234
  log_data = build_log_data(event_type, **extra)
162
235
  logger.info(log_data)
163
236
  end
164
237
 
165
238
  def build_log_data(event_type, **extra)
166
- log_data = {
239
+ log_data = ActiveSupport::HashWithIndifferentAccess.new(
167
240
  checkpoint: event_type,
168
- covered_experience: @definition.covered_experience,
241
+ covered_experience: id,
169
242
  feature_category: @definition.feature_category,
170
243
  urgency: @definition.urgency,
171
244
  start_time: @start_time,
@@ -173,8 +246,8 @@ module Labkit
173
246
  end_time: @end_time,
174
247
  elapsed_time_s: elapsed_time,
175
248
  urgency_threshold_s: urgency_threshold
176
- }
177
- log_data.merge!(extra) if extra
249
+ )
250
+ log_data.reverse_merge!(extra) if extra
178
251
 
179
252
  if has_error?
180
253
  log_data[:error] = true
@@ -186,8 +259,24 @@ module Labkit
186
259
  log_data
187
260
  end
188
261
 
189
- def warn(exception)
190
- logger.warn(component: self.class.name, message: exception.message)
262
+ def warn(err, **extra)
263
+ case err
264
+ when StandardError
265
+ logger.warn(component: self.class.name, message: err.message, **extra)
266
+ when String
267
+ logger.warn(component: self.class.name, message: err, **extra)
268
+ end
269
+ end
270
+
271
+ def validate_extra_parameters!(extra)
272
+ return if extra.empty?
273
+
274
+ reserved_keys = extra.keys.map(&:to_s) & RESERVED_KEYWORDS
275
+ return if reserved_keys.empty?
276
+
277
+ err = ReservedKeywordError.new("Reserved keywords found in extra parameters: #{reserved_keys.join(', ')}")
278
+
279
+ raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
191
280
  end
192
281
 
193
282
  def logger
@@ -6,9 +6,14 @@ module Labkit
6
6
  class Null
7
7
  include Singleton
8
8
 
9
- attr_reader :id, :description, :feature_category, :urgency
9
+ def id = 'null'
10
10
 
11
- def start(*_args)
11
+ def start(**_extra)
12
+ yield self if block_given?
13
+ self
14
+ end
15
+
16
+ def resume(**_extra)
12
17
  yield self if block_given?
13
18
  self
14
19
  end
@@ -17,6 +22,8 @@ module Labkit
17
22
  def checkpoint(*_args) = self
18
23
  def complete(*_args) = self
19
24
  def error!(*_args) = self
25
+ def rehydrate(*_args, **_kwargs) = self
26
+ def to_h = {}
20
27
  end
21
28
  end
22
29
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'labkit/covered_experience/current'
3
4
  require 'labkit/covered_experience/error'
4
5
  require 'labkit/covered_experience/experience'
5
6
  require 'labkit/covered_experience/null'
@@ -15,10 +16,11 @@ module Labkit
15
16
  module CoveredExperience
16
17
  # Configuration class for CoveredExperience
17
18
  class Configuration
18
- attr_accessor :logger
19
+ attr_accessor :logger, :registry_path
19
20
 
20
21
  def initialize
21
22
  @logger = Labkit::Logging::JsonLogger.new($stdout)
23
+ @registry_path = File.join("config", "covered_experiences")
22
24
  end
23
25
  end
24
26
 
@@ -33,28 +35,45 @@ module Labkit
33
35
 
34
36
  def configure
35
37
  yield(configuration) if block_given?
38
+ # Reset registry when configuration changes to pick up new registry_path
39
+ @registry = nil
36
40
  end
37
41
 
38
42
  def registry
39
- @registry ||= Registry.new
43
+ @registry ||= Registry.new(dir: configuration.registry_path)
40
44
  end
41
45
 
42
46
  def reset
43
47
  @registry = nil
48
+ reset_configuration
44
49
  end
45
50
 
51
+ # Retrieves a covered experience using the experience_id.
52
+ # It retrieves from the current context when available,
53
+ # otherwise it instantiates a new experience with the definition
54
+ # from the registry.
55
+ #
56
+ # @param experience_id [String, Symbol] The ID of the experience to retrieve.
57
+ # @return [Experience, Null] The found experience or a Null object if not found (in production/staging).
46
58
  def get(experience_id)
47
- definition = registry[experience_id]
59
+ find_current(experience_id) || raise_or_null(experience_id)
60
+ end
48
61
 
49
- if definition
50
- Experience.new(definition)
51
- else
52
- raise_or_null(experience_id)
53
- end
62
+ # Starts a covered experience using the experience_id.
63
+ #
64
+ # @param experience_id [String, Symbol] The ID of the experience to start.
65
+ # @param extra [Hash] Additional data to include in the log event.
66
+ # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
67
+ def start(experience_id, **extra, &)
68
+ get(experience_id).start(**extra, &)
54
69
  end
55
70
 
56
- def start(experience_id, &)
57
- get(experience_id).start(&)
71
+ # Resumes a covered experience using the experience_id.
72
+ #
73
+ # @param experience_id [String, Symbol] The ID of the experience to resume.
74
+ # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
75
+ def resume(experience_id, **extra, &)
76
+ get(experience_id).resume(**extra, &)
58
77
  end
59
78
 
60
79
  private
@@ -64,6 +83,14 @@ module Labkit
64
83
 
65
84
  raise(NotFoundError, "Covered Experience #{experience_id} not found in the registry")
66
85
  end
86
+
87
+ def find_current(experience_id)
88
+ xp = Current.active_experiences[experience_id.to_s]
89
+ return xp unless xp.nil?
90
+
91
+ definition = registry[experience_id]
92
+ Experience.new(definition) if definition
93
+ end
67
94
  end
68
95
  end
69
96
  end
@@ -84,7 +84,7 @@ module Labkit
84
84
  if value.is_a?(Time)
85
85
  hash[key] = value.utc.iso8601(3)
86
86
  elsif value.is_a?(Hash)
87
- format_time(value)
87
+ format_time!(value)
88
88
  end
89
89
  end
90
90
  end
@@ -25,6 +25,8 @@ module Labkit
25
25
 
26
26
  [status, headers, response]
27
27
  end
28
+ ensure
29
+ reset_current_experiences
28
30
  end
29
31
 
30
32
  private
@@ -44,6 +46,27 @@ module Labkit
44
46
  .merge("version" => "1")
45
47
  .to_json
46
48
  end
49
+
50
+ # Reset Current experiences to prevent memory leaks and cross-request contamination.
51
+ #
52
+ # Rails applications automatically call ActiveSupport::CurrentAttributes.reset_all
53
+ # after each request via https://github.com/rails/rails/blob/2d4f445051b63853d48c642d0ab59ab026aa1d6a/activesupport/lib/active_support/railtie.rb,
54
+ # but we must handle other scenarios where this middleware might be used:
55
+ #
56
+ # 1. Non-Rails Rack applications (Sinatra, Grape, plain Rack apps)
57
+ # 2. Custom Rack stacks that don't include ActiveSupport::Railtie
58
+ # 3. Test environments where Rails middleware stack might be bypassed
59
+ # 4. Microservices or API-only applications with minimal middleware
60
+ # 5. Background job processors that use Rack but not Rails' full stack
61
+ #
62
+ # By explicitly resetting here, we ensure covered experiences are properly
63
+ # cleaned up regardless of the application framework or middleware configuration.
64
+ def reset_current_experiences
65
+ # Only reset if the Current class is loaded to avoid unnecessary dependencies
66
+ return unless defined?(Labkit::CoveredExperience::Current)
67
+
68
+ Labkit::CoveredExperience::Current.reset
69
+ end
47
70
  end
48
71
  end
49
72
  end
@@ -13,6 +13,7 @@ module Labkit
13
13
  @chain ||= ::Sidekiq::Middleware::Chain.new do |chain|
14
14
  chain.add Labkit::Middleware::Sidekiq::Context::Client
15
15
  chain.add Labkit::Middleware::Sidekiq::Tracing::Client if Labkit::Tracing.enabled?
16
+ chain.add Labkit::Middleware::Sidekiq::CoveredExperience::Client
16
17
  end
17
18
  end
18
19
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labkit/covered_experience/current'
4
+
5
+ module Labkit
6
+ module Middleware
7
+ module Sidekiq
8
+ module CoveredExperience
9
+ # This middleware for Sidekiq-client wraps scheduling jobs with covered
10
+ # experience context. It retrieves the current experiences and
11
+ # populates the job with them.
12
+ class Client
13
+ def call(worker_class, job, _queue, _redis_pool)
14
+ data = Labkit::CoveredExperience::Current.active_experiences.inject({}) do |data, (_, xp)|
15
+ xp.checkpoint(checkpoint_action: "sidekiq_job_scheduled", worker: worker_class.to_s)
16
+ data.merge!(xp.to_h)
17
+ end
18
+
19
+ job[Labkit::CoveredExperience::Current::AGGREGATION_KEY] = data unless data.empty?
20
+
21
+ yield
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module Middleware
5
+ module Sidekiq
6
+ module CoveredExperience
7
+ # This middleware for Sidekiq-server rehydrates the current experiences
8
+ # serialized to the job
9
+ class Server
10
+ def call(_worker_class, job, _queue)
11
+ job[Labkit::CoveredExperience::Current::AGGREGATION_KEY]&.each do |experience_id, data|
12
+ xp = Labkit::CoveredExperience::Current.rehydrate(experience_id, **data)
13
+ xp.checkpoint(checkpoint_action: "sidekiq_job_started", worker: job["class"].to_s)
14
+ end
15
+
16
+ yield
17
+
18
+ ensure
19
+ Labkit::CoveredExperience::Current.reset
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module Middleware
5
+ module Sidekiq
6
+ # This module contains all the sidekiq middleware regarding covered
7
+ # experiences
8
+ module CoveredExperience
9
+ autoload :Client, "labkit/middleware/sidekiq/covered_experience/client"
10
+ autoload :Server, "labkit/middleware/sidekiq/covered_experience/server"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -13,6 +13,7 @@ module Labkit
13
13
  @chain ||= ::Sidekiq::Middleware::Chain.new do |chain|
14
14
  chain.add Labkit::Middleware::Sidekiq::Context::Server
15
15
  chain.add Labkit::Middleware::Sidekiq::Tracing::Server if Labkit::Tracing.enabled?
16
+ chain.add Labkit::Middleware::Sidekiq::CoveredExperience::Server
16
17
  end
17
18
  end
18
19
 
@@ -7,6 +7,7 @@ module Labkit
7
7
  autoload :Client, "labkit/middleware/sidekiq/client"
8
8
  autoload :Server, "labkit/middleware/sidekiq/server"
9
9
  autoload :Context, "labkit/middleware/sidekiq/context"
10
+ autoload :CoveredExperience, "labkit/middleware/sidekiq/covered_experience"
10
11
  autoload :Tracing, "labkit/middleware/sidekiq/tracing"
11
12
  end
12
13
  end
@@ -45,6 +45,17 @@ expect { subject }.to checkpoint_covered_experience('rails_request')
45
45
  expect { subject }.not_to checkpoint_covered_experience('rails_request')
46
46
  ```
47
47
 
48
+ #### `resume_covered_experience`
49
+
50
+ Tests that a covered experience is resumed (checkpoint=intermediate metric is incremented). This is an alias for `checkpoint_covered_experience` that provides more semantic meaning when testing code that resumes a covered experience previously started.
51
+
52
+ ```ruby
53
+ expect { subject }.to resume_covered_experience('rails_request')
54
+
55
+ # Test that it does NOT resume
56
+ expect { subject }.not_to resume_covered_experience('rails_request')
57
+ ```
58
+
48
59
  #### `complete_covered_experience`
49
60
 
50
61
  Tests that a covered experience is completed with the expected metrics:
@@ -81,7 +81,7 @@ end
81
81
  #
82
82
  # Parameters:
83
83
  # - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
84
- RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id|
84
+ RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id, by: 1|
85
85
  include Labkit::RSpec::Matchers::CoveredExperience
86
86
 
87
87
  description { "checkpoint covered experience '#{covered_experience_id}'" }
@@ -97,12 +97,15 @@ RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id|
97
97
  checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
98
98
  @checkpoint_change = checkpoint_after - checkpoint_before
99
99
 
100
- @checkpoint_change == 1
100
+ # Automatic checkpoints can be created in-between depending on the context,
101
+ # such as pushing experiences to background jobs. For this reason, we check
102
+ # that the value increases by at least "by".
103
+ @checkpoint_change >= by
101
104
  end
102
105
 
103
106
  failure_message do
104
107
  "Failed to checkpoint covered experience '#{covered_experience_id}':\n" \
105
- "expected checkpoint='intermediate' counter to increase by 1, but increased by #{@checkpoint_change}"
108
+ "expected checkpoint='intermediate' counter to increase by at least #{by}, but increased by #{@checkpoint_change}"
106
109
  end
107
110
 
108
111
  match_when_negated do |actual|
@@ -120,10 +123,13 @@ RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id|
120
123
 
121
124
  failure_message_when_negated do
122
125
  "Expected covered experience '#{covered_experience_id}' NOT to checkpoint:\n" \
123
- "expected checkpoint='intermediate' counter to increase by 0, but increased by #{@checkpoint_change}"
126
+ "expected checkpoint='intermediate' counter not to increase, but increased by #{@checkpoint_change}"
124
127
  end
125
128
  end
126
129
 
130
+ # Alias for checkpoint_covered_experience matcher
131
+ RSpec::Matchers.alias_matcher :resume_covered_experience, :checkpoint_covered_experience
132
+
127
133
  # Matcher for verifying CoveredExperience completion metrics instrumentation.
128
134
  #
129
135
  # Usage:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.0
4
+ version: 0.41.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -460,6 +460,7 @@ files:
460
460
  - lib/labkit/correlation/grpc/server_interceptor.rb
461
461
  - lib/labkit/covered_experience.rb
462
462
  - lib/labkit/covered_experience/README.md
463
+ - lib/labkit/covered_experience/current.rb
463
464
  - lib/labkit/covered_experience/error.rb
464
465
  - lib/labkit/covered_experience/experience.rb
465
466
  - lib/labkit/covered_experience/null.rb
@@ -485,6 +486,9 @@ files:
485
486
  - lib/labkit/middleware/sidekiq/context.rb
486
487
  - lib/labkit/middleware/sidekiq/context/client.rb
487
488
  - lib/labkit/middleware/sidekiq/context/server.rb
489
+ - lib/labkit/middleware/sidekiq/covered_experience.rb
490
+ - lib/labkit/middleware/sidekiq/covered_experience/client.rb
491
+ - lib/labkit/middleware/sidekiq/covered_experience/server.rb
488
492
  - lib/labkit/middleware/sidekiq/server.rb
489
493
  - lib/labkit/middleware/sidekiq/tracing.rb
490
494
  - lib/labkit/middleware/sidekiq/tracing/client.rb