gitlab_quality-test_tooling 2.16.0 → 2.20.2

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/.tool-versions +1 -1
  4. data/Gemfile.lock +30 -28
  5. data/exe/epic-readiness-notification +58 -0
  6. data/exe/relate-failure-issue +5 -0
  7. data/lib/gitlab_quality/test_tooling/click_house/client.rb +85 -0
  8. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +1 -1
  9. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +11 -0
  10. data/lib/gitlab_quality/test_tooling/feature_readiness/epic_readiness_notifier.rb +308 -0
  11. data/lib/gitlab_quality/test_tooling/gcs_tools.rb +49 -0
  12. data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +2 -9
  13. data/lib/gitlab_quality/test_tooling/gitlab_client/group_labels_client.rb +34 -0
  14. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +1 -1
  15. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +2 -2
  16. data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +1 -1
  17. data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +2 -2
  18. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +2 -2
  19. data/lib/gitlab_quality/test_tooling/report/group_issues/error_message_normalizer.rb +49 -0
  20. data/lib/gitlab_quality/test_tooling/report/group_issues/error_pattern_matcher.rb +36 -0
  21. data/lib/gitlab_quality/test_tooling/report/group_issues/failure_processor.rb +73 -0
  22. data/lib/gitlab_quality/test_tooling/report/group_issues/group_results_in_issues.rb +48 -0
  23. data/lib/gitlab_quality/test_tooling/report/group_issues/incident_checker.rb +61 -0
  24. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_base.rb +48 -0
  25. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_creator.rb +44 -0
  26. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_finder.rb +79 -0
  27. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_formatter.rb +83 -0
  28. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_manager.rb +33 -0
  29. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_updater.rb +87 -0
  30. data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +3 -3
  31. data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
  32. data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
  33. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +131 -4
  34. data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +2 -1
  35. data/lib/gitlab_quality/test_tooling/runtime/env.rb +9 -4
  36. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +1 -1
  37. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +1 -1
  38. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +1 -1
  39. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +1 -1
  40. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +39 -11
  41. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +88 -15
  42. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +71 -34
  43. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +105 -80
  44. data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +4 -0
  45. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  46. data/lib/gitlab_quality/test_tooling.rb +2 -0
  47. metadata +69 -55
  48. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/log_test_metrics.rb +0 -117
  49. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +0 -49
  50. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/influxdb_tools.rb +0 -33
@@ -8,6 +8,34 @@ module GitlabQuality
8
8
  class Config
9
9
  include Singleton
10
10
 
11
+ # GCS client configuration object used to push metrics as json file to gcs bucket
12
+ #
13
+ class GCS
14
+ def initialize(project_id:, credentials:, bucket_name:, metrics_file_name:, dry_run: false)
15
+ @project_id = project_id
16
+ @credentials = credentials
17
+ @bucket_name = bucket_name
18
+ @metrics_file_name = metrics_file_name
19
+ @dry_run = dry_run
20
+ end
21
+
22
+ attr_reader :project_id, :credentials, :bucket_name, :metrics_file_name, :dry_run
23
+ end
24
+
25
+ # ClickHouse client configuration object
26
+ #
27
+ class ClickHouse
28
+ def initialize(url:, database:, table_name:, username:, password:)
29
+ @url = url
30
+ @database = database
31
+ @table_name = table_name
32
+ @username = username
33
+ @password = password
34
+ end
35
+
36
+ attr_reader :url, :database, :table_name, :username, :password
37
+ end
38
+
11
39
  class << self
12
40
  def configuration
13
41
  Config.instance
@@ -18,25 +46,70 @@ module GitlabQuality
18
46
  end
19
47
  end
20
48
 
21
- attr_accessor :influxdb_url,
22
- :influxdb_token,
23
- :influxdb_bucket,
24
- :gcs_bucket,
25
- :gcs_project_id,
26
- :gcs_credentials,
27
- :gcs_metrics_file_name,
28
- :test_metric_file_name,
29
- :run_type
49
+ attr_reader :gcs_config, :clickhouse_config
50
+ attr_accessor :run_type
51
+ attr_writer :extra_rspec_metadata_keys,
52
+ :skip_record_proc,
53
+ :test_retried_proc,
54
+ :custom_metrics_proc,
55
+ :logger
30
56
 
