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.
- checksums.yaml +4 -4
- data/.copier-answers.yml +2 -1
- data/.gitlab-ci-asdf-versions.yml +3 -3
- data/.gitlab-ci.yml +3 -9
- data/.pre-commit-config.yaml +2 -2
- data/.rubocop_todo.yml +1 -1
- data/.tool-versions +3 -3
- data/README.md +3 -1
- data/config/covered_experiences/schema.json +35 -0
- data/config/covered_experiences/testing_sample.yml +4 -0
- data/gitlab-labkit.gemspec +1 -0
- data/lib/gitlab-labkit.rb +2 -1
- data/lib/labkit/context.rb +1 -0
- data/lib/labkit/covered_experience/README.md +185 -0
- data/lib/labkit/covered_experience/current.rb +34 -0
- data/lib/labkit/covered_experience/error.rb +11 -0
- data/lib/labkit/covered_experience/experience.rb +287 -0
- data/lib/labkit/covered_experience/null.rb +29 -0
- data/lib/labkit/covered_experience/registry.rb +105 -0
- data/lib/labkit/covered_experience.rb +96 -0
- data/lib/labkit/logging/json_logger.rb +11 -0
- data/lib/labkit/middleware/rack.rb +23 -0
- data/lib/labkit/middleware/sidekiq/client.rb +1 -0
- data/lib/labkit/middleware/sidekiq/covered_experience/client.rb +27 -0
- data/lib/labkit/middleware/sidekiq/covered_experience/server.rb +25 -0
- data/lib/labkit/middleware/sidekiq/covered_experience.rb +14 -0
- data/lib/labkit/middleware/sidekiq/server.rb +1 -0
- data/lib/labkit/middleware/sidekiq.rb +1 -0
- data/lib/labkit/rspec/README.md +132 -0
- data/lib/labkit/rspec/matchers/covered_experience_matchers.rb +204 -0
- data/lib/labkit/rspec/matchers.rb +10 -0
- metadata +31 -2
@@ -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
|