gitlab-labkit 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 639ab73c3db48ed9078b0ae3743b5031d3d3e89dfde6aff9c65818b9aece82cb
4
- data.tar.gz: ab65ac7320604017406ea64242c9dba98665ed12ad0f1268820e75031c301452
3
+ metadata.gz: 570e79f377fc9cfa11a87177437e6f66a6ed78c94958ad999744034922011e31
4
+ data.tar.gz: a58252e9bb1792ac0e6ca7e65d565f49803e917eb845de065ffb4921b1bf405c
5
5
  SHA512:
6
- metadata.gz: 14feead5381e4d99a08cac69768dace5080d394c7267cc4c90fc44d0b5839b24838a16469e627f086a2731ac972f38729cb28cec96e84c2ac0ba5d1c27cc4eeb
7
- data.tar.gz: aaae5ce7dbd8454862ce64a93a73280931802575213cfe18c35dee3a7edb410a240937da508b30c2cabe5dbefb0e3e534cbbd2c5a1428dcc2b6b8e90110392d8
6
+ metadata.gz: eb4718ca04e0c5769180e3e23e3e658ea914a60c12142efc6f26bfc8f25b9a29d467c1c8fb582f23827c4e6cf3c9d72e65d7a7a469ab37465eb479f650e8269a
7
+ data.tar.gz: 90c9bff31da38826e4d60d6ac623f97257a78fbea63ea86aba8a15dd22df69e069ed628af48fc5f4695d58f1157c098f22a8e9676f97e8345522073349603bf5
data/.copier-answers.yml CHANGED
@@ -3,10 +3,11 @@
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.31.0
6
+ _commit: v1.35.1
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  golang: false
10
+ helm: false
10
11
  initial_codeowners: '@reprazent @andrewn @mkaeppler @ayufan'
11
12
  jsonnet: false
12
13
  project_name: labkit-ruby
@@ -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.4"
3
+ GL_ASDF_RUBY_VERSION: "3.4.5"
4
4
  GL_ASDF_SHELLCHECK_VERSION: "0.10.0"
5
- GL_ASDF_SHFMT_VERSION: "3.11.0"
5
+ GL_ASDF_SHFMT_VERSION: "3.12"
data/.gitlab-ci.yml CHANGED
@@ -19,19 +19,13 @@ include:
19
19
  # It includes standard checks, gitlab-scanners, validations and release processes
20
20
  # common to all projects using this template library.
21
21
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/standard.md
22
- - project: "gitlab-com/gl-infra/common-ci-tasks"
23
- ref: v2.77 # renovate:managed
24
- file: templates/standard.yml
22
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v2.78
25
23
 
26
24
  # Runs rspec tests and rubocop on the project
27
25
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/ruby.md
28
- - project: "gitlab-com/gl-infra/common-ci-tasks"
29
- ref: v2.77 # renovate:managed
30
- file: templates/ruby.yml
26
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v2.78
31
27
 
32
- - project: "gitlab-com/gl-infra/common-ci-tasks"
33
- ref: v2.77 # renovate:managed
34
- file: "danger.yml"
28
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v2.78
35
29
 
36
30
  .test_template: &test_definition
37
31
  extends: .with_bundle
@@ -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.77 # renovate:managed
28
+ rev: v2.85 # renovate:managed
29
29
 
30
30
  hooks:
31
31
  - id: shellcheck # Run shellcheck for changed Shell files
data/.tool-versions CHANGED
@@ -1,3 +1,3 @@
1
- ruby 3.4.4
2
- shfmt 3.11.0
1
+ ruby 3.4.5
2
+ shfmt 3.12
3
3
  shellcheck 0.10.0
