gitlab-labkit 0.39.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.
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/hash_with_indifferent_access'
4
+ require 'forwardable'
5
+ require 'labkit/context'
6
+ require 'labkit/covered_experience/current'
7
+ require 'labkit/covered_experience/error'
8
+
9
+ module Labkit
10
+ module CoveredExperience
11
+ URGENCY_THRESHOLDS_IN_SECONDS = {
12
+ sync_fast: 2,
13
+ sync_slow: 5,
14
+ async_fast: 15,
15
+ async_slow: 300
16
+ }.freeze
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
+
33
+ # The `Experience` class represents a single Covered Experience
34
+ # event to be measured and reported.
35
+ class Experience
36
+ extend Forwardable
37
+
38
+ attr_reader :error, :start_time
39
+
40
+ def initialize(definition)
41
+ @definition = definition
42
+ end
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
+
60
+ # Start the Covered Experience.
61
+ #
62
+ # @yield [self] When a block is provided, the experience will be completed automatically.
63
+ # @param extra [Hash] Additional data to include in the log event
64
+ # @return [self]
65
+ # @raise [CoveredExperienceError] If the block raises an error.
66
+ #
67
+ # Usage:
68
+ #
69
+ # CoveredExperience.new(definition).start do |experience|
70
+ # experience.checkpoint
71
+ # experience.checkpoint
72
+ # end
73
+ #
74
+ # experience = CoveredExperience.new(definition)
75
+ # experience.start
76
+ # experience.checkpoint
77
+ # experience.complete
78
+ def start(**extra, &)
79
+ @start_time = Time.now.utc
80
+ checkpoint_counter.increment(checkpoint: "start", **base_labels)
81
+ log_event("start", **extra)
82
+
83
+ Labkit::CoveredExperience::Current.active_experiences[id] = self
84
+
85
+ return self unless block_given?
86
+
87
+ completable(**extra, &)
88
+ end
89
+
90
+ # Checkpoint the Covered Experience.
91
+ #
92
+ # @param extra [Hash] Additional data to include in the log event
93
+ # @raise [UnstartedError] If the experience has not been started and RAILS_ENV is development or test.
94
+ # @return [self]
95
+ def checkpoint(**extra)
96
+ return unless ensure_started!
97
+
98
+ @checkpoint_time = Time.now.utc
99
+ checkpoint_counter.increment(checkpoint: "intermediate", **base_labels)
100
+ log_event("intermediate", **extra)
101
+
102
+ self
103
+ end
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
+
119
+ # Complete the Covered Experience.
120
+ #
121
+ # @param extra [Hash] Additional data to include in the log event
122
+ # @raise [UnstartedError] If the experience has not been started and RAILS_ENV is development or test.
123
+ # @return [self]
124
+ def complete(**extra)
125
+ return unless ensure_started!
126
+ return unless ensure_incomplete!
127
+
128
+ begin
129
+ @end_time = Time.now.utc
130
+ ensure
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?
134
+ log_event("end", **extra)
135
+ Labkit::CoveredExperience::Current.active_experiences.delete(id)
136
+ end
137
+
138
+ self
139
+ end
140
+
141
+ # Marks the experience as failed with an error
142
+ #
143
+ # @param error [StandardError, String] The error that caused the experience to fail.
144
+ # @return [self]
145
+ def error!(error)
146
+ @error = error
147
+ self
148
+ end
149
+
150
+ def has_error?
151
+ !!@error
152
+ end
153
+
154
+ def to_h
155
+ return {} unless ensure_started!
156
+
157
+ { id => { "start_time" => @start_time&.iso8601(3) } }
158
+ end
159
+
160
+ private
161
+
162
+ def base_labels
163
+ @base_labels ||= @definition.to_h.slice(:covered_experience, :feature_category, :urgency)
164
+ end
165
+
166
+ def ensure_started!
167
+ return @start_time unless @start_time.nil?
168
+
169
+ err = UnstartedError.new("Covered Experience #{@definition.covered_experience} not started")
170
+
171
+ warn(err)
172
+ raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
173
+ end
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
+
197
+ def urgency_threshold
198
+ URGENCY_THRESHOLDS_IN_SECONDS[@definition.urgency.to_sym]
199
+ end
200
+
201
+ def elapsed_time
202
+ last_time = @end_time || @checkpoint_time || @start_time
203
+ last_time - @start_time
204
+ end
205
+
206
+ def apdex_success?
207
+ elapsed_time <= urgency_threshold
208
+ end
209
+
210
+ def checkpoint_counter
211
+ @checkpoint_counter ||= Labkit::Metrics::Client.counter(
212
+ :gitlab_covered_experience_checkpoint_total,
213
+ 'Total checkpoints for covered experiences'
214
+ )
215
+ end
216
+
217
+ def total_counter
218
+ @total_counter ||= Labkit::Metrics::Client.counter(
219
+ :gitlab_covered_experience_total,
220
+ 'Total covered experience events (success/failure)'
221
+ )
222
+ end
223
+
224
+ def apdex_counter
225
+ @apdex_counter ||= Labkit::Metrics::Client.counter(
226
+ :gitlab_covered_experience_apdex_total,
227
+ 'Total covered experience apdex events'
228
+ )
229
+ end
230
+
231
+ def log_event(event_type, **extra)
232
+ validate_extra_parameters!(extra)
233
+
234
+ log_data = build_log_data(event_type, **extra)
235
+ logger.info(log_data)
236
+ end
237
+
238
+ def build_log_data(event_type, **extra)
239
+ log_data = ActiveSupport::HashWithIndifferentAccess.new(
240
+ checkpoint: event_type,
241
+ covered_experience: id,
242
+ feature_category: @definition.feature_category,
243
+ urgency: @definition.urgency,
244
+ start_time: @start_time,
245
+ checkpoint_time: @checkpoint_time,
246
+ end_time: @end_time,
247
+ elapsed_time_s: elapsed_time,
248
+ urgency_threshold_s: urgency_threshold
249
+ )
250
+ log_data.reverse_merge!(extra) if extra
251
+
252
+ if has_error?
253
+ log_data[:error] = true
254
+ log_data[:error_message] = @error.inspect
255
+ end
256
+
257
+ log_data.compact!
258
+
259
+ log_data
260
+ end
261
+
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'])
280
+ end
281
+
282
+ def logger
283
+ Labkit::CoveredExperience.configuration.logger
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module CoveredExperience
5
+ # Fakes Labkit::CoveredExperience::Experience.
6
+ class Null
7
+ include Singleton
8
+
9
+ def id = 'null'
10
+
11
+ def start(**_extra)
12
+ yield self if block_given?
13
+ self
14
+ end
15
+
16
+ def resume(**_extra)
17
+ yield self if block_given?
18
+ self
19
+ end
20
+
21
+ def push_attributes!(*_args) = self
22
+ def checkpoint(*_args) = self
23
+ def complete(*_args) = self
24
+ def error!(*_args) = self
25
+ def rehydrate(*_args, **_kwargs) = self
26
+ def to_h = {}
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'json-schema'
5
+ require 'pathname'
6
+ require 'yaml'
7
+
8
+ module Labkit
9
+ module CoveredExperience
10
+ Definition = Data.define(:covered_experience, :description, :feature_category, :urgency)
11
+
12
+ class Registry
13
+ extend Forwardable
14
+
15
+ SCHEMA_PATH = File.expand_path('../../../config/covered_experiences/schema.json', __dir__)
16
+
17
+ def_delegator :@experiences, :empty?
18
+
19
+ # @param dir [String, Pathname] Directory path containing YAML file definitions
20
+ # Defaults to 'config/covered_experiences' relative to the calling application's root
21
+ def initialize(dir: File.join("config", "covered_experiences"))
22
+ @dir = Pathname.new(Dir.pwd).join(dir)
23
+ @experiences = load_on_demand
24
+ end
25
+
26
+ # Retrieve a definition experience given a covered_experience_id.
27
+ #
28
+ # @param covered_experience_id [String, Symbol] Covered experience identifier
29
+ # @return [Experience, nil] An experience if present, otherwise nil
30
+ def [](covered_experience_id)
31
+ @experiences[covered_experience_id.to_s]
32
+ end
33
+
34
+ private
35
+
36
+ # Initialize a hash that loads experiences on-demand
37
+ #
38
+ # @return [Hash] Hash with lazy loading behavior
39
+ def load_on_demand
40
+ unless readable_dir?
41
+ warn("Directory not readable: #{@dir}")
42
+ return {}
43
+ end
44
+
45
+ Hash.new do |result, experience_id|
46
+ experience = load_experience(experience_id.to_s)
47
+ # we also store nil to memoize the value and avoid triggering load_experience again
48
+ result[experience_id.to_s] = experience
49
+ end
50
+ end
51
+
52
+ # Load a covered experience definition.
53
+ #
54
+ # @param experience_id [String] Experience identifier
55
+ # @return [Experience, nil] Loaded experience or nil if not found/invalid
56
+ def load_experience(experience_id)
57
+ file_path = @dir.join("#{experience_id}.yml")
58
+
59
+ unless file_path.exist?
60
+ warn("Invalid Covered Experience definition: #{experience_id}")
61
+ return nil
62
+ end
63
+
64
+ read_experience(file_path, experience_id)
65
+ end
66
+
67
+ def readable_dir?
68
+ @dir.exist? && @dir.directory? && @dir.readable?
69
+ end
70
+
71
+ # Read and validate a definition experience file
72
+ #
73
+ # @param file_path [Pathname] Path to the definition file
74
+ # @param experience_id [String] Expected experience ID
75
+ # @return [Experience, nil] Parsed experience or nil if invalid
76
+ def read_experience(file_path, experience_id)
77
+ content = YAML.safe_load(file_path.read)
78
+ return nil unless content.is_a?(Hash)
79
+
80
+ errors = JSON::Validator.fully_validate(schema, content)
81
+ return Definition.new(covered_experience: experience_id, **content) if errors.empty?
82
+
83
+ warn("Invalid schema for #{file_path}")
84
+
85
+ nil
86
+ rescue Psych::SyntaxError => e
87
+ warn("Invalid definition file #{file_path}: #{e.message}")
88
+ rescue StandardError => e
89
+ warn("Unexpected error processing #{file_path}: #{e.message}")
90
+ end
91
+
92
+ def schema
93
+ @schema ||= JSON.parse(File.read(SCHEMA_PATH))
94
+ end
95
+
96
+ def warn(message)
97
+ logger.warn(component: self.class.name, message: message)
98
+ end
99
+
100
+ def logger
101
+ Labkit::CoveredExperience.configuration.logger
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labkit/covered_experience/current'
4
+ require 'labkit/covered_experience/error'
5
+ require 'labkit/covered_experience/experience'
6
+ require 'labkit/covered_experience/null'
7
+ require 'labkit/covered_experience/registry'
8
+ require 'labkit/logging/json_logger'
9
+
10
+ module Labkit
11
+ # Labkit::CoveredExperience namespace module.
12
+ #
13
+ # This module is responsible for managing covered experiences, which are
14
+ # specific events or activities within the application that are measured
15
+ # and reported for performance monitoring and analysis.
16
+ module CoveredExperience
17
+ # Configuration class for CoveredExperience
18
+ class Configuration
19
+ attr_accessor :logger, :registry_path
20
+
21
+ def initialize
22
+ @logger = Labkit::Logging::JsonLogger.new($stdout)
23
+ @registry_path = File.join("config", "covered_experiences")
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def reset_configuration
33
+ @configuration = nil
34
+ end
35
+
36
+ def configure
37
+ yield(configuration) if block_given?
38
+ # Reset registry when configuration changes to pick up new registry_path
39
+ @registry = nil
40
+ end
41
+
42
+ def registry
43
+ @registry ||= Registry.new(dir: configuration.registry_path)
44
+ end
45
+
46
+ def reset
47
+ @registry = nil
48
+ reset_configuration
49
+ end
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).
58
+ def get(experience_id)
59
+ find_current(experience_id) || raise_or_null(experience_id)
60
+ end
61
+
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, &)
69
+ end
70
+
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, &)
77
+ end
78
+
79
+ private
80
+
81
+ def raise_or_null(experience_id)
82
+ return Null.instance unless %w[development test].include?(ENV['RAILS_ENV'])
83
+
84
+ raise(NotFoundError, "Covered Experience #{experience_id} not found in the registry")
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
94
+ end
95
+ end
96
+ end
@@ -52,6 +52,7 @@ module Labkit
52
52
  data[:message] = message
53
53
  when Hash
54
54
  reject_reserved_log_keys!(message)
55
+ format_time!(data)
55
56
  data.merge!(message)
56
57
  end
57
58
 
@@ -77,6 +78,16 @@ module Labkit
77
78
  "\n\nUse key names that are descriptive e.g. by using a prefix."
78
79
  end
79
80
  end
81
+
82
+ def format_time!(hash)
83
+ hash.each do |key, value|
84
+ if value.is_a?(Time)
85
+ hash[key] = value.utc.iso8601(3)
86
+ elsif value.is_a?(Hash)
87
+ format_time!(value)
88
+ end
89
+ end
90
+ end
80
91
  end
81
92
  end
82
93
  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