gitlab-labkit 1.3.0 → 1.3.1

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: a0cf5e5935ee28f4b6bcc2165d6df81c38006f2b503044563850e744cfb85737
4
- data.tar.gz: e5c6137d9430cedfc0984d279d09c9758cdc43e6c25734c91d5b558cf92452e5
3
+ metadata.gz: 888cc5479aac46e57a89719bf13d9403a83f4d92c4f6c2a280b4c71950fee250
4
+ data.tar.gz: 2d5cc94125a609627065b10faa15a85eec3998285686db906a3ce1f54ed1f89d
5
5
  SHA512:
6
- metadata.gz: 4fab0460afaf9c9912e0225942395669336232f1de591a638e71715ea198cb9cee3f8c5dd462478fc62d1f3ca1741d7d4561983fb5a01cc6760efea2ed2d9374
7
- data.tar.gz: ed6778b22816c5e4b301f12b71b28a97d126f4238b896941674a543631a89b7d0624d0c2b2210918f2621f1a9cf8d4c874f3cb0df0c9e67018dbe5d420ba5330
6
+ metadata.gz: bc655d31560edac3cc29db253c3ea9c20f5fddfc479ac6f76563d56dfd795f265b0119a4480fa9bd10f90c9652eba31f458625a48570b70119004e4bf8ff609f
7
+ data.tar.gz: 992a1a49adecaf7a52bb4caaec4484dc8feddcfc88fc83a8ddb4ada3b0aca0f6b50de32bf6a64062c9380482958175ff237a037d3c61023e4e541cb1efbd2230
data/.gitlab/CODEOWNERS CHANGED
@@ -1 +1 @@
1
- * @andrewn @ayufan @reprazent @mkaeppler
1
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael
data/.gitlab-ci.yml CHANGED
@@ -19,13 +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
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v3.4
22
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v3.5
23
23
 
24
24
  # Runs rspec tests and rubocop on the project
25
25
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/ruby.md
26
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v3.4
26
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v3.5
27
27
 
28
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v3.4
28
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v3.5
29
29
 
30
30
  .test_template: &test_definition
31
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: v3.4 # renovate:managed
28
+ rev: v3.5 # renovate:managed
29
29
 
30
30
  hooks:
31
31
  - id: shellcheck # Run shellcheck for changed Shell files
data/CODEOWNERS CHANGED
@@ -1,4 +1,4 @@
1
1
  # CODEOWNERS is used to lookup assignees for
2
2
  # Renovate Bot dependency change Merge Requests.
3
3
  # https://docs.renovatebot.com/configuration-options/#assigneesfromcodeowners