31
- attr_writer :custom_keys_tags,
32
- :custom_keys_fields
57
+ # rubocop:disable Style/TrivialAccessors -- allows documenting that setting config enables the export as well as document input class type
58
+
59
+ # Enable metrics export to gcs bucket by setting configuration object
60
+ #
61
+ # @param config [Config::GCS]
62
+ # @return [GCS]
63
+ def gcs_config=(config)
64
+ @gcs_config = config
65
+ end
66
+
67
+ # Enable metrics export to clickhouse by setting configuration object
68
+ #
69
+ # @param config [Config::ClickHouse]
70
+ # @return [ClickHouse]
71
+ def clickhouse_config=(config)
72
+ @clickhouse_config = config
73
+ end
74
+
75
+ # rubocop:enable Style/TrivialAccessors
76
+
77
+ # Extra rspec metadata keys to include in exported metrics
78
+ #
79
+ # @return [Array<Symbol>]
80
+ def extra_rspec_metadata_keys
81
+ @extra_rspec_metadata_keys ||= []
82
+ end
83
+
84
+ # A lambda that determines whether to skip recording a test result
85
+ #
86
+ # This is useful when you would want to skip initial failure when retrying specs is set up in a separate process
87
+ # and you want to avoid duplicate records
88
+ #
89
+ # @return [Proc]
90
+ def skip_record_proc
91
+ @skip_record_proc ||= ->(_example) { false }
92
+ end
93
+
94
+ # A lambda that determines whether a test was retried or not
95
+ #
96
+ # @return [Proc]
97
+ def test_retried_proc
98
+ @test_retried_proc ||= ->(_example) { false }
99
+ end
33
100
 
34
- def custom_keys_tags
35
- @custom_keys_tags || []
101
+ # A lambda that return hash with additional custom metrics
102
+ #
103
+ # @return [Proc]
104
+ def custom_metrics_proc
105
+ @custom_metrics_proc ||= ->(_example) { {} }
36
106
  end
37
107
 
38
- def custom_keys_fields
39
- @custom_keys_fields || []
108
+ # Logger instance
109
+ #
110
+ # @return [Logger]
111
+ def logger
112
+ @logger ||= Logger.new($stdout, level: Logger::INFO)
40
113
  end
41
114
  end
42
115
  end
@@ -1,56 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rspec/core/formatters/base_formatter"
4
+
5
+ # rubocop:disable Metrics/AbcSize
3
6
  module GitlabQuality
4
7
  module TestTooling
5
8
  module TestMetricsExporter
6
9
  class Formatter < RSpec::Core::Formatters::BaseFormatter
7
10
  RSpec::Core::Formatters.register(self, :stop)
8
11
 
12
+ LOG_PREFIX = "[MetricsExporter]"
13
+
9
14
  def stop(notification)
10
- setup_test_metrics_exporter(notification.examples)
11
-
12
- log_test_metrics.push_test_metrics(
13
- custom_keys_tags: config.custom_keys_tags,
14
- custom_keys_fields: config.custom_keys_fields
15
- )
16
-
17
- log_test_metrics.save_test_metrics(
18
- file_name: config.test_metric_file_name,
19
- custom_keys_tags: config.custom_keys_tags,
20
- custom_keys_fields: config.custom_keys_fields
21
- )
15
+ data = notification.examples.filter_map do |example|
16
+ next if config.skip_record_proc.call(example)
17
+
18
+ TestMetrics.new(example, time).data
19
+ end
20
+ return logger.warn("#{LOG_PREFIX} No test execution records found, metrics will not be exported!") if data.empty?
21
+
22
+ push_to_gcs(data)
23
+ push_to_clickhouse(data)
22
24
  end
23
25
 
24
26
  private
25
27
 
26
- attr_reader :log_test_metrics
27
-
28
+ # Configuration instance
29
+ #
30
+ # @return [Config]
28
31
  def config
29
32
  Config.configuration
30
33
  end
31
34
 
