gitlab-labkit 1.5.0 → 1.12.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.copier-answers.yml +3 -1
  3. data/.gitlab/merge_request_templates/default.md +9 -0
  4. data/.gitlab-ci-asdf-versions.yml +2 -2
  5. data/.gitlab-ci.yml +14 -15
  6. data/.pre-commit-config.yaml +1 -1
  7. data/.releaserc.json +7 -1
  8. data/.tool-versions +2 -2
  9. data/CODEOWNERS +1 -1
  10. data/doc/FIELD_STANDARDIZATION.md +148 -128
  11. data/gitlab-labkit.gemspec +2 -2
  12. data/lib/labkit/context.rb +4 -0
  13. data/lib/labkit/fields.rb +127 -42
  14. data/lib/labkit/logging/field_validator/config.rb +5 -18
  15. data/lib/labkit/logging/field_validator.rb +1 -1
  16. data/lib/labkit/net_http_publisher.rb +20 -1
  17. data/lib/labkit/rspec/README.md +0 -9
  18. data/lib/labkit/rspec/matchers/user_experience_matchers.rb +53 -0
  19. data/lib/labkit/tracing/README.md +50 -25
  20. data/lib/labkit/tracing/abstract_instrumenter.rb +7 -8
  21. data/lib/labkit/tracing/adapters/opentelemetry_span.rb +4 -0
  22. data/lib/labkit/tracing/adapters/opentelemetry_tracer.rb +12 -0
  23. data/lib/labkit/tracing/adapters/opentracing_span.rb +4 -0
  24. data/lib/labkit/tracing/adapters/opentracing_tracer.rb +9 -6
  25. data/lib/labkit/tracing/auto_initialize.rb +28 -6
  26. data/lib/labkit/tracing/external_http/request_instrumenter.rb +4 -13
  27. data/lib/labkit/tracing/open_telemetry_factory.rb +3 -13
  28. data/lib/labkit/tracing/open_tracing_factory.rb +1 -10
  29. data/lib/labkit/tracing/rails/action_view/render_template_instrumenter.rb +6 -1
  30. data/lib/labkit/tracing/rails/active_record/sql_instrumenter.rb +9 -4
  31. data/lib/labkit/tracing/tracing_utils.rb +9 -7
  32. data/lib/labkit/tracing.rb +3 -1
  33. data/lib/labkit/user_experience_sli/README.md +20 -0
  34. data/lib/labkit/user_experience_sli/experience.rb +33 -12
  35. data/lib/labkit/user_experience_sli/null.rb +5 -2
  36. data/lib/labkit/user_experience_sli.rb +15 -3
  37. data/scripts/prepare-dev-env.sh +1 -0
  38. metadata +10 -6
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
3
+ require "uri"
4
4
  require "active_support/core_ext/object/blank"
5
5
 
6
6
  module Labkit
@@ -9,9 +9,16 @@ module Labkit
9
9
  def self.detect_service_name(connection_string)
10
10
  return Labkit::Tracing::DEFAULT_SERVICE_NAME unless connection_string
11
11
 
12
- if connection_string =~ /[?&]service_name=([^&]+)/
13
- CGI.unescape(Regexp.last_match(1))
14
- else
12
+ begin
13
+ parsed = URI.parse(connection_string)
14
+ options = TracingUtils.parse_query_string(parsed.query)
15
+
16
+ return options[:service_name] if options[:service_name]
17
+
18
+ warn "Labkit::Tracing: service_name not found in connection string, using default: '#{Labkit::Tracing::DEFAULT_SERVICE_NAME}'"
19
+ Labkit::Tracing::DEFAULT_SERVICE_NAME
20
+ rescue URI::InvalidURIError => e
21
+ warn "Labkit::Tracing: Invalid connection string (#{e.message}), using default service name: '#{Labkit::Tracing::DEFAULT_SERVICE_NAME}'"
15
22
  Labkit::Tracing::DEFAULT_SERVICE_NAME
16
23
  end
17
24
  end
@@ -22,9 +29,24 @@ module Labkit
22
29
 
23
30
  service_name = detect_service_name(connection_string)
24
31
 
