gitlab-labkit 0.37.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.copier-answers.yml +16 -0
  3. data/.editorconfig +28 -0
  4. data/.gitlab-ci-asdf-versions.yml +5 -0
  5. data/.gitlab-ci.yml +32 -37
  6. data/.gitleaks.toml +10 -0
  7. data/.mise.toml +8 -0
  8. data/.pre-commit-config.yaml +38 -0
  9. data/.releaserc.json +19 -0
  10. data/.rubocop.yml +1 -58
  11. data/.rubocop_todo.yml +399 -77
  12. data/.tool-versions +3 -1
  13. data/.yamllint.yaml +11 -0
  14. data/CODEOWNERS +4 -0
  15. data/Dangerfile +7 -1
  16. data/LICENSE +1 -3
  17. data/README.md +6 -5
  18. data/Rakefile +14 -24
  19. data/config/covered_experiences/schema.json +35 -0
  20. data/config/covered_experiences/testing_sample.yml +4 -0
  21. data/gitlab-labkit.gemspec +13 -8
  22. data/lib/gitlab-labkit.rb +3 -1
  23. data/lib/labkit/context.rb +1 -0
  24. data/lib/labkit/covered_experience/README.md +134 -0
  25. data/lib/labkit/covered_experience/error.rb +9 -0
  26. data/lib/labkit/covered_experience/experience.rb +198 -0
  27. data/lib/labkit/covered_experience/null.rb +22 -0
  28. data/lib/labkit/covered_experience/registry.rb +105 -0
  29. data/lib/labkit/covered_experience.rb +69 -0
  30. data/lib/labkit/logging/json_logger.rb +11 -0
  31. data/lib/labkit/metrics/README.md +98 -0
  32. data/lib/labkit/metrics/client.rb +90 -0
  33. data/lib/labkit/metrics/null.rb +20 -0
  34. data/lib/labkit/metrics/rack_exporter.rb +12 -0
  35. data/lib/labkit/metrics/registry.rb +69 -0
  36. data/lib/labkit/metrics.rb +19 -0
  37. data/lib/labkit/rspec/README.md +121 -0
  38. data/lib/labkit/rspec/matchers/covered_experience_matchers.rb +198 -0
  39. data/lib/labkit/rspec/matchers.rb +10 -0
  40. data/renovate.json +7 -0
  41. data/scripts/install-asdf-plugins.sh +13 -0
  42. data/scripts/prepare-dev-env.sh +68 -0
  43. data/scripts/update-asdf-version-variables.sh +30 -0
  44. metadata +115 -34
  45. data/.ruby-version +0 -1
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # LabKit-Ruby 🔬🔬🔬🔬🔬
1
+ # LabKit-Ruby 🔬🔬🔬🔬🔬
2
2
 
3
3
  LabKit-Ruby is minimalist library to provide functionality for Ruby services at GitLab.
4
4
 
@@ -19,9 +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.
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).
25
28
  1. `Labkit::Tracing` for handling and propagating distributed traces.
26
29
 
27
30
  ## Developing
@@ -40,8 +43,6 @@ $ # Run tests, linters
40
43
  $ bundle exec rake verify
41
44
  ```
42
45
 
43
- Note that LabKit-Ruby uses the [`rufo`](https://github.com/ruby-formatter/rufo) for auto-formatting. Please run `bundle exec rake fix` to auto-format your code before pushing.
44
-
45
46
  Please also review the [development section of the LabKit (go) README](https://gitlab.com/gitlab-org/labkit#developing-labkit) for details of the LabKit architectural philosophy.
46
47
 
47
48
  To work on some of the scripts we use for releasing a new version,
@@ -51,7 +52,7 @@ make sure to add a new `.env.sh`.
51
52
  cp .env.example.sh .env.sh`
52
53
  ```
53
54
 
54
- Inside `.env.sh`, add a personal acccess token for the `GITLAB_TOKEN`
55
+ Inside `.env.sh`, add a personal acccess token for the `CHANGELOG_GITLAB_TOKEN`
55
56
  environment variable. Next source the file:
56
57
 
57
58
  ```console
data/Rakefile CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "rufo"
5
4
 
6
5
  require "rspec/core/rake_task"
7
6
  RSpec::Core::RakeTask.new(:spec)
@@ -11,27 +10,9 @@ RuboCop::RakeTask.new(:rubocop) do |task|
11
10
  task.options = %w[--parallel]
12
11
  end
13
12
 
14
- desc "Alias for `rake rufo:run`"
15
- task :format => ["rufo:run"]
16
-
17
- namespace :rufo do
18
- require "rufo"
19
-
20
- def rufo_command(*switches, rake_args)
21
- files_or_dirs = rake_args[:files_or_dirs] || "."
22
- args = switches + files_or_dirs.split(" ")
23
- Rufo::Command.run(args)
24
- end
25
-
26
- desc "Format Ruby code in current directory"
27
- task :run, [:files_or_dirs] do |_task, rake_args|
28
- rufo_command(rake_args)
29
- end
30
-
31
- desc "Check that no formatting changes are produced"
32
- task :check, [:files_or_dirs] do |_task, rake_args|
33
- rufo_command("--check", rake_args)
34
- end
13
+ desc "update the rubocop todo config"
14
+ RuboCop::RakeTask.new("rubocop:config") do |task|
15
+ task.options = %w[--auto-gen-config]
35
16
  end