32
- # rubocop:disable Metrics/AbcSize
33
- def setup_test_metrics_exporter(examples)
34
- @log_test_metrics = LogTestMetrics.new(
35
- examples: examples,
36
- run_type: config.run_type
37
- )
38
-
39
- @log_test_metrics.configure_influxdb_client(
40
- influxdb_url: config.influxdb_url,
41
- influxdb_token: config.influxdb_token,
42
- influxdb_bucket: config.influxdb_bucket
43
- )
44
-
45
- @log_test_metrics.configure_gcs_client(
46
- gcs_bucket: config.gcs_bucket,
47
- gcs_project_id: config.gcs_project_id,
48
- gcs_credentials: config.gcs_credentials,
49
- gcs_metrics_file_name: config.gcs_metrics_file_name
50
- )
35
+ # Logger instance
36
+ #
37
+ # @return [Logger]
38
+ def logger
39
+ config.logger
40
+ end
41
+
42
+ # Single common timestamp for all exported example metrics to keep data points consistently grouped
43
+ #
44
+ # @return [Time]
45
+ def time
46
+ return @time if @time
47
+
48
+ ci_created_at = Runtime::Env.ci_pipeline_created_at
49
+ @time = ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc
50
+ end
51
+
52
+ # Push data to gcs
53
+ #
54
+ # @param data [Array]
55
+ # @return [void]
56
+ def push_to_gcs(data)
57
+ return logger.debug("#{LOG_PREFIX} GCS configuration missing, skipping gcs export!") unless config.gcs_config
58
+
59
+ gcs_config = config.gcs_config
60
+ GcsTools.gcs_client(
61
+ project_id: gcs_config.project_id,
62
+ credentials: gcs_config.credentials,
63
+ dry_run: gcs_config.dry_run
64
+ ).put_object(gcs_config.bucket_name, gcs_config.metrics_file_name, data.to_json)
65
+ logger.info("#{LOG_PREFIX} Successfully pushed #{data.size} entries to GCS bucket!")
66
+ rescue StandardError => e
67
+ logger.error("#{LOG_PREFIX} Error occurred while pushing metrics to GCS: #{e.message}")
68
+ end
69
+
70
+ # Push data to clickhouse
71
+ #
72
+ # @param data [Array<Hash>]
73
+ # @return [void]
74
+ def push_to_clickhouse(data)
75
+ return logger.debug("ClickHouse configuration missing, skipping ClickHouse export!") unless config.clickhouse_config
76
+
77
+ clickhouse_config = config.clickhouse_config
78
+ ClickHouse::Client.new(
79
+ url: clickhouse_config.url,
80
+ database: clickhouse_config.database,
81
+ username: clickhouse_config.username,
82
+ password: clickhouse_config.password,
83
+ logger: logger
84
+ ).insert_json_data(clickhouse_config.table_name, data)
85
+ logger.info("#{LOG_PREFIX} Successfully pushed #{data.size} entries to ClickHouse!")
86
+ rescue StandardError => e
87
+ logger.error("#{LOG_PREFIX} Error occurred while pushing metrics to ClickHouse: #{e.message}")
51
88
  end
52
- # rubocop:enable Metrics/AbcSize
53
89
  end
54
90
  end
55
91
  end
56
92
  end
93
+ # rubocop:enable Metrics/AbcSize
@@ -5,131 +5,138 @@ require 'time'
5
5
  module GitlabQuality
6
6
  module TestTooling
7
7
  module TestMetricsExporter
8
- module TestMetrics
9
- # Single common timestamp for all exported example metrics to keep data points consistently grouped
10
- #
11
- # @return [Time]
12
- def time
13
- return @time if defined?(@time)
14
-
15
- created_at = Time.strptime(env('CI_PIPELINE_CREATED_AT'), '%Y-%m-%dT%H:%M:%S%z') if env('CI_PIPELINE_CREATED_AT')
16
- @time = Time.parse((created_at || Time.now).utc.strftime('%Y-%m-%d %H:%M:%S %z'))
8
+ class TestMetrics
9
+ def initialize(example, timestamp)
10
+ @example = example
11
+ @timestamp = timestamp
17
12
  end
18
13
 
19
- # rubocop:disable Metrics/AbcSize
20
- # Metrics tags
14
+ # Test data hash
21
15
  #
22
- # @param [RSpec::Core::Example] example
23
- # @param [Array<String>] custom_keys
24
- # @param [String]
25
16
  # @return [Hash]