4
- * @reprazent @andrewn @mkaeppler @ayufan @hmerscher
4
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael
@@ -0,0 +1,249 @@
1
+ # LabKit Field Standardization
2
+
3
+ ## Overview
4
+
5
+ The LabKit Field Validator detects when your code uses deprecated logging field names and helps you migrate to standardized fields. This supports the [Observability Field Standardisation initiative](https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/observability_field_standardisation/).
6
+
7
+ **Goal:** Standardize logging field names across GitLab so logs are queryable and actionable across all systems.
8
+
9
+ **How it works:** The validator intercepts logging calls during development and testing, detects deprecated fields, and compares them against a frozen baseline. New offenses fail CI; known offenses are tracked in `.labkit_logging_todo.yml`. The validator is **not** active in production environments.
10
+
11
+ For the architectural decision and rationale, see [ADR: Dynamic Runtime Linting](./architecture/decisions/001_field_standardization_dynamic_runtime_linting.md).
12
+
13
+ ## Key Concepts
14
+
15
+ **Offense**
16
+ - A unique combination of [File Path] + [Deprecated Field] + [Logger Class]
17
+ - Multiple log calls in the same file using the same deprecated field = 1 offense
18
+ - Offenses exist until the deprecated field is entirely removed from the file
19
+
20
+ **TODO Baseline**
21
+ - A list of known offenses tracked in `.labkit_logging_todo.yml`
22
+ - Existing offenses in this baseline are allowed
23
+ - Any new offenses detected during development raise an error
24
+ - Prevents regression while allowing incremental cleanup
25
+
26
+ ## Quick Start
27
+
28
+ ### First-Time Setup
29
+
30
+ 1. **Initialize the todo file:**
31
+
32
+ ```bash
33
+ bundle exec labkit-logging init
34
+ ```
35
+
36
+ This creates `.labkit_logging_todo.yml` with `skip_ci_failure: true`, which allows CI to pass while collecting the initial baseline.
37
+
38
+ 2. **Commit and push:**
39
+
40
+ ```bash
41
+ git add .labkit_logging_todo.yml
42
+ git commit -m "Add LabKit logging todo baseline"
43
+ git push
44
+ ```
45
+
46
+ 3. **Wait for CI to complete**, then fetch the baseline:
47
+
48
+ ```bash
49
+ bundle exec labkit-logging fetch <project> <pipeline_id>
50
+ ```
51
+
52
+ For example:
53
+ ```bash
54
+ bundle exec labkit-logging fetch gitlab-org/gitlab 12345
55
+ ```
56
+
57
+ This fetches all detected offenses from the CI pipeline logs and populates the todo file. The `skip_ci_failure` flag is automatically removed.
58
+
59
+ 4. **Commit the populated baseline:**
60
+
61
+ ```bash
62
+ git add .labkit_logging_todo.yml
63
+ git commit -m "Populate LabKit logging todo baseline"
64
+ git push
65
+ ```
66
+
67
+ Future CI runs will now enforce this baseline—new offenses will fail the pipeline.
68
+
69
+ ## Developer Workflow
70
+
71
+ ### Fixing Offenses (Recommended)
72
+
73
+ Replace deprecated fields with standard constants:
74
+
75
+ ```ruby
76
+ # Before
77
+ logger.info(user_id: current_user.id)
78
+
79
+ # After
80
+ logger.info(Labkit::Fields::GL_USER_ID => current_user.id)
81
+ ```
82
+
83
+ When you fix an offense, it's automatically removed from the baseline on the next test run. Run your tests locally to verify:
84
+
85
+ ```bash
86
+ LABKIT_LOGGING_TODO_UPDATE=true bundle exec rspec
87
+ ```
88
+
89
+ ### Adding Offenses Temporarily
90
+
91
+ If you can't fix an offense immediately, add it to the baseline:
92
+
93
+ ```bash
94
+ LABKIT_LOGGING_TODO_UPDATE=true bundle exec rspec
95
+ ```
96
+
97
+ This updates `.labkit_logging_todo.yml` with any new offenses found during the test run. Commit the updated file with your changes.
98
+
99
+ **Note:** Justify in your MR why you can't fix immediately. Keep the baseline as small as possible.
100
+
101
+ ### Regenerating the Baseline
102
+
103
+ To regenerate the entire baseline from scratch:
104
+
105
+ ```bash
106
+ rm .labkit_logging_todo.yml
107
+ bundle exec labkit-logging init
108
+ # Run CI, then:
109
+ bundle exec labkit-logging fetch <project> <pipeline_id>
110
+ ```
111
+
112
+ ## CI Behavior
113
+
114
+ ### Baseline Generation Mode
115
+
116
+ When `skip_ci_failure: true` is set in the todo file:
117
+
118
+ - CI passes even when deprecated fields are detected
119
+ - Offenses are logged for collection via `labkit-logging fetch`
120
+ - Use this mode only during initial setup
121
+
122
+ ### Enforcement Mode
123
+
124
+ When `skip_ci_failure` is not set (normal operation):
125
+
126
+ - **New offenses fail the pipeline** with a detailed error message
127
+ - **Known offenses** (in the baseline) are allowed
128
+ - **Fixed offenses** are automatically detected and can be removed from the baseline
129
+
130
+ Example CI failure output:
131
+
132
+ ```
133
+ ================================================================================
134
+ LabKit Logging Field Standardization: New Offenses Detected
135
+ ================================================================================
136
+
137
+ app/services/user_service.rb:42: 'user_id' is deprecated. Use 'Labkit::Fields::GL_USER_ID' instead.
138
+ app/models/project.rb:15: 'project_id' is deprecated. Use 'Labkit::Fields::GL_PROJECT_ID' instead.
139
+
140
+ ================================================================================
141
+ Total: 2 new offense(s) in 2 file(s)
142
+ ================================================================================
143
+ ```
144
+
145
+ ### When Offenses Are Fixed
146
+
147
+ When you fix offenses that were in the baseline, you'll see a message indicating which offenses were resolved. Update the baseline locally to remove them:
148
+
149
+ ```bash
150
+ LABKIT_LOGGING_TODO_UPDATE=true bundle exec rspec
151
+ git add .labkit_logging_todo.yml
152
+ git commit -m "Remove fixed logging offenses from baseline"
153
+ ```
154
+
155
+ ## CLI Reference
156
+
157
+ The `labkit-logging` command provides subcommands for managing the field validator.
158
+
159
+ ```bash
160
+ bundle exec labkit-logging <command> [options]
161
+ ```
162
+
163
+ ### labkit-logging init
164
+
165
+ Creates a new `.labkit_logging_todo.yml` file with `skip_ci_failure: true`.
166
+
167
+ ```bash
168
+ bundle exec labkit-logging init
169
+ ```
170
+
171
+ ### labkit-logging fetch
172
+
173
+ Fetches offense logs from a GitLab CI pipeline and updates the todo file.
174
+
175
+ ```bash
176
+ bundle exec labkit-logging fetch <project> <pipeline_id>
177
+ ```
178
+
179
+ **Arguments:**
180
+ - `project` - GitLab project ID or path (e.g., `278964` or `gitlab-org/gitlab`)
181
+ - `pipeline_id` - CI pipeline ID number
182
+
183
+ **Environment Variables:**
184
+ - `GITLAB_TOKEN` - GitLab API token (required)
185
+ - `CI_API_V4_URL` - GitLab API URL (default: `https://gitlab.com/api/v4`)
186
+
187
+ **Examples:**
188
+
189
+ ```bash
190
+ # Using project path
191
+ bundle exec labkit-logging fetch gitlab-org/gitlab 12345
192
+
193
+ # Using project ID
194
+ bundle exec labkit-logging fetch 278964 12345
195
+ ```
196
+
197
+ ## Environment Variables
198
+
199
+ | Variable | Description |
200
+ |----------|-------------|
201
+ | `LABKIT_LOGGING_TODO_UPDATE=true` | Update the baseline with new offenses (local development) |
202
+ | `GITLAB_TOKEN` | GitLab API token for fetching CI logs |
203
+ | `CI_API_V4_URL` | GitLab API URL (defaults to gitlab.com) |
204
+
205
+ ## Troubleshooting
206
+
207
+ **"New Offenses Detected" in CI**
208
+ - Fix the deprecated fields in your code, or
209
+ - Update the baseline locally: `LABKIT_LOGGING_TODO_UPDATE=true bundle exec rspec`
210
+ - Commit the updated `.labkit_logging_todo.yml`
211
+
212
+ **Offenses not detected**
213
+ - Ensure `.labkit_logging_todo.yml` exists in your project root
214
+ - Verify you're using `Labkit::Logging::JsonLogger`
215
+ - Check you're logging with a Hash (not String)
216
+ - Verify the code path is executed during tests
217
+
218
+ **Pipeline not found when fetching**
219
+ - Verify the project path/ID is correct
220
+ - Ensure the pipeline has completed (not still running)
221
+ - Check your `GITLAB_TOKEN` has read access to the project
222
+
223
+ **No offenses found in pipeline**
224
+ - Ensure `skip_ci_failure: true` was set during the CI run
225
+ - Verify the pipeline ran tests that exercise the logging code
226
+ - Check job logs are accessible with your token
227
+
228
+ ## TODO File Format
229
+
230
+ ```yaml
231
+ # LabKit Logging Field Standardization TODO
232
+ # AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.
233
+
234
+ offenses:
235
+ - logger_class: "Labkit::Logging::JsonLogger"
236
+ callsite: "app/services/user_service.rb"
237
+ deprecated_field: "user_id"
238
+ standard_field: "Labkit::Fields::GL_USER_ID"
239
+ - logger_class: "Labkit::Logging::JsonLogger"
240
+ callsite: "app/models/project.rb"
241
+ deprecated_field: "project_id"
242
+ standard_field: "Labkit::Fields::GL_PROJECT_ID"
243
+ ```
244
+
245
+ ## References
246
+
247
+ - [ADR: Dynamic Runtime Linting](./architecture/decisions/001_field_standardization_dynamic_runtime_linting.md)
248
+ - [Observability Field Standardisation](https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/observability_field_standardisation/)
249
+ - [Quality Epic](https://gitlab.com/groups/gitlab-org/quality/-/epics/235)
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'yaml'
8
+ require 'set'
9
+ require 'labkit/fields'
10
+ require 'labkit/logging/field_validator/config'
11
+
12
+ module Labkit
13
+ module Logging
14
+ module FieldValidator
15
+ class CLI
16
+ THREAD_POOL_SIZE = 10
17
+ DETECTED_OFFENSE_PREFIX = 'LABKIT_LOGGING_OFFENSE'
18
+
19
+ def run(args)
20
+ case args.shift
21
+ when 'init' then init_todo_file
22
+ when 'fetch' then fetch(args)
23
+ else puts "Usage: labkit-logging <init|fetch> [options]\n\n init Initialize .labkit_logging_todo.yml\n fetch Fetch offenses: labkit-logging fetch <project> <pipeline_id>"
24
+ end
25
+ end
26
+
27
+ def init_todo_file
28
+ Config.init_file!
29
+ warn "Created #{Config.file_name} with skip_ci_failure enabled.\n\nNext steps:\n1. Commit this file\n2. Push and let CI run\n3. Run: labkit-logging fetch <project> <pipeline_id>"
30
+ end
31
+
32
+ private
33
+
34
+ def fetch(args)
35
+ @project = args[0]
36
+ @pipeline = args[1]
37
+ abort "Usage: labkit-logging fetch <project> <pipeline_id>" unless @project && @pipeline&.match?(/^\d+$/)
38
+ abort "GITLAB_TOKEN environment variable is required" unless token
39
+
40
+ warn "Fetching offenses from pipeline #{@pipeline}..."
41
+ validate_pipeline!
42
+ jobs = fetch_jobs
43
+
44
+ detected = process_jobs(jobs)
45
+ new_off, removed_off = compute_diff(detected)
46
+
47
+ if new_off.empty? && removed_off.empty?
48
+ warn "\nNo changes detected."
49
+ return
50
+ end
51
+
52
+ save_results(new_off, removed_off)
53
+ end
54
+
55
+ def validate_pipeline!
56
+ resp = api_get("/projects/#{enc(@project)}/pipelines/#{@pipeline}")
57
+ abort "Pipeline not found" unless resp.is_a?(Net::HTTPSuccess)
58
+ status = JSON.parse(resp.body)['status']
59
+ abort "Pipeline still running" if status == 'running'
60
+ abort "Pipeline status: #{status}" unless %w[success failed].include?(status)
61
+ end
62
+
63
+ def fetch_jobs
64
+ jobs = []
65
+ page = 1
66
+ loop do
67
+ resp = api_get("/projects/#{enc(@project)}/pipelines/#{@pipeline}/jobs?per_page=100&page=#{page}")
68
+ abort "Failed to fetch jobs" unless resp.is_a?(Net::HTTPSuccess)
69
+ batch = JSON.parse(resp.body)
70
+ break if batch.empty?
71
+
72
+ jobs.concat(batch)
73
+ page += 1
74
+ end
75
+ jobs
76
+ end
77
+
78
+ def process_jobs(jobs)
79
+ detected = []
80
+ mutex = Mutex.new
81
+ queue = Queue.new
82
+ jobs.each { |j| queue << j }
83
+ total = jobs.size
84
+ done = 0
85
+
86
+ threads = Array.new(THREAD_POOL_SIZE) do
87
+ Thread.new do
88
+ loop do
89
+ job = begin
90
+ queue.pop(true)
91
+ rescue StandardError
92
+ nil
93
+ end
94
+ break unless job
95
+
96
+ begin
97
+ resp = api_get("/projects/#{enc(@project)}/jobs/#{job['id']}/trace")
98
+ if resp.is_a?(Net::HTTPSuccess)
99
+ offenses = parse_log(resp.body)
100
+ mutex.synchronize { detected.concat(offenses) }
101
+ end
102
+ rescue StandardError => e
103
+ mutex.synchronize { warn "\nWarning: #{job['name']}: #{e.message}" }
104
+ ensure
105
+ mutex.synchronize do
106
+ done += 1
107
+ $stderr.print "\rProcessing jobs: #{done}/#{total}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ threads.each(&:join)
114
+ warn ""
115
+
116
+ dedupe(detected)
117
+ end
118
+
119
+ def parse_log(log)
120
+ offenses = []
121
+ log.each_line do |line|
122
+ clean = line.gsub(/\e\[[0-9;]*[a-zA-Z]/, '').strip
123
+ next unless clean.include?(DETECTED_OFFENSE_PREFIX)
124
+
125
+ idx = clean.index(DETECTED_OFFENSE_PREFIX)
126
+ next unless idx
127
+
128
+ json = clean[(idx + DETECTED_OFFENSE_PREFIX.length)..].sub(/^[:\s]+/, '')
129
+ offense = begin
130
+ JSON.parse(json)
131
+ rescue StandardError
132
+ nil
133
+ end
134
+ offenses << offense if offense
135
+ end
136
+ offenses
137
+ end
138
+
139
+ def compute_diff(detected)
140
+ baseline = Config.load.fetch('offenses', [])
141
+
142
+ baseline_keys = baseline.to_set { |o| [o['callsite'], o['deprecated_field'], o['logger_class']] }
143
+ detected_keys = detected.to_set { |o| [o['callsite'], o['deprecated_field'], o['logger_class']] }
144
+
145
+ new_offenses = detected.reject do |o|
146
+ key = [o['callsite'], o['deprecated_field'], o['logger_class']]
147
+ baseline_keys.include?(key)
148
+ end
149
+
150
+ removed_offenses = baseline.select do |o|
151
+ key = [o['callsite'], o['deprecated_field'], o['logger_class']]
152
+ !detected_keys.include?(key) # rubocop:disable Rails/NegateInclude -- Set has no exclude? method
153
+ end
154
+
155
+ [new_offenses, removed_offenses]
156
+ end
157
+
158
+ def dedupe(offenses)
159
+ offenses.uniq { |o| [o['callsite'], o['deprecated_field'], o['logger_class']] }
160
+ end
161
+
162
+ def save_results(new_off, removed_off)
163
+ skip_removed = Config.load.fetch('skip_ci_failure', false)
164
+ updated = Config.update!(new_off, removed_off)
165
+ warn "\n✓ Added #{new_off.size} new offenses" if new_off.any?
166
+ warn "✓ Removed #{removed_off.size} fixed offenses" if removed_off.any?
167
+ warn "✓ Removed skip_ci_failure flag" if skip_removed
168
+ warn "✓ Total: #{updated.size} offenses\n\nCommit the updated #{Config.file_name} file."
169
+ end
170
+
171
+ def api_get(path)
172
+ uri = URI.parse("#{api_url}#{path}")
173
+ http = Net::HTTP.new(uri.host, uri.port)
174
+ http.use_ssl = uri.scheme == 'https'
175
+ http.open_timeout = 10
176
+ http.read_timeout = 30
177
+ req = Net::HTTP::Get.new(uri.request_uri)
178
+ req['PRIVATE-TOKEN'] = token
179
+ http.request(req)
180
+ end
181
+
182
+ def token
183
+ ENV['GITLAB_API_PRIVATE_TOKEN'] || ENV['GITLAB_TOKEN'] || ENV.fetch('CI_JOB_TOKEN', nil)
184
+ end
185
+
186
+ def api_url
187
+ ENV['GITLAB_API_ENDPOINT'] || ENV['CI_API_V4_URL'] || 'https://gitlab.com/api/v4'
188
+ end
189
+
190
+ def enc(str)
191
+ URI.encode_www_form_component(str.to_s)
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ Labkit::Logging::FieldValidator::CLI.new.run(ARGV)
@@ -16,6 +16,8 @@ Gem::Specification.new do |spec|
16
16
  spec.license = "MIT"
17
17
 
18
18
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|tools)/}) }
19
+ spec.bindir = "exe"
20
+ spec.executables = %w[labkit-logging]
19
21
  spec.require_paths = ["lib"]
