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
@@ -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,98 @@
1
+ # Labkit::Metrics
2
+
3
+ ## Usage
4
+
5
+ ```ruby
6
+ # create a new counter metric and returns it
7
+ http_requests = Labkit::Metrics::Client.counter(:http_requests, 'A counter of HTTP requests made')
8
+ # start using the counter
9
+ http_requests.increment
10
+
11
+ # resets the registry and reinitializes all metrics files
12
+ Labkit::Metrics::Client.reset!
13
+
14
+ # retrieves the metric (be it a counter, gauge, histogram, summary)
15
+ http_requests = Labkit::Metrics::Client.get(:http_requests)
16
+ ```
17
+
18
+ ### Counter
19
+
20
+ ```ruby
21
+ counter = Labkit::Metrics::Client.counter(:service_requests_total, '...')
22
+
23
+ # increment the counter for a given label set
24
+ counter.increment({ service: 'foo' })
25
+
26
+ # increment by a given value
27
+ counter.increment({ service: 'bar' }, 5)
28
+
29
+ # get current value for a given label set
30
+ counter.get({ service: 'bar' })
31
+ # => 5
32
+ ```
33
+
34
+ ### Gauge
35
+
36
+ ```ruby
37
+ gauge = Labkit::Metrics::Client.gauge(:room_temperature_celsius, '...')
38
+
39
+ # set a value
40
+ gauge.set({ room: 'kitchen' }, 21.534)
41
+
42
+ # retrieve the current value for a given label set
43
+ gauge.get({ room: 'kitchen' })
44
+ # => 21.534
45
+ ```
46
+
47
+ ### Histogram
48
+
49
+ ```ruby
50
+ histogram = Labkit::Metrics::Client.histogram(:service_latency_seconds, '...')
51
+
52
+ # record a value
53
+ histogram.observe({ service: 'users' }, Benchmark.realtime { service.call(arg) })
54
+
55
+ # retrieve the current bucket values
56
+ histogram.get({ service: 'users' })
57
+ # => { 0.005 => 3, 0.01 => 15, 0.025 => 18, ..., 2.5 => 42, 5 => 42, 10 => 42 }
58
+ ```
59
+
60
+ ### Summary
61
+
62
+ ```ruby
63
+ summary = Labkit::Metrics::Client.summary(:service_latency_seconds, '...')
64
+
65
+ # record a value
66
+ summary.observe({ service: 'database' }, Benchmark.realtime { service.call() })
67
+
68
+ # retrieve the current quantile values
69
+ summary.get({ service: 'database' })
70
+ # => { 0.5 => 0.1233122, 0.9 => 3.4323, 0.99 => 5.3428231 }
71
+ ```
72
+
73
+ ## Rack middleware
74
+
75
+ ```ruby
76
+ # config.ru
77
+
78
+ require 'rack'
79
+ require 'labkit/metrics/rack_exporter'
80
+
81
+ use Labkit::Metrics::RackExporter
82
+
83
+ run ->(env) { [200, {'Content-Type' => 'text/html'}, ['OK']] }
84
+ ```
85
+
86
+ ## Configuration
87
+
88
+ ```ruby
89
+ # config/initializers/metrics.rb
90
+
91
+ Labkit::Metrics::Client.reinitialize_on_pid_change(force: true)
92
+
93
+ Labkit::Metrics::Client.configure do |config|
94
+ config.logger = Gitlab::AppLogger
95
+ config.multiprocess_files_dir = 'tmp/prometheus_multiproc_dir'
96
+ config.pid_provider = ::Prometheus::PidProvider.method(:worker_id)
97
+ end
98
+ ```
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "singleton"
5
+ require 'concurrent-ruby'
6
+ require "prometheus/client"
7
+ require "labkit/metrics/registry"
8
+ require "labkit/metrics/null"
9
+
10
+ module Labkit
11
+ module Metrics
12
+ InvalidLabelSet = Class.new(RuntimeError)
13
+
14
+ # A thin wrapper around Prometheus::Client from the prometheus-client-mmap gem
15
+ # https://gitlab.com/gitlab-org/ruby/gems/prometheus-client-mmap
16
+ class Client
17
+ include Singleton
18
+ extend Forwardable
19
+
20
+ def_delegators :wrapped_client, :configure, :reinitialize_on_pid_change, :configuration
21
+
22
+ def initialize
23
+ @enabled = Concurrent::AtomicBoolean.new(true)
24
+ end
25
+
26
+ def wrapped_client
27
+ @client ||= ::Prometheus::Client
28
+ end
29
+
30
+ def disable!
31
+ @enabled.make_false
32
+ end
33
+
34
+ def enable!
35
+ @enabled.make_true
36
+ end
37
+
38
+ def enabled?
39
+ @enabled.true? && metrics_folder_present?
40
+ end
41
+
42
+ def safe_provide_metric(metric_type, name, *args)
43
+ return Null.instance unless enabled?
44
+
45
+ Registry.safe_register(metric_type, name, *args)
46
+ end
47
+
48
+ def metrics_folder_present?
49
+ dir = configuration.multiprocess_files_dir
50
+ dir && Dir.exist?(dir) && File.writable?(dir)
51
+ end
52
+
53
+ def counter(name, docstring, base_labels = {})
54
+ safe_provide_metric(:counter, name, docstring, base_labels)
55
+ end
56
+
57
+ def summary(name, docstring, base_labels = {})
58
+ safe_provide_metric(:summary, name, docstring, base_labels)
59
+ end
60
+
61
+ def histogram(
62
+ name, docstring, base_labels = {},
63
+ buckets = Prometheus::Client::Histogram::DEFAULT_BUCKETS)
64
+ safe_provide_metric(:histogram, name, docstring, base_labels, buckets)
65
+ end
66
+
67
+ def gauge(name, docstring, base_labels = {}, multiprocess_mode = :all)
68
+ safe_provide_metric(:gauge, name, docstring, base_labels, multiprocess_mode)
69
+ end
70
+
71
+ def reset!
72
+ Registry.reset!
73
+ end
74
+
75
+ def get(metric_name)
76
+ Registry.get(metric_name)
77
+ end
78
+
79
+ class << self
80
+ extend Forwardable
81
+
82
+ def_delegators :instance,
83
+ :enable!, :disable!, :counter, :gauge, :histogram, :summary, :reset!, :enabled?,
84
+ :configure, :reinitialize_on_pid_change, :configuration, :get
85
+
86
+ private :instance
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module Metrics
5
+ # Fakes Prometheus::Client::Metric and all derived metrics.
6
+ # Explicitly avoiding meta-programming to make interface more obvious to interact with.
7
+ class Null
8
+ include Singleton
9
+
10
+ attr_reader :name, :docstring, :base_labels
11
+
12
+ def get(*args); end
13
+ def set(*args); end
14
+ def increment(*args); end
15
+ def decrement(*args); end
16
+ def observe(*args); end
17
+ def values(*args); end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prometheus/client/rack/exporter"
4
+
5
+ module Labkit
6
+ module Metrics
7
+ # A wrapper around the Rack exporter middleware provided by
8
+ # prometheus-client-mmap gem
9
+ # https://gitlab.com/gitlab-org/ruby/gems/prometheus-client-mmap
10
+ class RackExporter < Prometheus::Client::Rack::Exporter; end
11
+ end
12
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prometheus/client"
4
+
5
+ module Labkit
6
+ module Metrics
7
+ InvalidMetricType = Class.new(StandardError)
8
+
9
+ # A thin wrapper around Prometheus::Client::Registry.
10
+ # It provides a thread-safe way to register metrics with the Prometheus registry.
11
+ class Registry
12
+ class << self
13
+ INIT_REGISTRY_MUTEX = Mutex.new
14
+ REGISTER_MUTEX = Mutex.new
15
+
16
+ # Registers a metric with the Prometheus registry in a thread-safe manner.
17
+ # If the metric already exists, it returns the existing metric.
18
+ # If the metric does not exist, it creates a new one.
19
+ # Each metric-name is only registered once for a type (counter, gauge, histogram, summary),
20
+ # even if multiple threads attempt to register the same metric simultaneously.
21
+ #
22
+ # @param metric_type [Symbol] The type of metric to register (:counter, :gauge, :histogram, :summary)
23
+ # @param name [Symbol, String] The name of the metric
24
+ # @param args [Array] Additional arguments to pass to the metric constructor
25
+ # @return [Prometheus::Client::Metric] The registered metric
26
+ # @raise [InvalidMetricType] If the metric_type is not supported
27
+ #
28
+ # @example
29
+ # # Register a counter
30
+ # counter = Registry.safe_register(:counter, :http_requests_total, 'Total HTTP requests')
31
+ def safe_register(metric_type, name, *args)
32
+ REGISTER_MUTEX.synchronize do
33
+ get(name) || wrapped_registry.method(metric_type).call(name, *args)
34
+ end
35
+ end
36
+
37
+ # Cleans up the Prometheus registry and resets it to a new state.
38
+ def reset!
39
+ INIT_REGISTRY_MUTEX.synchronize do
40
+ Prometheus::Client.cleanup!
41
+ Prometheus::Client.reset!
42
+ @registry = nil
43
+ end
44
+ end
45
+
46
+ # Returns the metric for the given name from the Prometheus registry.
47
+ #
48
+ # @param metric_name [Symbol, String] The name of the metric
49
+ # @return [Prometheus::Client::Metric, nil] The registered metric or nil if it does not exist
50
+ def get(metric_name)
51
+ wrapped_registry.get(metric_name)
52
+ end
53
+
54
+ private
55
+
56
+ def wrapped_registry
57
+ @registry ||= init_registry
58
+ end
59
+
60
+ def init_registry
61
+ # Prometheus::Client.registry initializes a new registry with an underlying hash
62
+ # storing metrics and a mutex synchronizing the writes to that hash.
63
+ # This means we need to make sure we only build one registry, for accessing from within Labkit.
64
+ INIT_REGISTRY_MUTEX.synchronize { Prometheus::Client.registry }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prometheus/client/formats/text"
4
+
5
+ module Labkit
6
+ # Metrics provides functionality for producing metrics
7
+ module Metrics
8
+ autoload :Client, "labkit/metrics/client"
9
+ autoload :RackExporter, "labkit/metrics/rack_exporter"
10
+ autoload :Null, "labkit/metrics/null"
11
+
12
+ class << self
13
+ def prometheus_metrics_text
14
+ dir = Client.configuration.multiprocess_files_dir
15
+ ::Prometheus::Client::Formats::Text.marshal_multiprocess(dir)
16
+ end
17
+ end
18
+ end
19
+ 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