data/README.md CHANGED
@@ -19,10 +19,12 @@ The changelog is available via [**tagged release notes**](https://gitlab.com/git
19
19
  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
- 1. `Labkit::Correlation` For accessing the correlation id. (Generated and propagated by `Labkit::Context`)
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
24
  1. `Labkit::FIPS` for checking for FIPS mode and using FIPS-compliant algorithms.
24
25
  1. `Labkit::Logging` for sanitizing log messages.
25
26
  1. `Labkit::Metrics` for metrics. More on the [README](./lib/labkit/metrics/README.md).
27
+ 1. `Labkit::RSpec` for RSpec matchers to test Labkit functionality (requires selective loading). More on the [README](./lib/labkit/rspec/README.md).
26
28
  1. `Labkit::Tracing` for handling and propagating distributed traces.
27
29
 
28
30
  ## Developing
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-06/schema#",
3
+ "$id": "https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/raw/master/config/covered_experiences/schema.json",
4
+ "title": "Covered Experience Definition",
5
+ "description": "Schema for GitLab Covered Experience files",
6
+ "type": "object",
7
+ "properties": {
8
+ "description": {
9
+ "type": "string",
10
+ "minLength": 1,
11
+ "description": "Human-readable description of the covered experience"
12
+ },
13
+ "feature_category": {
14
+ "type": "string",
15
+ "minLength": 1,
16
+ "description": "GitLab feature category this experience belongs to"
17
+ },
18
+ "urgency": {
19
+ "type": "string",
20
+ "enum": [
21
+ "sync_fast",
22
+ "sync_slow",
23
+ "async_fast",
24
+ "async_slow"
25
+ ],
26
+ "description": "Urgency level for this covered experience"
27
+ }
28
+ },
29
+ "required": [
30
+ "description",
31
+ "feature_category",
32
+ "urgency"
33
+ ]
34
+ }
35
+
@@ -0,0 +1,4 @@
1
+ # yaml-language-server: $schema=./schema.json
2
+ description: "Creating a new merge request in a project"
3
+ feature_category: "source_code_management"
4
+ urgency: "sync_fast"
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.add_runtime_dependency "grpc", ">= 1.62" # Be sure to update the "grpc-tools" dev_dependency too
26
26
  spec.add_runtime_dependency "google-protobuf", "~> 3" # Keep the major version to 3 until we update the `grpc` gem
27
27
  spec.add_runtime_dependency "jaeger-client", "~> 1.1.0"
28
+ spec.add_runtime_dependency 'json-schema', '~> 5.1'
28
29
  spec.add_runtime_dependency "opentracing", "~> 0.4"
29
30
  spec.add_runtime_dependency "pg_query", ">= 6.1.0", "< 7.0"
30
31
  spec.add_runtime_dependency "prometheus-client-mmap", "~> 1.2.9"
data/lib/gitlab-labkit.rb CHANGED
@@ -7,8 +7,9 @@
7
7
  module Labkit
8
8
  autoload :System, "labkit/system"
9
9
 
10
- autoload :Correlation, "labkit/correlation"
11
10
  autoload :Context, "labkit/context"
11
+ autoload :Correlation, "labkit/correlation"
12
+ autoload :CoveredExperience, "labkit/covered_experience"
12
13
  autoload :FIPS, "labkit/fips"
13
14
  autoload :Tracing, "labkit/tracing"
14
15
  autoload :Logging, "labkit/logging"
@@ -5,6 +5,7 @@ require "securerandom"
5
5
  require "active_support/core_ext/module/delegation"
6
6
  require "active_support/core_ext/string/starts_ends_with"
7
7
  require "active_support/core_ext/string/inflections"
8
+ require "active_support/core_ext/object/blank"
8
9
 
9
10
  module Labkit
10
11
  # A context can be used to provide structured information on what resources