20
22
  spec.required_ruby_version = "~> 3.2"
21
23
 
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module Logging
5
+ module FieldValidator
6
+ module Config
7
+ FILE_NAME = '.labkit_logging_todo.yml'
8
+
9
+ class << self
10
+ def root
11
+ @root ||= detect_root.freeze
12
+ end
13
+
14
+ def path
15
+ @path ||= File.join(root, file_name).freeze
16
+ end
17
+
18
+ def file_name
19
+ FILE_NAME
20
+ end
21
+
22
+ def deprecated_fields
23
+ @deprecated_fields ||= Labkit::Fields::Deprecated.all.freeze
24
+ end
25
+
26
+ def load
27
+ return {} unless config_file_exists?
28
+
29
+ YAML.safe_load_file(path) || {}
30
+ end
31
+
32
+ def update!(new_offenses, removed_offenses = [])
33
+ baseline_offenses = load.fetch('offenses', [])
34
+
35
+ if removed_offenses.any?
36
+ removed_keys = removed_offenses.to_set { |o| offense_key(o) }
37
+ baseline_offenses = baseline_offenses.reject { |o| removed_keys.include?(offense_key(o)) }
38
+ end
39
+
40
+ updated = process_offenses(baseline_offenses + new_offenses)
41
+ write_file({ 'offenses' => updated }, header: header)
42
+ updated
43
+ end
44
+
45
+ def init_file!
46
+ return if config_file_exists?
47
+
48
+ content = <<~YAML
49
+ # LabKit Logging Field Standardization TODO
50
+ # This file tracks deprecated logging fields that need migration.
51
+ #
52
+ # To collect offenses from CI:
53
+ # bundle exec labkit-logging fetch <project> <pipeline_id>
54
+ #
55
+ # To collect offenses locally:
56
+ # LABKIT_LOGGING_TODO_UPDATE=true <local development process>
57
+ #
58
+ # More info: https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/blob/master/doc/FIELD_STANDARDIZATION.md
59
+
60
+ skip_ci_failure: true # Remove this flag once baseline is established
61
+
62
+ offenses: []
63
+ YAML
64
+
65
+ write_file(nil, header: content)
66
+ end
67
+
68
+ def config_file_exists?
69
+ File.exist?(path)
70
+ end
71
+
72
+ def skip_ci_failure?
73
+ load.fetch('skip_ci_failure', false) == true
74
+ end
75
+
76
+ def detect_root
77
+ return Bundler.root.to_s if defined?(Bundler) && Bundler.respond_to?(:root)
78
+ return Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root)
79
+
80
+ Dir.pwd
81
+ end
82
+
83
+ def header
84
+ <<~HEADER
85
+ # LabKit Logging Field Standardization TODO
86
+ # AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.
87
+ #
88
+ # This file tracks deprecated logging fields that need to be migrated to standard fields.
89
+ # Each offense represents a file using a deprecated field that should be replaced.
90
+ #
91
+ # === HOW TO FIX ===
92
+ #
93
+ # 1. Replace the deprecated field with the standard field constant
94
+ # 2. Remove the deprecated field entirely (adding the new field is not enough)
95
+ # 3. Run your tests - the offense will be automatically removed
96
+ #
97
+ # Example:
98
+ # # Before
99
+ # logger.info(user_id: 123)
100
+ #
101
+ # # After
102
+ # logger.info(Labkit::Fields::GL_USER_ID => 123)
103
+ #
104
+ # === ADDING OFFENSES (if fixing is not immediately possible) ===
105
+ #
106
+ # Run: LABKIT_LOGGING_TODO_UPDATE=true bundle exec rspec <spec_file>
107
+ #
108
+ # === REGENERATE ENTIRE TODO ===
109
+ #
110
+ # Delete this file and run: LABKIT_LOGGING_TODO_UPDATE=true bundle exec rspec
111
+
112
+ HEADER
113
+ end
114
+
115
+ private
116
+
117
+ def write_file(data, header: self.header)
118
+ content = data ? header + YAML.dump(data, line_width: -1) : header
119
+ File.write(path, content)
120
+ end
121
+
122
+ def offense_key(offense)
123
+ [offense['callsite'], offense['deprecated_field'], offense['logger_class']]
124
+ end
125
+
126
+ def process_offenses(offenses)
127
+ offenses
128
+ .uniq { |o| offense_key(o) }
129
+ .sort_by { |o| offense_key(o) }
130
+ .map { |o| format_offense(o) }
131
+ end
132
+
133
+ def format_offense(offense)
134
+ {
135
+ 'logger_class' => offense['logger_class'],
136
+ 'callsite' => offense['callsite'],
137
+ 'deprecated_field' => offense['deprecated_field'],
138
+ 'standard_field' => standard_field_constant(offense['standard_field'])
139
+ }
140
+ end
141
+
142
+ def standard_field_constant(standard_field)
143
+ const_name = Labkit::Fields.constant_name_for(standard_field)
144
+ const_name ? "Labkit::Fields::#{const_name}" : standard_field
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module Logging
5
+ module FieldValidator
6
+ module LogInterceptor
7
+ IGNORE_PATHS = [
8
+ %r{/gems/logger-},
9
+ %r{/lib/labkit/logging/},
10
+ %r{/gems/rspec-},
11
+ %r{/ruby/\d+\.\d+\.\d+/}
12
+ ].freeze
13
+
14
+ LOGGER_WRAPPER_PATTERNS = [
15
+ %r{/.*logger\.rb$}
16
+ ].freeze
17
+
18
+ class << self
19
+ def register_wrapper_pattern(pattern)
20
+ wrapper_patterns << pattern
21
+ end
22
+
23
+ def wrapper_patterns
24
+ @wrapper_patterns ||= LOGGER_WRAPPER_PATTERNS.dup
25
+ end
26
+
27
+ def reset_wrapper_patterns!
28
+ @wrapper_patterns = nil
29
+ end
30
+ end
31
+
32
+ def format_data(severity, timestamp, progname, message)
33
+ data = super
34
+ location = determine_callsite
35
+
36
+ return data unless location
37
+
38
+ callsite_path = normalize_path(location.path)
39
+ return data unless callsite_path
40
+
41
+ all_fields = extract_string_keys(data)
42
+ logger_class = self.class.name || 'AnonymousLogger'
43
+
44
+ all_fields.each do |field|
45
+ standard_field = Labkit::Fields::Deprecated.standard_field_for(field)
46
+ next unless standard_field
47
+
48
+ Registry.instance.record_offense(callsite_path, location.lineno, field, standard_field, logger_class)
49
+ end
50
+
51
+ Registry.instance.check_for_removed_offenses(callsite_path, all_fields, logger_class)
52
+
53
+ data
54
+ end
55
+
56
+ private
57
+
58
+ def determine_callsite
59
+ locations = caller_locations(1, 30) || []
60
+
61
+ locations.find do |loc|
62
+ path = loc.path
63
+
64
+ next if path == __FILE__
65
+ next if IGNORE_PATHS.any? { |pattern| pattern.match?(path) }
66
+ next if LogInterceptor.wrapper_patterns.any? { |pattern| pattern.match?(path) }
67
+
68
+ true
69
+ end
70
+ end
71
+
72
+ def normalize_path(absolute_path)
73
+ root = Config.root
74
+ return nil unless absolute_path.start_with?(root)
75
+
76
+ start_idx = root.length
77
+ start_idx += 1 if absolute_path[start_idx] == '/'
78
+ absolute_path[start_idx..]
79
+ end
80
+
81
+ def extract_string_keys(data)
82
+ return Set.new unless data.is_a?(Hash)
83
+
84
+ data.keys.to_set(&:to_s)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'set'
5
+
6
+ module Labkit
7
+ module Logging
8
+ module FieldValidator
9
+ class Registry
10
+ include Singleton
11
+
12
+ attr_reader :offenses
13
+
14
+ def initialize
15
+ @mutex = Mutex.new
16
+ @offenses = Set.new
17
+ @offense_keys = Set.new # For O(1) lookup
18
+ @removed_offenses = Set.new
19
+ @baseline_by_callsite = nil
20
+ @resolved_fields = {} # Cache for resolved standard fields
21
+ end
22
+
23
+ def record_offense(callsite, lineno, deprecated_field, standard_field, logger_class)
24
+ key = [callsite, deprecated_field, logger_class].freeze
25
+
26
+ return if @offense_keys.include?(key)
27
+
28
+ @mutex.synchronize do
29
+ next if @offense_keys.include?(key)
30
+
31
+ @offense_keys << key
32
+ @offenses << {
33
+ 'callsite' => callsite,
34
+ 'lineno' => lineno,
35
+ 'deprecated_field' => deprecated_field,
36
+ 'standard_field' => standard_field,
37
+ 'logger_class' => logger_class
38
+ }.freeze
39
+
40
+ @removed_offenses.reject! { |r| offense_key(r) == key }
41
+ end
42
+ end
43
+
44
+ def check_for_removed_offenses(callsite, fields, logger_class)
45
+ baseline = baseline_by_callsite[[callsite, logger_class]]
46
+ return unless baseline
47
+
48
+ @mutex.synchronize do
49
+ baseline.each do |offense|
50
+ key = offense_key(offense)
51
+ next if @offense_keys.include?(key)
52
+
53
+ deprecated = offense['deprecated_field']
54
+ standard = resolve_standard_field(offense['standard_field'])
55
+
56
+ next unless fields.include?(standard) && fields.exclude?(deprecated)
57
+
58
+ @removed_offenses << offense
59
+ end
60
+ end
61
+ end
62
+
63
+ # Returns [detected_offenses, new_offenses, removed_offenses]
64
+ def finalize
65
+ @mutex.synchronize do
66
+ baseline_keys = load_baseline.to_set { |b| offense_key(b) }
67
+
68
+ new_offenses = @offenses.reject { |o| baseline_keys.include?(offense_key(o)) }
69
+
70
+ [@offenses.to_a, new_offenses, @removed_offenses.to_a]
71
+ end
72
+ end
73
+
74
+ def clear!
75
+ @mutex.synchronize do
76
+ @offenses.clear
77
+ @offense_keys.clear
78
+ @removed_offenses.clear
79
+ @baseline_by_callsite = nil
80
+ @resolved_fields.clear
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def offense_key(offense)
87
+ [offense['callsite'], offense['deprecated_field'], offense['logger_class']].freeze
88
+ end
89
+
90
+ def baseline_by_callsite
91
+ @baseline_by_callsite ||= load_baseline.group_by { |o| [o['callsite'], o['logger_class']] }
92
+ end
93
+
94
+ def resolve_standard_field(standard_field)
95
+ # Convert "Labkit::Fields::GL_USER_ID" to actual value "gl.user_id"
96
+ @resolved_fields[standard_field] ||= resolve_labkit_field_constant(standard_field)
97
+ end
98
+
99
+ def resolve_labkit_field_constant(standard_field)
100
+ return standard_field unless standard_field.start_with?('Labkit::Fields::')
101
+
102
+ const_name = standard_field.delete_prefix('Labkit::Fields::')
103
+ return standard_field unless Labkit::Fields.const_defined?(const_name)
104
+
105
+ Labkit::Fields.const_get(const_name)
106
+ end
107
+
108
+ def load_baseline
109
+ config = Config.load
110
+ config.fetch('offenses', [])
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'json'
5
+ require 'yaml'
6
+
7
+ require_relative 'field_validator/config'
8
+ require_relative 'field_validator/log_interceptor'
9
+ require_relative 'field_validator/registry'
10
+
11
+ module Labkit
12
+ module Logging
13
+ ##
14
+ # Runtime validator for logging fields.
15
+ # Validates logged fields against standard and deprecated field lists.
16
+ # This validator is automatically injected in non-production environments.
17
+ # Offenses are collected during test runs and development.
18
+ module FieldValidator
19
+ class << self
20
+ # Inject the validator into JsonLogger
21
+ def inject!
22
+ return if @injected
23
+
24
+ # If the config file does not exist, we don't inject the validator.
25
+ # To enable field validation in a repository, a config file must exist.
26
+ return unless Config.config_file_exists?
27
+
28
+ ::Labkit::Logging::JsonLogger.prepend(LogInterceptor)
29
+ Kernel.at_exit { FieldValidator.process_violations }
30
+ @injected = true
31
+ end
32
+
33
+ def initialize_todo_file
34
+ Config.init_file!
35
+ warn "Created .labkit_logging_todo.yml with skip_ci_failure enabled."
36
+ warn ""
37
+ warn "Next steps:"
38
+ warn "1. Commit this file to source control"
39
+ warn "2. Push and let CI run to generate offense logs"
40
+ warn "3. Run: bundle exec labkit-logging fetch <project> <pipeline_id>"
41
+ warn "4. Commit the populated todo file (skip_ci_failure will be removed automatically)"
42
+ end
43
+
44
+ def process_violations
45
+ detected_offenses, new_offenses, removed_offenses = Registry.instance.finalize
46
+
47
+ return if detected_offenses.empty? && new_offenses.empty? && removed_offenses.empty?
48
+
49
+ in_ci = ENV['CI'] == 'true'
50
+
51
+ output_ndjson(detected_offenses) if in_ci
52
+
53
+ # Auto-remove fixed offenses (not in CI to avoid race conditions)
54
+ handle_removed_offenses(removed_offenses) if removed_offenses.any? && !in_ci
55
+
56
+ if ENV['LABKIT_LOGGING_TODO_UPDATE'] == 'true'
57
+ handle_update(new_offenses)
58
+ elsif new_offenses.any?
59
+ handle_new_offenses(new_offenses)
60
+ end
61
+ end
62
+
63
+ def clear_offenses!
64
+ Registry.instance.clear!
65
+ end
66
+
67
+ private
68
+
69
+ def handle_removed_offenses(removed_offenses)
70
+ Config.update!([], removed_offenses)
71
+
72
+ warn thank_you_message(removed_offenses)
73
+ end
74
+
75
+ def thank_you_message(removed_offenses)
76
+ lines = [
77
+ "",
78
+ "=" * 80,
79
+ "Thank you for improving our logging standards!",
80
+ "=" * 80,
81
+ "",
82
+ "You fixed #{removed_offenses.size} deprecated field offense(s):",
83
+ ""
84
+ ]
85
+
86
+ removed_offenses.each do |o|
87
+ lines << " - #{o['callsite']}: '#{o['deprecated_field']}' -> '#{format_standard_field(o['standard_field'])}'"
88
+ end
89
+
90
+ lines << ""
91
+ lines << "#{Config.file_name} has been automatically updated."
92
+ lines << "Please commit the changes to complete the cleanup."
93
+ lines << ""
94
+ lines << ("=" * 80)
95
+ lines << ""
96
+
97
+ lines.join("\n")
98
+ end
99
+
100
+ def handle_new_offenses(new_offenses)
101
+ if ENV['CI'] == 'true' && Config.skip_ci_failure?
102
+ warn baseline_generation_message(new_offenses)
103
+ else
104
+ warn report_new_offenses(new_offenses)
105
+ raise "New LabKit logging offenses detected"
106
+ end
107
+ end
108
+
109
+ def baseline_generation_message(offenses)
110
+ lines = [
111
+ "",
112
+ "ℹ️ LabKit Logging: Baseline generation mode active",
113
+ "",
114
+ "Deprecated fields detected but skip_ci_failure is enabled.",
115
+ "Offenses are being logged for collection.",
116
+ "",
117
+ "To establish baseline:",
118
+ " bundle exec labkit-logging fetch <project> <pipeline_id>",
119
+ "",
120
+ "Documentation: https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/blob/master/doc/FIELD_STANDARDIZATION.md",
121
+ "",
122
+ "--- Offenses Summary ---",
123
+ "Total offenses: #{offenses.size} across #{offenses.map { |o| o['callsite'] }.uniq.size} file(s)",
124
+ ""
125
+ ]
126
+ lines.join("\n")
127
+ end
128
+
129
+ def output_ndjson(detected_offenses)
130
+ detected_offenses.each do |offense|
131
+ puts "LABKIT_LOGGING_OFFENSE: #{JSON.generate(offense)}"
132
+ end
133
+ end
134
+
135
+ def handle_update(new_offenses)
136
+ updated_offenses = Config.update!(new_offenses)
137
+
138
+ warn "\n✓ Updated .labkit_logging_todo.yml"
139
+ warn "\n Added #{new_offenses.size} new offenses ✓" if new_offenses.any?
140
+
141
+ warn "Total: #{updated_offenses.size} offenses"
142
+ warn "\nCommit the updated #{Config.file_name}."
143
+ end
144
+
145
+ def report_new_offenses(new_offenses)
146
+ lines = [
147
+ "",
148
+ "=" * 80,
149
+ "LabKit Logging Field Standardization: New Offenses Detected",
150
+ "=" * 80,
151
+ ""
152
+ ]
153
+
154
+ new_offenses.each do |o|
155
+ lines << "#{o['callsite']}:#{o['lineno']}: '#{o['deprecated_field']}' is deprecated. Use '#{format_standard_field(o['standard_field'])}' instead."
156
+ end
157
+
158
+ lines << ""
159
+ lines << ("=" * 80)
160
+ lines << "Total: #{new_offenses.size} new offense(s) in #{new_offenses.map { |o| o['callsite'] }.uniq.size} file(s)"
161
+ lines << ""
162
+ lines << "See https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/blob/master/doc/FIELD_STANDARDIZATION.md"
163
+ lines << ("=" * 80)
164
+ lines << ""
165
+
166
+ lines.join("\n")
167
+ end
168
+
169
+ def format_standard_field(standard_field)
170
+ const_name = Labkit::Fields.constant_name_for(standard_field)
171
+ const_name ? "Labkit::Fields::#{const_name}" : standard_field
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ Labkit::Logging::FieldValidator.inject!
@@ -7,5 +7,9 @@ module Labkit
7
7
  autoload :GRPC, "labkit/logging/grpc"