32
+ require "opentelemetry/instrumentation/concurrent_ruby"
33
+ require "opentelemetry/instrumentation/net/http"
34
+ require "opentelemetry/instrumentation/action_pack" if defined?(ActionPack)
35
+ require "opentelemetry/instrumentation/action_mailer" if defined?(ActionMailer)
36
+ require "opentelemetry/instrumentation/active_job" if defined?(ActiveJob)
37
+
25
38
  Factory.create_tracer(service_name, connection_string) do |c|
26
- require "opentelemetry/instrumentation/all"
27
- c.use_all
39
+ # Enable only non-conflicting instrumentations
40
+ # Exclude: ActiveSupport, ActiveRecord, ActionView, Redis, Rack, Sidekiq
41
+ # (LabKit provides its own instrumentation for these via AbstractInstrumenter)
42
+
43
+ # Core instrumentations
44
+ c.use("OpenTelemetry::Instrumentation::ConcurrentRuby")
45
+
46
+ # Rails components that don't conflict with LabKit
47
+ c.use("OpenTelemetry::Instrumentation::ActionPack") if defined?(ActionPack)
48
+ c.use("OpenTelemetry::Instrumentation::ActionMailer") if defined?(ActionMailer)
49
+ c.use("OpenTelemetry::Instrumentation::ActiveJob") if defined?(ActiveJob)
28
50
  end
29
51
 
30
52
  enable_labkit_instrumentation
@@ -12,20 +12,11 @@ module Labkit
12
12
  def tags(payload)
13
13
  # Duration is calculated by start and end time
14
14
  # Exception is already captured in lib/labkit/tracing/tracing_utils.rb
15
- tags = {
16
- "component" => "external_http",
17
- "method" => payload[:method],
18
- "code" => payload[:code],
19
- "host" => payload[:host],
20
- "port" => payload[:port],
21
- "path" => payload[:path],
22
- "scheme" => payload[:scheme],
23
- }
15
+ tags = payload.slice(:method, :code, :host, :port, :path, :scheme, :proxy_host, :proxy_port)
16
+ tags.transform_keys!(&:name)
17
+ tags["component"] = "external_http"
24
18
 
25
- unless payload[:proxy_host].nil?
26
- tags["proxy_host"] = payload[:proxy_host]
27
- tags["proxy_port"] = payload[:proxy_port]
28
- end
19
+ tags.compact!
29
20
 
30
21
  tags
31
22
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
- require "cgi"
4
+ require "cgi/escape"
5
5
  require "active_support"
6
6
  require "active_support/core_ext"
7
7
 
@@ -79,14 +79,10 @@ module Labkit
79
79
  yield(c)
80
80
  end
81
81
 
82
- # SDK.configure doesn't expose sampler configuration directly
83
- # We need to replace the tracer provider to set the sampler
84
- # This is a known limitation of the OpenTelemetry Ruby SDK
85
82
  current_provider = OpenTelemetry.tracer_provider
86
83
  return unless current_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
87
84
 
88
- # Create new provider with sampler, preserving resource from SDK.configure
89
- create_and_configure_provider(current_provider.resource, sampler, exporter)
85
+ current_provider.sampler = sampler
90
86
  else
91
87
  create_and_configure_provider(base_resource, sampler, exporter)
92
88
  end
@@ -175,7 +171,7 @@ module Labkit
175
171
  parsed = URI.parse(connection_string)
176
172
 
177
173
  # Parse query parameters for additional options
178
- options = parse_query(parsed.query)
174
+ options = TracingUtils.parse_query_string(parsed.query)
179
175
 
180
176
  # Handle console exporter (special case - no endpoint needed)
181
177
  if parsed.host == "console"
@@ -206,12 +202,6 @@ module Labkit
206
202
 
207
203
  endpoint
208
204
  end
209
-
210
- def parse_query(query)
211
- return {} unless query
212
-
213
- CGI.parse(query).symbolize_keys.transform_values(&:first)
214
- end
215
205
  end
216
206
  end
217
207
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
4
-
5
3
  module Labkit
6
4
  module Tracing
7
5
  class OpenTracingFactory
@@ -26,17 +24,10 @@ module Labkit
26
24
 
27
25
  raise "Invalid tracing connection string" unless valid_uri?(parsed)
28
26
 
29
- { driver_name: parsed.host, options: parse_query(parsed.query) }
27
+ { driver_name: parsed.host, options: TracingUtils.parse_query_string(parsed.query) }
30
28
  end
31
29
  private_class_method :parse_connection_string