@@ -0,0 +1,134 @@
1
+ # Covered Experience
2
+
3
+ This module covers the definition for Covered Experiences, as described in the [blueprint](https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/covered_experience_slis/#covered-experience-definition).
4
+
5
+ ## Configuration
6
+
7
+ ### Logger Configuration
8
+
9
+ By default, `Labkit::CoveredExperience` uses `Labkit::Logging::JsonLogger.new($stdout)` for logging. You can configure a custom logger:
10
+
11
+ ```ruby
12
+ Labkit::CoveredExperience.configure do |config|
13
+ config.logger = Labkit::Logging::JsonLogger.new($stdout)
14
+ end
15
+ ```
16
+
17
+ This configuration affects all Covered Experience instances and their logging output.
18
+
19
+ ### Covered Experience Definitions
20
+
21
+ Covered experience definitions will be lazy loaded from the default directory (`config/covered_experiences`).
22
+
23
+ Create a new covered experience file in the registry directory, e.g. config/covered_experiences/merge_request_creation.yaml
24
+
25
+ The basename of the file will be taken as the covered_experience_id.
26
+
27
+ The schema header is optional, but if you're using VSCode (or any other editor with support), you can get them validated
28
+ instantaneously in the editor via a [JSON schema plugin](https://marketplace.visualstudio.com/items?itemName=remcohaszing.schemastore).
29
+
30
+ ```yaml
31
+ # yaml-language-server: $schema=https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/raw/master/config/covered_experiences/schema.json
32
+ description: "Creating a new merge request in a project"
33
+ feature_category: "source_code_management"
34
+ urgency: "sync_fast"
35
+ ```
36
+
37
+ **Feature category**
38
+
39
+ https://docs.gitlab.com/development/feature_categorization/#feature-categorization.
40
+
41
+ **Urgency**
42
+
43
+ | Threshold | Description | Examples | Value |
44
+ |--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|-------|
45
+ | `sync_fast` | A user is awaiting a synchronous response which needs to be returned before they can continue with their action | A full-page render | 2s |
46
+ | `sync_slow` | A user is awaiting a synchronous response which needs to be returned before they can continue with their action, but which the user may accept a slower response | Displaying a full-text search response while displaying an amusement animation | 5s |
47
+ | `async_fast` | An async process which may block a user from continuing with their user journey | MR diff update after git push | 15s |
48
+ | `async_slow` | An async process which will not block a user and will not be immediately noticed as being slow | Notification following an assignment | 5m |
49
+
50
+ ## Usage
51
+
52
+ The `Labkit::CoveredExperience` module provides a simple API for measuring and tracking covered experiences in your application.
53
+
54
+
55
+ #### Accessing a Covered Experience
56
+
57
+ ```ruby
58
+ # Get a covered experience by ID
59
+ experience = Labkit::CoveredExperience.get('merge_request_creation')
60
+ ```
61
+
62
+ #### Using with a Block (Recommended)
63
+
64
+ The simplest way to use covered experiences is with a block, which automatically handles starting and completing the experience:
65
+
66
+ ```ruby
67
+ Labkit::CoveredExperience.start('merge_request_creation') do |experience|
68
+ # Your code here
69
+ create_merge_request
70
+
71
+ # Add checkpoints for important milestones
72
+ experience.checkpoint
73
+
74
+ validate_merge_request
75
+ experience.checkpoint
76
+
77
+ send_notifications
78
+ end
79
+ ```
80
+
81
+ #### Manual Control
82
+
83
+ For more control, you can manually start, checkpoint, and complete experiences:
84
+
85
+ ```ruby
86
+ experience = Labkit::CoveredExperience.get('merge_request_creation')
87
+ experience.start
88
+
89
+ # Perform some work
90
+ create_merge_request
91
+
92
+ # Mark important milestones
93
+ experience.checkpoint
94
+
95
+ # Perform more work
96
+ validate_merge_request
97
+ experience.checkpoint
98
+
99
+ # Complete the experience
100
+ experience.complete
101
+ ```
102
+
103
+ ### Error Handling
104
+
105
+ When using the block form, errors are automatically captured:
106
+
107
+ ```ruby
108
+ Labkit::CoveredExperience.start('merge_request_creation') do |experience|
109
+ # If this raises an exception, it will be captured automatically
110
+ risky_operation
111
+ end
112
+ ```
113
+
114
+ For manual control, you can mark errors explicitly:
115
+
116
+ ```ruby
117
+ experience = Labkit::CoveredExperience.get('merge_request_creation')
118
+ experience.start
119
+
120
+ begin
121
+ risky_operation
122
+ rescue StandardError => e
123
+ experience.error!(e)
124
+ raise
125
+ ensure
126
+ experience.complete
127
+ end
128
+ ```
129
+
130
+ ### Error Behavior
131
+
132
+ - In `development` and `test` environments, accessing a non-existent covered experience will raise a `NotFoundError`
133
+ - 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module CoveredExperience
5
+ CoveredExperienceError = Class.new(StandardError)
6
+ UnstartedError = Class.new(CoveredExperienceError)
7
+ NotFoundError = Class.new(CoveredExperienceError)
8
+ end
9
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labkit/context'
4
+ require 'labkit/covered_experience/error'
5
+
6
+ module Labkit
7
+ module CoveredExperience
8
+ URGENCY_THRESHOLDS_IN_SECONDS = {
9
+ sync_fast: 2,
10
+ sync_slow: 5,
11
+ async_fast: 15,
12
+ async_slow: 300
13
+ }.freeze
14
+
15
+ # The `Experience` class represents a single Covered Experience
16
+ # event to be measured and reported.
17
+ class Experience
18
+ attr_reader :error
19
+
20
+ def initialize(definition)
21
+ @definition = definition
22
+ end
23
+
24
+ # Start the Covered Experience.
25
+ #
26
+ # @yield [self] When a block is provided, the experience will be completed automatically.
27
+ # @param extra [Hash] Additional data to include in the log event
28
+ # @return [self]
29
+ # @raise [CoveredExperienceError] If the block raises an error.
30
+ #
31
+ # Usage:
32
+ #
33
+ # CoveredExperience.new(definition).start do |experience|
34
+ # experience.checkpoint
35
+ # experience.checkpoint
36
+ # end
37
+ #
38
+ # experience = CoveredExperience.new(definition)
39
+ # experience.start
40
+ # experience.checkpoint
41
+ # experience.complete
42
+ def start(**extra, &)
43
+ @start_time = Time.now.utc
44
+ checkpoint_counter.increment(checkpoint: "start")
45
+ log_event("start", **extra)
46
+
47
+ return self unless block_given?
48
+
49
+ begin
50
+ yield self
51
+ self
52
+ rescue StandardError => e
53
+ error!(e)
54
+ raise
55
+ ensure
56
+ complete(**extra)
57
+ end
58
+ end
59
+
60
+ # Checkpoint the Covered Experience.
61
+ #
62
+ # @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
+ # @return [self]
65
+ def checkpoint(**extra)
66
+ return unless ensure_started!
67
+
68
+ @checkpoint_time = Time.now.utc
69
+ checkpoint_counter.increment(checkpoint: "intermediate")
70
+ log_event("intermediate", **extra)
71
+
72
+ self
73
+ end
74
+
75
+ # Complete the Covered Experience.
76
+ #
77
+ # @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
+ # @return [self]
80
+ def complete(**extra)
81
+ return unless ensure_started!
82
+
83
+ begin
84
+ @end_time = Time.now.utc
85
+ ensure
86
+ checkpoint_counter.increment(checkpoint: "end")
87
+ total_counter.increment(error: has_error?)
88
+ apdex_counter.increment(success: apdex_success?) unless has_error?
89
+ log_event("end", **extra)
90
+ end
91
+
92
+ self
93
+ end
94
+
95
+ # Marks the experience as failed with an error
96
+ #
97
+ # @param error [StandardError, String] The error that caused the experience to fail.
98
+ # @return [self]
99
+ def error!(error)
100
+ @error = error
101
+ self
102
+ end
103
+
104
+ def has_error?
105
+ !!@error
106
+ end
107
+
108
+ private
109
+
110
+ def base_labels
111
+ @base_labels ||= @definition.to_h.slice(:covered_experience, :feature_category, :urgency)
112
+ end
113
+
114
+ def ensure_started!
115
+ return @start_time unless @start_time.nil?
116
+
117
+ err = UnstartedError.new("Covered Experience #{@definition.covered_experience} not started")
118
+
119
+ warn(err)
120
+ raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
121
+ end
122
+
123
+ def urgency_threshold
124
+ URGENCY_THRESHOLDS_IN_SECONDS[@definition.urgency.to_sym]
125
+ end
126
+
127
+ def elapsed_time
128
+ last_time = @end_time || @checkpoint_time || @start_time
129
+ last_time - @start_time
130
+ end
131
+
132
+ def apdex_success?
133
+ elapsed_time <= urgency_threshold
134
+ end
135
+
136
+ def checkpoint_counter
137
+ @checkpoint_counter ||= Labkit::Metrics::Client.counter(
138
+ :gitlab_covered_experience_checkpoint_total,
139
+ 'Total checkpoints for covered experiences',
140
+ base_labels
141
+ )
142
+ end
143
+
144
+ def total_counter
145
+ @total_counter ||= Labkit::Metrics::Client.counter(
146
+ :gitlab_covered_experience_total,
147
+ 'Total covered experience events (success/failure)',
148
+ base_labels
149
+ )
150
+ end
151
+
152
+ def apdex_counter
153
+ @apdex_counter ||= Labkit::Metrics::Client.counter(
154
+ :gitlab_covered_experience_apdex_total,
155
+ 'Total covered experience apdex events',
156
+ base_labels
157
+ )
158
+ end
159
+
160
+ def log_event(event_type, **extra)
161
+ log_data = build_log_data(event_type, **extra)
162
+ logger.info(log_data)
163
+ end
164
+
165
+ def build_log_data(event_type, **extra)
166
+ log_data = {
167
+ checkpoint: event_type,
168
+ covered_experience: @definition.covered_experience,
169
+ feature_category: @definition.feature_category,
170
+ urgency: @definition.urgency,
171
+ start_time: @start_time,
172
+ checkpoint_time: @checkpoint_time,
173
+ end_time: @end_time,
174
+ elapsed_time_s: elapsed_time,
175
+ urgency_threshold_s: urgency_threshold
176
+ }
177
+ log_data.merge!(extra) if extra
178
+
179
+ if has_error?
180
+ log_data[:error] = true
181
+ log_data[:error_message] = @error.inspect
182
+ end
183
+
184
+ log_data.compact!
185
+
186
+ log_data
187
+ end
188
+
189
+ def warn(exception)
190
+ logger.warn(component: self.class.name, message: exception.message)
191
+ end
192
+
193
+ def logger
194
+ Labkit::CoveredExperience.configuration.logger
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,22 @@
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
+ attr_reader :id, :description, :feature_category, :urgency
10
+
11
+ def start(*_args)
12
+ yield self if block_given?
13
+ self
14
+ end
15
+
16
+ def push_attributes!(*_args) = self
17
+ def checkpoint(*_args) = self
18
+ def complete(*_args) = self
19
+ def error!(*_args) = self
20
+ end
21
+ end
22
+ 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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labkit/covered_experience/error'
4
+ require 'labkit/covered_experience/experience'
5
+ require 'labkit/covered_experience/null'
6
+ require 'labkit/covered_experience/registry'
7
+ require 'labkit/logging/json_logger'
8
+
9
+ module Labkit
10
+ # Labkit::CoveredExperience namespace module.
11
+ #
12
+ # This module is responsible for managing covered experiences, which are
13
+ # specific events or activities within the application that are measured
14
+ # and reported for performance monitoring and analysis.
15
+ module CoveredExperience
16
+ # Configuration class for CoveredExperience
17
+ class Configuration
18
+ attr_accessor :logger
19
+
20
+ def initialize
21
+ @logger = Labkit::Logging::JsonLogger.new($stdout)
22
+ end
23
+ end
24
+
25
+ class << self
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def reset_configuration
31
+ @configuration = nil
32
+ end
33
+
34
+ def configure
35
+ yield(configuration) if block_given?
36
+ end
37
+
38
+ def registry
39
+ @registry ||= Registry.new
40
+ end
41
+
42
+ def reset
43
+ @registry = nil
44
+ end
45
+
46
+ def get(experience_id)
47
+ definition = registry[experience_id]
48
+
49
+ if definition
50
+ Experience.new(definition)
51
+ else
52
+ raise_or_null(experience_id)
53
+ end
54
+ end
55
+
56
+ def start(experience_id, &)
57
+ get(experience_id).start(&)
58
+ end
59
+
60
+ private
61
+
62
+ def raise_or_null(experience_id)
63
+ return Null.instance unless %w[development test].include?(ENV['RAILS_ENV'])
64
+
65
+ raise(NotFoundError, "Covered Experience #{experience_id} not found in the registry")
66
+ end
67
+ end
68
+ end
69
+ 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
@@ -0,0 +1,121 @@
1
+ # Labkit RSpec Support
2
+
3
+ This module provides RSpec matchers for testing Labkit functionality in your Rails applications.
4
+
5
+ ## Setup
6
+
7
+ You must explicitly require the RSpec matchers in your test files:
8
+
9
+ ```ruby
10
+ # In your spec_helper.rb or rails_helper.rb
11
+ require 'labkit/rspec/matchers'
12
+ ```
13
+
14
+ This approach ensures that:
15
+ - Test dependencies are not loaded in production environments
16
+ - You have explicit control over which matchers are available
17
+ - The gem remains lightweight for non-testing use cases
18
+
19
+
20
+ ## Available Matchers
21
+
22
+ ### Covered Experience Matchers
23
+
24
+ These matchers help you test that your code properly instruments covered experiences with the expected metrics.
25
+
26
+ #### `start_covered_experience`
27
+
28
+ Tests that a covered experience is started (checkpoint=start metric is incremented).
29
+
30
+ ```ruby
31
+ expect { subject }.to start_covered_experience('rails_request')
32
+
33
+ # Test that it does NOT start
34
+ expect { subject }.not_to start_covered_experience('rails_request')
35
+ ```
36
+
37
+ #### `checkpoint_covered_experience`
38
+
39
+ Tests that a covered experience checkpoint is recorded (checkpoint=intermediate metric is incremented).
40
+
41
+ ```ruby
42
+ expect { subject }.to checkpoint_covered_experience('rails_request')
43
+
44
+ # Test that it does NOT checkpoint
45
+ expect { subject }.not_to checkpoint_covered_experience('rails_request')
46
+ ```
47
+
48
+ #### `complete_covered_experience`
49
+
50
+ Tests that a covered experience is completed with the expected metrics:
51
+ - `gitlab_covered_experience_checkpoint_total` (with checkpoint=end)
52
+ - `gitlab_covered_experience_total` (with error flag)
53
+ - `gitlab_covered_experience_apdex_total` (with success flag)
54
+
55
+ ```ruby
56
+ # Test successful completion
57
+ expect { subject }.to complete_covered_experience('rails_request')
58
+
59
+ # Test completion with error
60
+ expect { subject }.to complete_covered_experience('rails_request', error: true, success: false)
61
+
62
+ # Test that it does NOT complete
63
+ expect { subject }.not_to complete_covered_experience('rails_request')
64
+ ```
65
+
66
+ ## Example Usage
67
+
68
+ ### In your spec_helper.rb or rails_helper.rb:
69
+
70
+ ```ruby
71
+ # spec/spec_helper.rb or spec/rails_helper.rb
72
+ require 'gitlab-labkit'
73
+
74
+ # Explicitly require the RSpec matchers
75
+ require 'labkit/rspec/matchers'
76
+
77
+ RSpec.configure do |config|
78
+ # Your other RSpec configuration...
79
+ end
80
+ ```
81
+
82
+ ### In your test files:
83
+
84
+ ```ruby
85
+ RSpec.describe MyController, type: :controller do
86
+ describe '#index' do
87
+ it 'instruments the request properly' do
88
+ expect { get :index }.to start_covered_experience('rails_request')
89
+ .and complete_covered_experience('rails_request')
90
+ end
91
+
92
+ context 'when an error occurs' do
93
+ before do
94
+ allow(MyService).to receive(:call).and_raise(StandardError)
95
+ end
96
+
97
+ it 'records the error in metrics' do
98
+ expect { get :index }.to complete_covered_experience('rails_request', error: true, success: false)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ ```
104
+
105
+ ### For individual spec files (alternative approach):
106
+
107
+ ```ruby
108
+ # spec/controllers/my_controller_spec.rb
109
+ require 'spec_helper'
110
+ require 'labkit/rspec/matchers' # Can also be required per-file if needed
111
+
112
+ RSpec.describe MyController do
113
+ # Your tests using the matchers...
114
+ end
115
+ ```
116
+
117
+ ## Requirements
118
+
119
+ - The covered experience must be registered in `Labkit::CoveredExperience::Registry`
120
+ - Metrics must be properly configured in your test environment
121
+ - The code under test must use Labkit's covered experience instrumentation
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matchers for testing Labkit CoveredExperience functionality
4
+ #
5
+ # This file must be explicitly required in your test setup:
6
+ # require 'labkit/rspec/matchers'
7
+
8
+ raise LoadError, "RSpec is not loaded. Please require 'rspec' before requiring 'labkit/rspec/matchers'" unless defined?(RSpec)
9
+
10
+ module Labkit
11
+ module RSpec
12
+ module Matchers
13
+ # Helper module for CoveredExperience functionality
14
+ module CoveredExperience
15
+ def attributes(covered_experience_id)
16
+ raise ArgumentError, "covered_experience_id is required" if covered_experience_id.nil?
17
+
18
+ definition = Labkit::CoveredExperience::Registry.new[covered_experience_id]
19
+ definition.to_h.slice(:covered_experience, :feature_category, :urgency)
20
+ end
21
+
22
+ def checkpoint_counter
23
+ Labkit::Metrics::Client.get(:gitlab_covered_experience_checkpoint_total)
24
+ end
25
+
26
+ def total_counter
27
+ Labkit::Metrics::Client.get(:gitlab_covered_experience_total)
28
+ end
29
+
30
+ def apdex_counter
31
+ Labkit::Metrics::Client.get(:gitlab_covered_experience_apdex_total)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ # Matcher for verifying CoveredExperience start metrics instrumentation.
39
+ #
40
+ # Usage:
41
+ # expect { subject }.to start_covered_experience('rails_request')
42
+ #
43
+ # This matcher verifies that the following metric is incremented:
44
+ # - gitlab_covered_experience_checkpoint_total (with checkpoint=start)
45
+ #
46
+ # Parameters:
47
+ # - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
48
+ RSpec::Matchers.define :start_covered_experience do |covered_experience_id|
49
+ include Labkit::RSpec::Matchers::CoveredExperience
50
+
51
+ description { "start covered experience '#{covered_experience_id}'" }
52
+ supports_block_expectations
53
+
54
+ match do |actual|
55
+ labels = attributes(covered_experience_id)
56
+
57
+ checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "start")).to_i
58
+
59
+ actual.call
60
+
61
+ checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "start")).to_i
62
+
63
+ @checkpoint_change = checkpoint_after - checkpoint_before
64
+
65
+ @checkpoint_change == 1
66
+ end
67
+
68
+ failure_message do
69
+ "Failed to checkpoint covered experience '#{covered_experience_id}':\n" \
70
+ "expected checkpoint='start' counter to increase by 1, but increased by #{@checkpoint_change}"
71
+ end
72
+ end
73
+
74
+ # Matcher for verifying CoveredExperience checkpoint metrics instrumentation.
75
+ #
76
+ # Usage:
77
+ # expect { subject }.to checkpoint_covered_experience('rails_request')
78
+ #
79
+ # This matcher verifies that the following metric is incremented:
80
+ # - gitlab_covered_experience_checkpoint_total (with checkpoint=intermediate)
81
+ #
82
+ # Parameters:
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|
85
+ include Labkit::RSpec::Matchers::CoveredExperience
86
+
87
+ description { "checkpoint covered experience '#{covered_experience_id}'" }
88
+ supports_block_expectations
89
+
90
+ match do |actual|
91
+ labels = attributes(covered_experience_id)
92
+
93
+ checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
94
+
95
+ actual.call
96
+
97
+ checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
98
+ @checkpoint_change = checkpoint_after - checkpoint_before
99
+
100
+ @checkpoint_change == 1
101
+ end
102
+
103
+ failure_message do
104
+ "Failed to checkpoint covered experience '#{covered_experience_id}':\n" \
105
+ "expected checkpoint='intermediate' counter to increase by 1, but increased by #{@checkpoint_change}"
106
+ end
107
+
108
+ match_when_negated do |actual|
109
+ labels = attributes(covered_experience_id)
110
+
111
+ checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
112
+
113
+ actual.call
114
+
115
+ checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
116
+ @checkpoint_change = checkpoint_after - checkpoint_before
117
+
118
+ @checkpoint_change.zero?
119
+ end
120
+
121
+ failure_message_when_negated do
122
+ "Expected covered experience '#{covered_experience_id}' NOT to checkpoint:\n" \
123
+ "expected checkpoint='intermediate' counter to increase by 0, but increased by #{@checkpoint_change}"
124
+ end
125
+ end
126
+
127
+ # Matcher for verifying CoveredExperience completion metrics instrumentation.
128
+ #
129
+ # Usage:
130
+ # expect { subject }.to complete_covered_experience('rails_request')
131
+ #
132
+ # This matcher verifies that the following metrics are incremented with specific labels:
133
+ # - gitlab_covered_experience_checkpoint_total (with checkpoint=end)
134
+ # - gitlab_covered_experience_total (with error=false)
135
+ # - gitlab_covered_experience_apdex_total (with success=true)
136
+ #
137
+ # Parameters:
138
+ # - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
139
+ # - error: Optional. The expected error flag for gitlab_covered_experience_total (false by default)
140
+ # - success: Optional. The expected success flag for gitlab_covered_experience_apdex_total (true by default)
141
+ RSpec::Matchers.define :complete_covered_experience do |covered_experience_id, error: false, success: true|
142
+ include Labkit::RSpec::Matchers::CoveredExperience
143
+
144
+ description { "complete covered experience '#{covered_experience_id}'" }
145
+ supports_block_expectations
146
+
147
+ match do |actual|
148
+ labels = attributes(covered_experience_id)
149
+
150
+ checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
151
+ total_before = total_counter&.get(labels.merge(error: error)).to_i
152
+ apdex_before = apdex_counter&.get(labels.merge(success: success)).to_i
153
+
154
+ actual.call
155
+
156
+ checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
157
+ total_after = total_counter&.get(labels.merge(error: error)).to_i
158
+ apdex_after = apdex_counter&.get(labels.merge(success: success)).to_i
159
+ @checkpoint_change = checkpoint_after - checkpoint_before
160
+ @total_change = total_after - total_before
161
+ @apdex_change = apdex_after - apdex_before
162
+
163
+ @checkpoint_change == 1 && @total_change == 1 && @apdex_change == (error ? 0 : 1)
164
+ end
165
+
166
+ failure_message do
167
+ "Failed to complete covered experience '#{covered_experience_id}':\n" \
168
+ "expected checkpoint='end' counter to increase by 1, but increased by #{@checkpoint_change}\n" \
169
+ "expected total='error: #{error}' counter to increase by 1, but increased by #{@total_change}\n" \
170
+ "expected apdex='success: #{success}' counter to increase by 1, but increased by #{@apdex_change}"
171
+ end
172
+
173
+ match_when_negated do |actual|
174
+ labels = attributes(covered_experience_id)
175
+
176
+ checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
177
+ total_before = total_counter&.get(labels.merge(error: error)).to_i
178
+ apdex_before = apdex_counter&.get(labels.merge(success: success)).to_i
179
+
180
+ actual.call
181
+
182
+ checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
183
+ total_after = total_counter&.get(labels.merge(error: error)).to_i
184
+ apdex_after = apdex_counter&.get(labels.merge(success: success)).to_i
185
+ @checkpoint_change = checkpoint_after - checkpoint_before
186
+ @total_change = total_after - total_before
187
+ @apdex_change = apdex_after - apdex_before
188
+
189
+ @checkpoint_change.zero? && @total_change.zero? && @apdex_change == (error ? 1 : 0)
190
+ end
191
+
192
+ failure_message_when_negated do
193
+ "Failed covered experience '#{covered_experience_id}' NOT to complete:\n" \
194
+ "expected checkpoint='end' counter to increase by 0, but increased by #{@checkpoint_change}\n" \
195
+ "expected total='error: #{error}' counter to increase by 0, but increased by #{@total_change}\n" \
196
+ "expected apdex='success: #{success}' counter to increase by 0, but increased by #{@apdex_change}"
197
+ end
198
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matchers loader for Labkit
4
+ #
5
+ # This file loads all available RSpec matchers for Labkit.
6
+ # It must be explicitly required in your test setup.
7
+
8
+ raise LoadError, "RSpec is not loaded. Please require 'rspec' before requiring 'labkit/rspec/matchers'" unless defined?(RSpec)
9
+
10
+ require_relative 'matchers/covered_experience_matchers'
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.39.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -91,6 +91,20 @@ dependencies:
91
91
  - - "~>"