36
17
 
37
18
  desc "Generate test protobuf stubs"
@@ -40,8 +21,17 @@ task :gen_test_proto do
40
21
  Rufo::Command.run(["spec/support/grpc_service/test_pb.rb", "spec/support/grpc_service/test_services_pb.rb"])
41
22
  end
42
23
 
43
- task :fix => %w[rufo:run rubocop:auto_correct]
24
+ task :fix => ["rubocop:autocorrect"]
44
25
 
45
- task :verify => %w[spec rufo:check rubocop]
26
+ task :verify => %w[spec rubocop]
46
27
 
47
28
  task :default => %w[verify build]
29
+
30
+ desc "Start an IRB console with gem pre-loaded"
31
+ task :console do
32
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
33
+ require "irb"
34
+ require "gitlab-labkit"
35
+ ARGV.clear
36
+ IRB.start
37
+ end
@@ -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"
@@ -5,7 +5,8 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
 
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "gitlab-labkit"
8
- spec.version = `git describe --tags`.chomp.gsub(/^v/, "")
8
+ version = ENV["CI_COMMIT_TAG"] || `git describe --tag --match "v[0-9]*.[0-9]*.[0-9]*"`
9
+ spec.version = version.chomp.gsub(/^v/, "")
9
10
  spec.authors = ["Andrew Newdigate"]
10
11
  spec.email = ["andrew@gitlab.com"]
11
12
 
@@ -16,32 +17,36 @@ Gem::Specification.new do |spec|
16
17
 
17
18
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|tools)/}) }
18
19
  spec.require_paths = ["lib"]
19
- spec.required_ruby_version = ">= 2.6.0"
20
+ spec.required_ruby_version = "~> 3.2"
20
21
 
21
22
  # Please maintain alphabetical order for dependencies
22
23
  spec.add_runtime_dependency "actionpack", ">= 5.0.0", "< 8.1.0"
23
24
  spec.add_runtime_dependency "activesupport", ">= 5.0.0", "< 8.1.0"
24
25
  spec.add_runtime_dependency "grpc", ">= 1.62" # Be sure to update the "grpc-tools" dev_dependency too
26
+ spec.add_runtime_dependency "google-protobuf", "~> 3" # Keep the major version to 3 until we update the `grpc` gem
25
27
  spec.add_runtime_dependency "jaeger-client", "~> 1.1.0"
28
+ spec.add_runtime_dependency 'json-schema', '~> 5.1'
26
29
  spec.add_runtime_dependency "opentracing", "~> 0.4"
27
- spec.add_runtime_dependency "pg_query", ">= 5.1.0", "< 7.0"
30
+ spec.add_runtime_dependency "pg_query", ">= 6.1.0", "< 7.0"
31
+ spec.add_runtime_dependency "prometheus-client-mmap", "~> 1.2.9"
28
32
  spec.add_runtime_dependency "redis", "> 3.0.0", "< 6.0.0"
29
33
 
30
34
  # Please maintain alphabetical order for dev dependencies
31
35
  spec.add_development_dependency "excon", "~> 0.78.1"
32
36
  spec.add_development_dependency "faraday", "~> 1.10.3"
33
37
  spec.add_development_dependency "gitlab-dangerfiles", "~> 2.11.0"
34
- spec.add_development_dependency "gitlab-styles", "~> 6.2.0"
38
+ spec.add_development_dependency "gitlab-styles", "~> 13.0.2"
35
39
  spec.add_development_dependency "grpc-tools", ">= 1.62"
36
- spec.add_development_dependency "httparty", "~> 0.17.3"
37
- spec.add_development_dependency "httpclient", "~> 2.8.3"
40
+ spec.add_development_dependency "httparty", "~> 0.22.0"
41
+ spec.add_development_dependency "httpclient", "~> 2.9.0"
42
+ spec.add_development_dependency "irb", "~> 1.15.2"
38
43
  spec.add_development_dependency "pry", "~> 0.12"
44
+ spec.add_development_dependency "pry-byebug", "~> 3.11"
39
45
  spec.add_development_dependency "rack", "~> 2.0"
40
- spec.add_development_dependency "rake", "~> 12.3"
46
+ spec.add_development_dependency "rake", "~> 13.2"
41
47
  spec.add_development_dependency "rest-client", "~> 2.1.0"
42
48
  spec.add_development_dependency "rspec", "~> 3.12.0"
43
49
  spec.add_development_dependency "rspec-parameterized", "~> 1.0"
44
- spec.add_development_dependency "rufo", "0.9.0"
45
50
  spec.add_development_dependency "sidekiq", ">= 5.2", "< 7"
46
51
  spec.add_development_dependency "webrick", "~> 1.7.0"
47
52
  end
data/lib/gitlab-labkit.rb CHANGED
@@ -7,11 +7,13 @@
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"
16
+ autoload :Metrics, "labkit/metrics"
15
17
  autoload :Middleware, "labkit/middleware"
16
18
 
17
19
  # Publishers to publish notifications whenever a HTTP reqeust is made.
@@ -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