gitlab-labkit 0.40.0 → 0.41.2

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: 021f710ca320399402124ec377576c549cb5e323f59da70babd4e70f576cf12f
4
+ data.tar.gz: ef32fd2ac8e127847a7124f73155b8fc0d7397d2d07a563dde469d0f3c08b58d
5
5
  SHA512:
6
- metadata.gz: eb4718ca04e0c5769180e3e23e3e658ea914a60c12142efc6f26bfc8f25b9a29d467c1c8fb582f23827c4e6cf3c9d72e65d7a7a469ab37465eb479f650e8269a
7
- data.tar.gz: 90c9bff31da38826e4d60d6ac623f97257a78fbea63ea86aba8a15dd22df69e069ed628af48fc5f4695d58f1157c098f22a8e9676f97e8345522073349603bf5
6
+ metadata.gz: d6fa54d12f6fc16838d116316ca612ec5bc243e282b5b24ffca56547f9a4192e2ae59db05499cf18d4bb2d2f97c4295bb424dffadaa47f760c3614f8f6099e17
7
+ data.tar.gz: 6ed7ef8bc8a7fa9cfe212ef1329c4dd86ec5613db5e6b80b42cc8e432ba49f10b6af200fdd10a46fd0e63198be4fd061a3555627002de158950adf1a2060ae47
data/.copier-answers.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.35.1
6
+ _commit: v1.36.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  golang: false
@@ -1,5 +1,5 @@
1
1
  # DO NOT MANUALLY EDIT; Run ./scripts/update-asdf-version-variables.sh to update this
2
2
  variables:
3
- GL_ASDF_RUBY_VERSION: "3.4.5"
4
- GL_ASDF_SHELLCHECK_VERSION: "0.10.0"
3
+ GL_ASDF_RUBY_VERSION: "3.4.6"
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
- ruby 3.4.5
1
+ ruby 3.4.6
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 and safely ignores the operation.
153
+
103
154
  ### Error Handling
104
155
 
105
156
  When using the block form, errors are automatically captured:
@@ -131,4 +182,4 @@ end
131
182
 
132
183
  - In `development` and `test` environments, accessing a non-existent covered experience will raise a `NotFoundError`
133
184
  - In other environments, a null object is returned that safely ignores all method calls
134
- - Attempting to checkpoint or complete an unstarted experience will raise an `UnstartedError` in `development` and `test` environments
185
+ - Attempting to checkpoint or complete an unstarted experience will safely ignore the operation
@@ -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
@@ -3,7 +3,7 @@
3
3
  module Labkit
4
4
  module CoveredExperience
5
5
  CoveredExperienceError = Class.new(StandardError)
6
- UnstartedError = Class.new(CoveredExperienceError)
7
6
  NotFoundError = Class.new(CoveredExperienceError)
7
+ ReservedKeywordError = Class.new(CoveredExperienceError)
8
8
  end
9
9
  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,52 +77,59 @@ 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.
61
91
  #
62
92
  # @param extra [Hash] Additional data to include in the log event
63
- # @raise [UnstartedError] If the experience has not been started and RAILS_ENV is development or test.
64
93
  # @return [self]
65
94
  def checkpoint(**extra)
66
- return unless ensure_started!
95
+ return self unless ensure_started!
67
96
 
68
97
  @checkpoint_time = Time.now.utc
69
- checkpoint_counter.increment(checkpoint: "intermediate")
98
+ checkpoint_counter.increment(checkpoint: "intermediate", **base_labels)
70
99
  log_event("intermediate", **extra)
71
100
 
72
101
  self
73
102
  end
74
103
 
104
+ # Resume the Covered Experience.
105
+ #
106
+ # @yield [self] When a block is provided, the experience will be completed automatically.
107
+ # @param extra [Hash] Additional data to include in the log
108
+ def resume(**extra, &)
109
+ return self unless ensure_started!
110
+
111
+ checkpoint(checkpoint_action: 'resume', **extra)
112
+
113
+ return self unless block_given?
114
+
115
+ completable(**extra, &)
116
+ end
117
+
75
118
  # Complete the Covered Experience.
76
119
  #
77
120
  # @param extra [Hash] Additional data to include in the log event
78
- # @raise [UnstartedError] If the experience has not been started and RAILS_ENV is development or test.
79
121
  # @return [self]
80
122
  def complete(**extra)
81
- return unless ensure_started!
123
+ return self unless ensure_started! && ensure_incomplete!
82
124
 
83
125
  begin
84
126
  @end_time = Time.now.utc
85
127
  ensure
86
- checkpoint_counter.increment(checkpoint: "end")
87
- total_counter.increment(error: has_error?)
88
- apdex_counter.increment(success: apdex_success?) unless has_error?
128
+ checkpoint_counter.increment(checkpoint: "end", **base_labels)
129
+ total_counter.increment(error: has_error?, **base_labels)
130
+ apdex_counter.increment(success: apdex_success?, **base_labels) unless has_error?
89
131
  log_event("end", **extra)