92
92
  - !ruby/object:Gem::Version
93
93
  version: 1.1.0
94
+ - !ruby/object:Gem::Dependency
95
+ name: json-schema
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '5.1'
101
+ type: :runtime
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '5.1'
94
108
  - !ruby/object:Gem::Dependency
95
109
  name: opentracing
96
110
  requirement: !ruby/object:Gem::Requirement
@@ -433,6 +447,8 @@ files:
433
447
  - LICENSE
434
448
  - README.md
435
449
  - Rakefile
450
+ - config/covered_experiences/schema.json
451
+ - config/covered_experiences/testing_sample.yml
436
452
  - gitlab-labkit.gemspec
437
453
  - lib/gitlab-labkit.rb
438
454
  - lib/labkit/context.rb
@@ -442,6 +458,12 @@ files:
442
458
  - lib/labkit/correlation/grpc/client_interceptor.rb
443
459
  - lib/labkit/correlation/grpc/grpc_common.rb
444
460
  - lib/labkit/correlation/grpc/server_interceptor.rb
461
+ - lib/labkit/covered_experience.rb
462
+ - lib/labkit/covered_experience/README.md
463
+ - lib/labkit/covered_experience/error.rb
464
+ - lib/labkit/covered_experience/experience.rb
465
+ - lib/labkit/covered_experience/null.rb
466
+ - lib/labkit/covered_experience/registry.rb
445
467
  - lib/labkit/excon_publisher.rb
446
468
  - lib/labkit/fips.rb
447
469
  - lib/labkit/httpclient_publisher.rb
@@ -469,6 +491,9 @@ files:
469
491
  - lib/labkit/middleware/sidekiq/tracing/server.rb
470
492
  - lib/labkit/middleware/sidekiq/tracing/sidekiq_common.rb
471
493
  - lib/labkit/net_http_publisher.rb
494
+ - lib/labkit/rspec/README.md
495
+ - lib/labkit/rspec/matchers.rb
496
+ - lib/labkit/rspec/matchers/covered_experience_matchers.rb
472
497
  - lib/labkit/system.rb
473
498
  - lib/labkit/tracing.rb
474
499
  - lib/labkit/tracing/abstract_instrumenter.rb
@@ -525,7 +550,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
525
550
  - !ruby/object:Gem::Version
526
551
  version: '0'
527
552
  requirements: []
528
- rubygems_version: 3.6.7
553
+ rubygems_version: 3.6.9
529
554
  specification_version: 4
530
555
  summary: Instrumentation for GitLab
531
556
  test_files: []