26
- def tags(example, custom_keys, run_type)
17
+ def data
27
18
  {
28
- name: example.full_description,
29
- file_path: example.metadata[:file_path].sub(/\A./, ''),
30
- status: status(example),
31
- quarantined: quarantined(example),
32
- job_name: job_name,
33
- merge_request: merge_request,
34
- run_type: run_type,
35
- feature_category: example.metadata[:feature_category],
36
- product_group: example.metadata[:product_group],
37
- exception_class: example.execution_result.exception&.class&.to_s,
38
- **custom_metrics(example.metadata, custom_keys)
19
+ time: timestamp,
20
+ **rspec_metrics,
21
+ **ci_metrics,
22
+ **custom_metrics
39
23
  }.compact
40
24
  end
41
- # rubocop:enable Metrics/AbcSize
42
25
 
43
- # Metrics fields
26
+ private
27
+
28
+ attr_reader :example, :timestamp
29
+
30
+ # Exporter configuration
31
+ #
32
+ # @return [Config]
33
+ def config
34
+ Config.configuration
35
+ end
36
+
37
+ # Rspec related metrics
44
38
  #
45
- # @param [RSpec::Core::Example] example
46
- # @param [Array<String>] custom_keys
47
39
  # @return [Hash]
48
- def fields(example, custom_keys)
40
+ def rspec_metrics # rubocop:disable Metrics/AbcSize
49
41
  {
50
42
  id: example.id,
43
+ name: example.full_description,
44
+ file_path: example.metadata[:file_path].sub(/\A./, ''),
45
+ status: example.execution_result.status,
51
46
  run_time: (example.execution_result.run_time * 1000).round,
52
- job_url: Runtime::Env.ci_job_url,
53
- pipeline_url: env('CI_PIPELINE_URL'),
54
- pipeline_id: env('CI_PIPELINE_ID'),
55
- job_id: env('CI_JOB_ID'),
56
- merge_request_iid: merge_request_iid,
57
- failure_exception: example.execution_result.exception.to_s.delete("\n"),
58
- **custom_metrics(example.metadata, custom_keys)
59
- }.compact
47
+ location: example_location,
48
+ exception_class: exception_class,
49
+ failure_exception: failure_exception,
50
+ quarantined: quarantined?,
51
+ test_retried: config.test_retried_proc.call(example)
52
+ }
60
53
  end
61
54
 
62
- # Return a more detailed status
63
- #
64
- # - if test is failed or pending, return rspec status
65
- # - if test passed but had more than 1 attempt, consider test flaky
55
+ # CI related metrics
66
56
  #
