gitlab-labkit 0.40.0 → 0.41.1

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: 0d9f3f7db908df4f839a96f74ce39650dad4a132e5b72e3ac4d7fb1358bc48f1
4
+ data.tar.gz: 125b63a4f1716bfbb8dca8ab986c5f824dd2a56f8df46079041c6c62f5e385e5
5
5
  SHA512:
6
- metadata.gz: eb4718ca04e0c5769180e3e23e3e658ea914a60c12142efc6f26bfc8f25b9a29d467c1c8fb582f23827c4e6cf3c9d72e65d7a7a469ab37465eb479f650e8269a
7
- data.tar.gz: 90c9bff31da38826e4d60d6ac623f97257a78fbea63ea86aba8a15dd22df69e069ed628af48fc5f4695d58f1157c098f22a8e9676f97e8345522073349603bf5
6
+ metadata.gz: 92907d96e457abebe07567df0506f80c585f159ead4b78b6bdf8c57b3eb82d19c7700b88d2733415dad5c0d3bba792c23ae9b1e2281df9fef6ec44b7069d453c
7
+ data.tar.gz: 678ab5ad3290e93ff0682ef1f5f08681ea85e01c65919c3c16f21c95949065fa70c20507499b20c2c72f1a856838650fe79963533b529a463d9fc5a6f99a3bfc
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,60 @@ 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
95
  return 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 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
123
  return unless ensure_started!
124
+ return unless ensure_incomplete!
82
125
 
83
126
  begin
84
127
  @end_time = Time.now.utc
85
128
  ensure
86
- checkpoint_counter.increment(checkpoint: "end")
87
- total_counter.increment(error: has_error?)
88
- apdex_counter.increment(success: apdex_success?) unless has_error?
129
+ checkpoint_counter.increment(checkpoint: "end", **base_labels)
130
+ total_counter.increment(error: has_error?, **base_labels)
131
+ apdex_counter.increment(success: apdex_success?, **base_labels) unless has_error?
89
132
  log_event("end", **extra)
133
+ Labkit::CoveredExperience::Current.active_experiences.delete(id)
90
134
  end
91
135
 
92
136
  self
@@ -105,6 +149,12 @@ module Labkit
105
149
  !!@error
106
150
  end
107
151
 
152
+ def to_h
153
+ return {} unless ensure_started!
154
+
155
+ { id => { "start_time" => @start_time&.iso8601(3) } }
156
+ end
157
+
108
158
  private
109
159
 
110
160
  def base_labels
@@ -114,10 +164,28 @@ module Labkit
114
164
  def ensure_started!
115
165
  return @start_time unless @start_time.nil?
116
166
 
117
- err = UnstartedError.new("Covered Experience #{@definition.covered_experience} not started")
167
+ warn("Covered Experience #{@definition.covered_experience} not started")
168
+ false
169
+ end
118
170
 
119
- warn(err)
120
- raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
171
+ def completable(**extra, &)
172
+ begin
173
+ yield self
174
+ rescue StandardError => e
175
+ error!(e)
176
+ raise
177
+ ensure
178
+ complete(**extra)
179
+ end
180
+
181
+ self
182
+ end
183
+
184
+ def ensure_incomplete!
185
+ return true if @end_time.nil?
186
+
187
+ warn("Covered Experience #{@definition.covered_experience} already completed")
188
+ false
121
189
  end
122
190
 
123
191
  def urgency_threshold
@@ -125,6 +193,8 @@ module Labkit
125
193
  end
126
194
 
127
195
  def elapsed_time
196
+ return 0 unless @start_time
197
+
128
198
  last_time = @end_time || @checkpoint_time || @start_time
129
199
  last_time - @start_time
130
200
  end
@@ -136,36 +206,35 @@ module Labkit
136
206
  def checkpoint_counter
137
207
  @checkpoint_counter ||= Labkit::Metrics::Client.counter(
138
208
  :gitlab_covered_experience_checkpoint_total,
139
- 'Total checkpoints for covered experiences',
140
- base_labels
209
+ 'Total checkpoints for covered experiences'
141
210
  )
142
211
  end
143
212
 
144
213
  def total_counter
145
214
  @total_counter ||= Labkit::Metrics::Client.counter(
146
215
  :gitlab_covered_experience_total,
147
- 'Total covered experience events (success/failure)',
148
- base_labels
216
+ 'Total covered experience events (success/failure)'
149
217
  )
150
218
  end
151
219
 
152
220
  def apdex_counter
153
221
  @apdex_counter ||= Labkit::Metrics::Client.counter(
154
222
  :gitlab_covered_experience_apdex_total,
155
- 'Total covered experience apdex events',
156
- base_labels
223
+ 'Total covered experience apdex events'
157
224
  )
158
225
  end
159
226
 
160
227
  def log_event(event_type, **extra)
228
+ validate_extra_parameters!(extra)
229
+
161
230
  log_data = build_log_data(event_type, **extra)
162
231
  logger.info(log_data)
163
232
  end
164
233
 
165
234
  def build_log_data(event_type, **extra)
166
- log_data = {
235
+ log_data = ActiveSupport::HashWithIndifferentAccess.new(
167
236
  checkpoint: event_type,
168
- covered_experience: @definition.covered_experience,
237
+ covered_experience: id,
169
238
  feature_category: @definition.feature_category,
170
239
  urgency: @definition.urgency,
171
240
  start_time: @start_time,
@@ -173,8 +242,8 @@ module Labkit
173
242
  end_time: @end_time,
174
243
  elapsed_time_s: elapsed_time,
175
244
  urgency_threshold_s: urgency_threshold
176
- }
177
- log_data.merge!(extra) if extra
245
+ )
246
+ log_data.reverse_merge!(extra) if extra
178
247
 
179
248
  if has_error?
180
249
  log_data[:error] = true
@@ -186,8 +255,24 @@ module Labkit
186
255
  log_data
187
256
  end
188
257
 
189
- def warn(exception)
190
- logger.warn(component: self.class.name, message: exception.message)
258
+ def warn(err, **extra)
259
+ case err
260
+ when StandardError
261
+ logger.warn(component: self.class.name, message: err.message, **extra)
262
+ when String
263
+ logger.warn(component: self.class.name, message: err, **extra)
264
+ end
265
+ end
266
+
267
+ def validate_extra_parameters!(extra)
268
+ return if extra.empty?
269
+
270
+ reserved_keys = extra.keys.map(&:to_s) & RESERVED_KEYWORDS
271
+ return if reserved_keys.empty?
272
+
273
+ err = ReservedKeywordError.new("Reserved keywords found in extra parameters: #{reserved_keys.join(', ')}")
274
+
275
+ raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
191
276
  end
192
277
 
193
278
  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.1
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