8
8
  autoload :Sanitizer, "labkit/logging/sanitizer"
9
9
  autoload :JsonLogger, "labkit/logging/json_logger"
10
+
11
+ # Eagerly load FieldValidator in non-production environments
12
+ # This ensures injection happens before JsonLogger instances are created
13
+ require "labkit/logging/field_validator" unless ENV['RAILS_ENV'] == 'production'
10
14
  end
11
15
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -451,7 +451,8 @@ dependencies:
451
451
  version: 1.7.0
452
452
  email:
453
453
  - andrew@gitlab.com
454
- executables: []
454
+ executables:
455
+ - labkit-logging
455
456
  extensions: []
456
457
  extra_rdoc_files: []
457
458
  files:
@@ -481,7 +482,9 @@ files:
481
482
  - Rakefile
482
483
  - config/user_experience_slis/schema.json
483
484
  - config/user_experience_slis/testing_sample.yml
485
+ - doc/FIELD_STANDARDIZATION.md
484
486
  - doc/architecture/decisions/001_field_standardization_dynamic_runtime_linting.md
487
+ - exe/labkit-logging
485
488
  - gitlab-labkit.gemspec
486
489
  - lib/gitlab-labkit.rb
487
490
  - lib/labkit/context.rb
@@ -498,6 +501,10 @@ files:
498
501
  - lib/labkit/json_schema/README.md
499
502
  - lib/labkit/json_schema/ref_resolver.rb
500
503
  - lib/labkit/logging.rb
504
+ - lib/labkit/logging/field_validator.rb
505
+ - lib/labkit/logging/field_validator/config.rb
506
+ - lib/labkit/logging/field_validator/log_interceptor.rb
507
+ - lib/labkit/logging/field_validator/registry.rb
501
508
  - lib/labkit/logging/grpc.rb
502
509
  - lib/labkit/logging/grpc/server_interceptor.rb
503
510
  - lib/labkit/logging/json_logger.rb