67
- # @param [RSpec::Core::Example] example
68
- # @return [Symbol]
69
- def status(example)
70
- rspec_status = example.execution_result.status
71
- return rspec_status if [:pending, :failed].include?(rspec_status)
72
-
73
- retry_attempts(example.metadata).positive? ? :flaky : :passed
57
+ # @return [Hash]
58
+ def ci_metrics
59
+ {
60
+ ci_project_id: env("CI_PROJECT_ID")&.to_i,
61
+ ci_project_path: env("CI_PROJECT_PATH"),
62
+ ci_job_name: ci_job_name,
63
+ ci_job_id: env('CI_JOB_ID')&.to_i,
64
+ ci_pipeline_id: env('CI_PIPELINE_ID')&.to_i,
65
+ ci_merge_request_iid: (env('CI_MERGE_REQUEST_IID') || env('TOP_UPSTREAM_MERGE_REQUEST_IID'))&.to_i,
66
+ ci_branch: env("CI_COMMIT_REF_NAME"),
67
+ ci_target_branch: env("CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
68
+ }
74
69
  end
75
70
 
76
- # Retry attempts
71
+ # Additional custom metrics
77
72
  #
78
- # @param [Hash] example
79
- # @return [Integer]
80
- def retry_attempts(metadata)
81
- metadata[:retry_attempts] || 0
73
+ # @return [Hash]
74
+ def custom_metrics
75
+ metrics = example.metadata
76
+ .slice(*config.extra_rspec_metadata_keys)
77
+ .merge(config.custom_metrics_proc.call(example))
78
+
79
+ metrics.each_with_object({}) do |(k, value), custom_metrics|
80
+ custom_metrics[k.to_sym] = metrics_value(value)
81
+ end
82
82
  end
83
83
 
84
84
  # Checks if spec is quarantined
85
85
  #
86
- # @param [RSpec::Core::Example] example
87
86
  # @return [String]
88
- def quarantined(example)
89
- return "false" unless example.metadata.key?(:quarantine)
87
+ def quarantined?
88
+ return false unless example.metadata.key?(:quarantine)
90
89
 
91
90
  # if quarantine key is present and status is pending, consider it quarantined
92
- (example.execution_result.status == :pending).to_s
91
+ example.execution_result.status == :pending
93
92
  end
94
93
 
95
94
  # Base ci job name
96
95
  #
97
96
  # @return [String]
98
- def job_name
99
- @job_name ||= Runtime::Env.ci_job_name&.gsub(%r{ \d{1,2}/\d{1,2}}, '')
97
+ def ci_job_name
98
+ env("CI_JOB_NAME")&.gsub(%r{ \d{1,2}/\d{1,2}}, '')
100
99
  end
101
100
 
102
- # Check if it is a merge request execution
101
+ # Example location
103
102
  #
104
103
  # @return [String]
105
- def merge_request
106
- (!!merge_request_iid).to_s
104
+ def example_location
105
+ # ensures that location will be correct even in case of shared examples
106
+ file = example
107
+ .metadata
108
+ .fetch(:shared_group_inclusion_backtrace)
109
+ .last
110
+ &.formatted_inclusion_location
111
+
112
+ return example.location unless file
113
+
114
+ file
107
115
  end
108
116
 
109
- # Merge request iid
117
+ # Failure exception class
110
118
  #
111
119
  # @return [String]
112
- def merge_request_iid
113
- env('CI_MERGE_REQUEST_IID') || env('TOP_UPSTREAM_MERGE_REQUEST_IID')
120
+ def exception_class
121
+ example.execution_result.exception&.class&.to_s
114
122
  end
115
123
 
116
- # Custom test metrics
124
+ # Truncated exception stacktrace
117
125
  #
118
- # @param [Hash] metadata
119
- # @param [Array] array of custom metrics keys
120
- # @return [Hash]
121
- def custom_metrics(metadata, custom_keys)
122
- return {} if custom_keys.nil?
123
-
124
- custom_metrics = {}
125
- custom_keys.each do |k|
126
- value = metadata[k.to_sym]
127
- v = value.is_a?(Numeric) || value.nil? ? value : value.to_s
126
+ # @return [String]
127
+ def failure_exception
128
+ example.execution_result.exception.then do |exception|
129
+ next unless exception
128
130
 
129
- custom_metrics[k.to_sym] = v
131
+ exception.to_s.tr("\n", " ").slice(0, 1000)
130
132
  end
133
+ end
131
134
 
132
- custom_metrics
135
+ # Test run type | suite name
136
+ #
137
+ # @return [String]
138
+ def run_type
139
+ config.run_type || ci_job_name || "unknown"
133
140
  end
134
141
 
135
142
  # Return non empty environment variable value
@@ -141,6 +148,24 @@ module GitlabQuality
141
148
 
142
149
  ENV.fetch(name)
143
150
  end
151
+
152
+ # Metrics value cast to a valid type
153
+ #
154
+ # @param value [Object]
155
+ # @return [Object]
156
+ def metrics_value(value)
157
+ return value if value.is_a?(Numeric) || value.is_a?(String) || bool?(value) || value.nil?
158
+
159
+ value.to_s
160
+ end
161
+
162
+ # Value is a true or false
163
+ #
164
+ # @param val [Object]
165
+ # @return [Boolean]
166
+ def bool?(val)
167
+ [true, false].include?(val)
168
+ end
144
169
  end
145
170
  end
146
171
  end
@@ -73,6 +73,10 @@ module GitlabQuality
73
73
  product_group != ''
74
74
  end
75
75
 
76
+ def feature_category?
77
+ feature_category && !feature_category.empty?
78
+ end
79
+
76
80
  def failure_issue
77
81
  report['failure_issue']
78
82
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "2.16.0"
5
+ VERSION = "2.20.2"
6
6
  end
7
7
  end
@@ -7,6 +7,8 @@ module GitlabQuality
7
7
  module TestTooling
8
8
  Error = Class.new(StandardError)
9
9
  loader = Zeitwerk::Loader.new
10
+ loader.push_dir(__dir__.to_s, namespace: GitlabQuality)
11
+ loader.ignore("#{__dir__}/test_tooling.rb")
10
12
  loader.push_dir("#{__dir__}/test_tooling", namespace: GitlabQuality::TestTooling)
11
13
  loader.ignore("#{__dir__}/test_tooling/version.rb")
12
14