32
30
 
33
- def self.parse_query(query)
34
- return {} unless query
35
-
36
- CGI.parse(query).symbolize_keys.transform_values(&:first)
37
- end
38
- private_class_method :parse_query
39
-
40
31
  def self.valid_uri?(uri)
41
32
  return false unless uri
42
33
 
@@ -16,7 +16,12 @@ module Labkit
16
16
  end
17
17
 
18
18
  def tags(payload)
19
- { "component" => COMPONENT_TAG, "template.id" => payload[:identifier], "template.layout" => payload[:layout] }
19
+ tags = { "component" => COMPONENT_TAG }
20
+ # OpenTelemetry rejects nil attributes, and error templates may not
21
+ # have a layout (e.g., public/406-unsupported-browser.html).
22
+ tags["template.id"] = payload[:identifier] if payload[:identifier]
23
+ tags["template.layout"] = payload[:layout] if payload[:layout]
24
+ tags
20
25
  end
21
26
  end
22
27
  end
@@ -19,15 +19,20 @@ module Labkit
19
19
  fingerprint = Labkit::Logging::Sanitizer.sql_fingerprint(sql)
20
20
  end
21
21
 
22
- {
22
+ tags = {
23
23
  "component" => COMPONENT_TAG,
24
24
  "span.kind" => "client",
25
25
  "db.type" => "sql",
26
- "db.connection_id" => payload[:connection_id],
27
26
  "db.cached" => payload[:cached] || false,
28
- "db.statement" => sql,
29
- "db.statement_fingerprint" => fingerprint,
30
27
  }
28
+
29
+ # OpenTelemetry rejects nil attributes, so only add statement fields
30
+ # when ActiveRecord supplies SQL (schema events may omit it).
31
+ tags["db.statement"] = sql if sql
32
+ tags["db.statement_fingerprint"] = fingerprint if fingerprint
33
+ tags["db.connection_id"] = payload[:connection_id] if payload[:connection_id]
34
+
35
+ tags
31
36
  end
32
37
  end
33
38
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
3
4
  require "active_support/core_ext/string/starts_ends_with"
4
5
  require "opentracing"
5
6
 
@@ -16,7 +17,7 @@ module Labkit
16
17
  begin
17
18
  yield span
18
19
  rescue StandardError => e
19
- log_exception_on_span(span, e)
20
+ span.set_error(e)
20
21
  raise e
21
22
  end
22
23
  end
@@ -55,7 +56,7 @@ module Labkit
55
56
  span = tracer.start_span(operation_name, child_of: child_of, tags: tags, start_time: start_time)
56
57
 
57
58
  log_common_fields_on_span(span, operation_name)
58
- log_exception_on_span(span, exception) if exception
59
+ span.set_error(exception)
59
60
 
60
61
  span.finish(end_timestamp: end_time)
61
62
  end
@@ -68,11 +69,6 @@ module Labkit
68
69
  span.log_event("stack", stack: caller.join('\n')) if include_stacktrace?(operation_name)
69
70
  end
70
71
 
71
- # Add exception logging to a span
72
- def self.log_exception_on_span(span, exception)
73
- span.set_error(exception)
74
- end
75
-
76
72
  def self.include_stacktrace?(operation_name)
77
73
  @include_stacktrace ||= Hash.new do |result, name|
78
74
  result[name] = Tracing.stacktrace_operations.any? { |stacktrace_operation| name.starts_with?(stacktrace_operation) }
@@ -80,6 +76,12 @@ module Labkit
80
76
 
81
77
  @include_stacktrace[operation_name]
82
78
  end
79
+
80
+ def self.parse_query_string(query)
81
+ return {} unless query
82
+
83
+ URI.decode_www_form(query).each_with_object({}) { |(k, v), h| h[k.to_sym] ||= v }
84
+ end
83
85
  end
84
86
  end
85
87
  end
@@ -30,7 +30,9 @@ module Labkit
30
30
  autoload :OpentracingTracer, "labkit/tracing/adapters/opentracing_tracer"
31
31
  end
32
32
 
33
- DEFAULT_SERVICE_NAME = :'labkit-service'
33
+ # Must be a String, not Symbol, as OpenTelemetry requires resource attribute values
34
+ # to be strings, integers, floats, or booleans
35
+ DEFAULT_SERVICE_NAME = "labkit-service"
34
36
 
35
37
  # Module-level attribute for storing the configured service name
36
38
  # Set by Factory.create_tracer when a tracer is created
@@ -161,6 +161,26 @@ experience.checkpoint
161
161
  experience.complete
162
162
  ```
163
163
 
164
+ #### Observing a Past Experience
165
+
166
+ When an action already happened and you want to record its duration retroactively, use `observed`. It fires the start and end metrics without registering in the active context:
167
+
168
+ ```ruby
169
+ Labkit::UserExperienceSli.observed('merge_request_creation', start_time: start_time_of_past_action)
170
+ ```
171
+
172
+ You can also signal that an error occurred during the action:
173
+
174
+ ```ruby
175
+ Labkit::UserExperienceSli.observed('merge_request_creation', start_time: start_time_of_past_action, error: true)
176
+ ```
177
+
178
+ Extra labels are forwarded to both the start and end log events:
179
+
180
+ ```ruby
181
+ Labkit::UserExperienceSli.observed('merge_request_creation', start_time: start_time_of_past_action, worker: 'MyWorker')
182
+ ```
183
+
164
184
  #### Resuming Experiences
165
185
 
166
186
  You can resume a user experience SLI that was previously started and stored in the context. This is useful for distributed operations or when work spans multiple processes.
@@ -61,7 +61,8 @@ module Labkit
61
61
  #
62
62
  # @yield [self] When a block is provided, the experience will be completed automatically.
63
63
  # @param extra [Hash] Additional data to include in the log event
64
- # @return [self]
64
+ # @return [self] When no block is given.
65
+ # @return [Object] The result of the block when a block is given.
65
66
  # @raise [UserExperienceError] If the block raises an error.
66
67
  #
67
68
  # Usage:
@@ -104,7 +105,9 @@ module Labkit
104
105
  # Resume the User Experience.
105
106
  #
106
107
  # @yield [self] When a block is provided, the experience will be completed automatically.
107
- # @param extra [Hash] Additional data to include in the log
108
+ # @param extra [Hash] Additional data to include in the log event
109
+ # @return [self] When no block is given.
110
+ # @return [Object] The result of the block when a block is given.
108
111
  def resume(**extra, &)
109
112
  return self unless ensure_started!
110
113
 
@@ -135,6 +138,28 @@ module Labkit
135
138
  self
136
139
  end
137
140
 
141
+ # Records a past User Experience by its duration.
142
+ #
143
+ # @param start_time [Time] The time when the experience started.
144
+ # @param error [Boolean] Whether the experience ended in an error.
145
+ # @param extra [Hash] Additional data to include in the log events.
146
+ # @return [self]
147
+ def observed(start_time:, error: false, **extra)
148
+ @start_time = start_time.utc
149
+ @end_time = Time.now.utc
150
+ error!("observed_error") if error
151
+
152
+ checkpoint_counter.increment(checkpoint: "start", **base_labels)
153
+ log_event("start", **extra)
154
+
155
+ checkpoint_counter.increment(checkpoint: "end", **base_labels)
156
+ total_counter.increment(error: has_error?, **base_labels)
157
+ apdex_counter.increment(success: apdex_success?, **base_labels) unless has_error?
158
+ log_event("end", **extra)
159
+
160
+ self
161
+ end
162
+
138
163
  # Marks the experience as failed with an error
139
164
  #
140
165
  # @param error [StandardError, String] The error that caused the experience to fail.
@@ -168,16 +193,12 @@ module Labkit
168
193
  end
169
194
 
170
195
  def completable(**extra, &)
171
- begin
172
- yield self
173
- rescue StandardError => e
174
- error!(e)
175
- raise
176
- ensure
177
- complete(**extra)
178
- end
179
-
180
- self
196
+ yield self
197
+ rescue StandardError => e
198
+ error!(e)
199
+ raise
200
+ ensure
201
+ complete(**extra)
181
202
  end
182
203
 
183
204
  def ensure_incomplete!
@@ -11,15 +11,18 @@ module Labkit
11
11
  def id = 'null'
12
12
 
13
13
  def start(**_extra)
14
- yield self if block_given?
14
+ return yield(self) if block_given?
15
+
15
16
  self
16
17
  end
17
18
 
18
19
  def resume(**_extra)
19
- yield self if block_given?
20
+ return yield(self) if block_given?
21
+
20
22
  self
21
23
  end
22
24
 
25
+ def observed(**_kwargs) = self
23
26
  def push_attributes!(*_args) = self
24
27
  def checkpoint(*_args) = self
25
28
  def complete(*_args) = self
@@ -77,7 +77,7 @@ module Labkit
77
77
  reset_configuration
78
78
  end
79
79
 
80
- # Retrieves a covered experience using the experience_id.
80
+ # Retrieves a user experience using the experience_id.
81
81
  # It retrieves from the current context when available,
82
82
  # otherwise it instantiates a new experience with the definition
83
83
  # from the registry.
@@ -88,7 +88,7 @@ module Labkit
88
88
  find_current(experience_id) || raise_or_null(experience_id)
89
89
  end
90
90
 
91
- # Starts a covered experience using the experience_id.
91
+ # Starts a user experience using the experience_id.
92
92
  #
93
93
  # @param experience_id [String, Symbol] The ID of the experience to start.
94
94
  # @param extra [Hash] Additional data to include in the log event.
@@ -97,7 +97,19 @@ module Labkit
97
97
  get(experience_id).start(**extra, &)
98
98
  end
99
99
 
100
- # Resumes a covered experience using the experience_id.
100
+ # Records a past user experience by its duration atomically.
101
+ #
102
+ # @param experience_id [String, Symbol] The ID of the experience.
103
+ # @param start_time [Time] The time when the experience started.
104
+ # @param extra [Hash] Additional data to include in the log events.
105
+ # @return [Experience, Null] The observed experience or a Null object if not found (in production/staging).
106
+ def observed(experience_id, start_time:, **extra)
107
+ definition = registry[experience_id]
108
+ experience = definition ? Experience.new(definition) : raise_or_null(experience_id)
109
+ experience.observed(start_time: start_time, **extra)
110
+ end
111
+
112
+ # Resumes a user experience using the experience_id.
101
113
  #
102
114
  # @param experience_id [String, Symbol] The ID of the experience to resume.
103
115
  # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
@@ -54,6 +54,7 @@ fi
54
54
  # install mise/asdf dependencies
55
55
  echo "installing required plugins with mise install.."
56
56
  mise plugins update -q
57
+ mise trust
57
58
  mise install
58
59
 
59
60
  # set PROMPT_COMMAND to empty value for mise if unset
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: 1.5.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -267,14 +267,14 @@ dependencies:
267
267
  requirements:
268
268
  - - "~>"
269
269
  - !ruby/object:Gem::Version
270
- version: 1.10.3
270
+ version: '2.13'
271
271
  type: :development
272
272
  prerelease: false
273
273
  version_requirements: !ruby/object:Gem::Requirement
274
274
  requirements:
275
275
  - - "~>"
276
276
  - !ruby/object:Gem::Version
277
- version: 1.10.3
277
+ version: '2.13'
278
278
  - !ruby/object:Gem::Dependency
279
279
  name: gitlab-dangerfiles
280
280
  requirement: !ruby/object:Gem::Requirement
@@ -525,6 +525,7 @@ files:
525
525
  - ".gitlab-ci-asdf-versions.yml"
526
526
  - ".gitlab-ci.yml"
527
527
  - ".gitlab/CODEOWNERS"
528
+ - ".gitlab/merge_request_templates/default.md"
528
529
  - ".gitleaks.toml"
529
530
  - ".mise.toml"
530
531
  - ".pre-commit-config.yaml"
@@ -664,16 +665,19 @@ require_paths:
664
665
  - lib
665
666
  required_ruby_version: !ruby/object:Gem::Requirement
666
667
  requirements:
667
- - - "~>"
668
+ - - ">="
669
+ - !ruby/object:Gem::Version
670
+ version: '3.3'
671
+ - - "<"
668
672
  - !ruby/object:Gem::Version
669
- version: '3.2'
673
+ version: '5'
670
674
  required_rubygems_version: !ruby/object:Gem::Requirement
671
675
  requirements:
672
676
  - - ">="
673
677
  - !ruby/object:Gem::Version
674
678
  version: '0'
675
679
  requirements: []
676
- rubygems_version: 3.6.9
680
+ rubygems_version: 4.0.3
677
681
  specification_version: 4
678
682
  summary: Instrumentation for GitLab
679
683
  test_files: []