132
+ Labkit::CoveredExperience::Current.active_experiences.delete(id)
90
133
  end
91
134
 
92
135
  self
@@ -105,6 +148,12 @@ module Labkit
105
148
  !!@error
106
149
  end
107
150
 
151
+ def to_h
152
+ return {} unless ensure_started!
153
+
154
+ { id => { "start_time" => @start_time&.iso8601(3) } }
155
+ end
156
+
108
157
  private
109
158
 
110
159
  def base_labels
@@ -114,10 +163,28 @@ module Labkit
114
163
  def ensure_started!
115
164
  return @start_time unless @start_time.nil?
116
165
 
117
- err = UnstartedError.new("Covered Experience #{@definition.covered_experience} not started")
166
+ warn("Covered Experience #{@definition.covered_experience} not started")
167
+ false
168
+ end
118
169
 
119
- warn(err)
120
- raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
170
+ def completable(**extra, &)
171
+ begin
172
+ yield self
173
+ rescue StandardError => e
174
+ error!(e)
175
+ raise
176
+ ensure
177
+ complete(**extra)
178
+ end
179
+
180
+ self
181
+ end
182
+
183
+ def ensure_incomplete!
184
+ return true if @end_time.nil?
185
+
186
+ warn("Covered Experience #{@definition.covered_experience} already completed")
187
+ false
121
188
  end
122
189
 
123
190
  def urgency_threshold
@@ -125,6 +192,8 @@ module Labkit
125
192
  end
126
193
 
127
194
  def elapsed_time
195
+ return 0 unless @start_time
196
+
128
197
  last_time = @end_time || @checkpoint_time || @start_time
129
198
  last_time - @start_time
130
199
  end
@@ -136,36 +205,35 @@ module Labkit
136
205
  def checkpoint_counter
137
206
  @checkpoint_counter ||= Labkit::Metrics::Client.counter(
138
207
  :gitlab_covered_experience_checkpoint_total,
139
- 'Total checkpoints for covered experiences',
140
- base_labels
208
+ 'Total checkpoints for covered experiences'
141
209
  )
142
210
  end
143
211
 
144
212
  def total_counter
145
213
  @total_counter ||= Labkit::Metrics::Client.counter(
146
214
  :gitlab_covered_experience_total,
147
- 'Total covered experience events (success/failure)',
148
- base_labels
215
+ 'Total covered experience events (success/failure)'
149
216
  )
150
217
  end
151
218
 
152
219
  def apdex_counter
153
220
  @apdex_counter ||= Labkit::Metrics::Client.counter(
154
221
  :gitlab_covered_experience_apdex_total,
155
- 'Total covered experience apdex events',
156
- base_labels
222
+ 'Total covered experience apdex events'
157
223
  )
158
224
  end
159
225
 
160
226
  def log_event(event_type, **extra)
227
+ validate_extra_parameters!(extra)
228
+
161
229
  log_data = build_log_data(event_type, **extra)
162
230
  logger.info(log_data)
163
231
  end
164
232
 
165
233
  def build_log_data(event_type, **extra)
166
- log_data = {
234
+ log_data = ActiveSupport::HashWithIndifferentAccess.new(
167
235
  checkpoint: event_type,
168
- covered_experience: @definition.covered_experience,
236
+ covered_experience: id,
169
237
  feature_category: @definition.feature_category,
170
238
  urgency: @definition.urgency,
171
239
  start_time: @start_time,
@@ -173,8 +241,8 @@ module Labkit
173
241
  end_time: @end_time,
174
242
  elapsed_time_s: elapsed_time,
175
243
  urgency_threshold_s: urgency_threshold
176
- }
177
- log_data.merge!(extra) if extra
244
+ )
245
+ log_data.reverse_merge!(extra) if extra
178
246
 
179
247
  if has_error?
180
248
  log_data[:error] = true
@@ -186,8 +254,24 @@ module Labkit
186
254
  log_data
187
255
  end
188
256
 
189
- def warn(exception)
190
- logger.warn(component: self.class.name, message: exception.message)
257
+ def warn(err, **extra)
258
+ case err
259
+ when StandardError
260
+ logger.warn(component: self.class.name, message: err.message, **extra)
261
+ when String
262
+ logger.warn(component: self.class.name, message: err, **extra)
263
+ end
264
+ end
265
+
266
+ def validate_extra_parameters!(extra)
267
+ return if extra.empty?
268
+
269
+ reserved_keys = extra.keys.map(&:to_s) & RESERVED_KEYWORDS
270
+ return if reserved_keys.empty?
271
+
272
+ err = ReservedKeywordError.new("Reserved keywords found in extra parameters: #{reserved_keys.join(', ')}")
273
+
274
+ raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
191
275
  end
192
276
 
193
277
  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.2
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