gitlab_internal_events_cli 0.0.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.tool-versions +1 -0
  4. data/Gemfile +11 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE.txt +19 -0
  7. data/README.md +164 -0
  8. data/Rakefile +10 -0
  9. data/exe/gitlab-internal-events-cli +7 -0
  10. data/gitlab_internal_events_cli.gemspec +39 -0
  11. data/lib/gitlab_internal_events_cli/cli.rb +59 -0
  12. data/lib/gitlab_internal_events_cli/configuration.rb +115 -0
  13. data/lib/gitlab_internal_events_cli/event.rb +73 -0
  14. data/lib/gitlab_internal_events_cli/flows/event_definer.rb +306 -0
  15. data/lib/gitlab_internal_events_cli/flows/flow_advisor.rb +90 -0
  16. data/lib/gitlab_internal_events_cli/flows/metric_definer.rb +468 -0
  17. data/lib/gitlab_internal_events_cli/flows/usage_viewer.rb +474 -0
  18. data/lib/gitlab_internal_events_cli/gitlab_prompt.rb +9 -0
  19. data/lib/gitlab_internal_events_cli/global_state.rb +63 -0
  20. data/lib/gitlab_internal_events_cli/helpers/cli_inputs.rb +138 -0
  21. data/lib/gitlab_internal_events_cli/helpers/event_options.rb +63 -0
  22. data/lib/gitlab_internal_events_cli/helpers/files.rb +84 -0
  23. data/lib/gitlab_internal_events_cli/helpers/formatting.rb +166 -0
  24. data/lib/gitlab_internal_events_cli/helpers/group_ownership.rb +160 -0
  25. data/lib/gitlab_internal_events_cli/helpers/metric_options.rb +253 -0
  26. data/lib/gitlab_internal_events_cli/helpers/schema_loader.rb +25 -0
  27. data/lib/gitlab_internal_events_cli/helpers/service_ping_dashboards.rb +22 -0
  28. data/lib/gitlab_internal_events_cli/helpers.rb +47 -0
  29. data/lib/gitlab_internal_events_cli/http_cache.rb +52 -0
  30. data/lib/gitlab_internal_events_cli/metric.rb +406 -0
  31. data/lib/gitlab_internal_events_cli/schema_resolver.rb +25 -0
  32. data/lib/gitlab_internal_events_cli/subflows/database_metric_definer.rb +71 -0
  33. data/lib/gitlab_internal_events_cli/subflows/event_metric_definer.rb +258 -0
  34. data/lib/gitlab_internal_events_cli/text/event_definer.rb +166 -0
  35. data/lib/gitlab_internal_events_cli/text/flow_advisor.rb +64 -0
  36. data/lib/gitlab_internal_events_cli/text/metric_definer.rb +138 -0
  37. data/lib/gitlab_internal_events_cli/time_framed_key_path.rb +18 -0
  38. data/lib/gitlab_internal_events_cli/version.rb +5 -0
  39. data/lib/gitlab_internal_events_cli.rb +36 -0
  40. metadata +170 -0
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers related to listing existing metric definitions
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module MetricOptions
7
+ # Creates a list of metrics to be used as options in a
8
+ # select/multiselect menu; existing metrics and metrics for
9
+ # unavailable identifiers are marked as disabled
10
+ #
11
+ # @param events [Array<ExistingEvent>]
12
+ # @return [Array<Hash>] hash (compact) has keys/values:
13
+ # value: [Array<NewMetric>]
14
+ # name: [String] formatted description of the metrics
15
+ # disabled: [String] reason metrics are disabled
16
+ def get_metric_options(events)
17
+ selection = EventSelection.new(events)
18
+ available_options = []
19
+ disabled_options = []
20
+
21
+ options = get_all_metric_options(selection.actions)
22
+
23
+ options.each do |metric|
24
+ # Filters & breaks up menu items based on existing metrics
25
+ # and supported functionality, appending formatted options
26
+ # to available_options and disabled_options arrays
27
+ collect_options!(metric, selection, available_options, disabled_options)
28
+ end
29
+
30
+ # Push disabled options to the end for better skimability;
31
+ # retain relative order for continuity
32
+ available_options + disabled_options
33
+ end
34
+
35
+ private
36
+
37
+ # Lists all potential metrics supported in service ping,
38
+ # ordered by: identifier > filters > time_frame
39
+ #
40
+ # @param actions [Array<String>] event names
41
+ # @return [Array<NewMetric>]
42
+ def get_all_metric_options(actions)
43
+ [
44
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'user' },
45
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'project' },
46
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'namespace' },
47
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'user', filters: [] },
48
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'project', filters: [] },
49
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'namespace', filters: [] },
50
+ { time_frame: %w[7d 28d all], operator: 'count' },
51
+ { time_frame: %w[7d 28d all], operator: 'count', filters: [] },
52
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'label' },
53
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'property' },
54
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'value' },
55
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'label', filters: [] },
56
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'property', filters: [] },
57
+ { time_frame: %w[7d 28d], operator: 'unique_count', identifier: 'value', filters: [] },
58
+ { time_frame: %w[7d 28d all], operator: 'sum', identifier: 'value' },
59
+ { time_frame: %w[7d 28d all], operator: 'sum', identifier: 'value', filters: [] }
60
+ ].map do |attributes|
61
+ Metric.new(**attributes, actions: actions)
62
+ end
63
+ end
64
+
65
+ def collect_options!(metric, selection, available_options, disabled_options)
66
+ identifier = metric.identifier.value
67
+
68
+ # Hide the filtered version of an option if unsupported; it just adds noise without value. Still,
69
+ # showing unsupported options is valuable, because it advertises possibilities and explains why
70
+ # those options aren't available.
71
+ return if metric.filtered? && !selection.supports_operations?(identifier)
72
+ return if metric.filtered? && !selection.can_filter_when_operated_on?(identifier)
73
+ return if selection.exclude_filter_identifier?(identifier)
74
+
75
+ option = Option.new(
76
+ metric: metric,
77
+ events_name: selection.events_name,
78
+ filter_name: (selection.filter_name(identifier) if metric.filtered?),
79
+ defined: false,
80
+ supported: true
81
+ )
82
+
83
+ conflicting_timeframes = get_conflicting_timeframes(metric)
84
+ available_timeframes = metric.time_frame.value - conflicting_timeframes
85
+
86
+ if conflicting_timeframes.any?
87
+ disabled_metric = metric.dup
88
+ disabled_metric[:time_frame] = conflicting_timeframes
89
+
90
+ disabled_option = option.dup
91
+ disabled_option.defined = true
92
+ disabled_option.metric = disabled_metric
93
+
94
+ disabled_options << disabled_option.formatted
95
+ end
96
+
97
+ return if available_timeframes.none?
98
+
99
+ metric[:time_frame] = available_timeframes.sort
100
+
101
+ if selection.supports_operations?(identifier)
102
+ available_options << option.formatted
103
+ else
104
+ option.supported = false
105
+ disabled_options << option.formatted
106
+ end
107
+ end
108
+
109
+ def get_conflicting_timeframes(metric)
110
+ return [] if metric.filters_expected?
111
+
112
+ cli.global.metrics.flat_map do |existing_metric|
113
+ next if existing_metric.filtered?
114
+ next unless (existing_metric.unique_ids & metric.unique_ids).any?
115
+
116
+ existing_metric.time_frame
117
+ end.compact.uniq
118
+ end
119
+
120
+ # Represents the attributes of set of events that depend on
121
+ # the other events in the set
122
+ EventSelection = Struct.new(:events) do
123
+ def actions
124
+ events.map(&:action)
125
+ end
126
+
127
+ # Very brief summary of the provided events to use in a
128
+ # basic description of the metric
129
+ # This ignores filters for simplicity & skimability
130
+ def events_name
131
+ return actions.first if actions.length == 1
132
+
133
+ "any of #{actions.length} events"
134
+ end
135
+
136
+ # Formatted list of filter options for these events, given
137
+ # the provided uniqueness constraint
138
+ def filter_name(identifier)
139
+ filter_options.difference([identifier]).join('/')
140
+ end
141
+
142
+ # We accept different filters for each event, so we want
143
+ # any filter options available for any event
144
+ def filter_options
145
+ events.flat_map(&:available_filters).uniq
146
+ end
147
+
148
+ # We require the same uniqueness constraint for all events,
149
+ # so we want only the options they have in common
150
+ def operable_identifiers
151
+ [*shared_identifiers, *shared_filters, nil]
152
+ end
153
+
154
+ # Whether there are any filtering options other than the
155
+ # selected uniqueness constraint
156
+ def can_filter_when_operated_on?(identifier)
157
+ supports_operations?(identifier) && filter_options.difference([identifier]).any?
158
+ end
159
+
160
+ # Whether the given identifier is available for all events
161
+ # and can be used as a uniqueness constraint
162
+ def supports_operations?(identifier)
163
+ operable_identifiers.include?(identifier)
164
+ end
165
+
166
+ # Common values for identifiers shared across all the events
167
+ def shared_identifiers
168
+ events.map(&:identifiers).reduce(&:&)
169
+ end
170
+
171
+ # Common values for filters shared across all the events
172
+ def shared_filters
173
+ events.map(&:available_filters).reduce(&:&)
174
+ end
175
+
176
+ # Whether none of the events have additional properties
177
+ # and the given identifier is an additional property.
178
+ # In this case, it makes sense to exclude these from the
179
+ # menu to keep the flow simple when the use-case is simple
180
+ def exclude_filter_identifier?(identifier)
181
+ return false if identifier.nil? || Metric::Identifier.new(identifier).default?
182
+
183
+ filter_options.empty?
184
+ end
185
+ end
186
+
187
+ # Formats & structures a single select/multiselect menu item
188
+ #
189
+ # @param identifier [String, nil] if present, used in unique-by-identifier metrics
190
+ # @param events_name [String] how the selected events will be referred to as a group
191
+ # @param filter_name [String] how the potential filters will be referred to as a group
192
+ # @param metrics [Array<NewMetric>]
193
+ # @option defined [Boolean] whether this metric already exists
194
+ # @option supported [Boolean] whether unique metrics are supported for this identifier
195
+ Option = Struct.new(:events_name, :filter_name, :metric, :defined, :supported,
196
+ keyword_init: true) do
197
+ include GitlabInternalEventsCli::Helpers::Formatting
198
+
199
+ # @return [Hash] see #get_metric_options for format
200
+ # ex) Monthly/Weekly count of unique users who triggered cli_template_included where label/property is...
201
+ # ex) Monthly/Weekly count of unique users who triggered cli_template_included (user unavailable)
202
+ def formatted
203
+ name = [time_frame_phrase, operator_phrase, filter_phrase].compact.join(' ')
204
+ name = format_help(name) if disabled
205
+
206
+ { name: name, disabled: disabled, value: metric }.compact
207
+ end
208
+
209
+ def identifier
210
+ metric.identifier
211
+ end
212
+
213
+ # ex) "Monthly/Weekly"
214
+ def time_frame_phrase
215
+ phrase = metric.time_frame.description
216
+
217
+ disabled ? phrase : format_info(phrase)
218
+ end
219
+
220
+ # ex) "count of unique users who triggered cli_template_included"
221
+ def operator_phrase
222
+ phrase = "#{metric.operator.description} #{identifier.description % events_name}"
223
+ phrase.gsub!(highlighted_phrase, format_info(highlighted_phrase)) unless disabled
224
+
225
+ phrase
226
+ end
227
+
228
+ # ex) "unique users"
229
+ def highlighted_phrase
230
+ "#{metric.operator.qualifier} #{identifier.plural}"
231
+ end
232
+
233
+ # ex) "where label/property is..."
234
+ def filter_phrase
235
+ return unless filter_name
236
+ return 'where filtered' if disabled
237
+
238
+ "#{format_info("where #{filter_name}")} is..."
239
+ end
240
+
241
+ # Returns the string to include at the end of disabled
242
+ # menu items. Nil if menu item shouldn't be disabled
243
+ def disabled
244
+ if defined
245
+ pastel.bold(format_help('(already defined)'))
246
+ elsif !supported
247
+ pastel.bold(format_help("(#{identifier.value} unavailable)"))
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ module Helpers
5
+ # Helper module for loading JSON schemas with caching and reference resolution
6
+ module SchemaLoader
7
+ def self.load(schema_url, schema_type)
8
+ schema_json = HttpCache.get(schema_url)
9
+ return nil unless schema_json
10
+
11
+ base_uri = "#{File.dirname(schema_url)}/"
12
+
13
+ resolver = SchemaResolver.new(base_uri)
14
+ JSONSchemer.schema(
15
+ JSON.parse(schema_json),
16
+ base_uri: URI(base_uri),
17
+ ref_resolver: resolver
18
+ )
19
+ rescue StandardError => e
20
+ warn "Failed to load #{schema_type} schema: #{e.message}"
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers for generating service ping exploration dashboards links
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module ServicePingDashboards
7
+ def metric_exploration_group_path(product_group, stage_name)
8
+ "#{tableau_base_path}/MetricExplorationbyGroup?Group%20Name=#{product_group}&Stage%20Name=#{stage_name}"
9
+ end
10
+
11
+ def metric_trend_path(key_path)
12
+ "#{tableau_base_path}/MetricTrend?Metrics%20Path=#{key_path}"
13
+ end
14
+
15
+ private
16
+
17
+ def tableau_base_path
18
+ @tableau_base_path ||= 'https://10az.online.tableau.com/#/site/gitlab/views/PDServicePingExplorationDashboard'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/cli_inputs'
4
+ require_relative 'helpers/files'
5
+ require_relative 'helpers/formatting'
6
+ require_relative 'helpers/group_ownership'
7
+ require_relative 'helpers/event_options'
8
+ require_relative 'helpers/metric_options'
9
+ require_relative 'helpers/schema_loader'
10
+ require_relative 'helpers/service_ping_dashboards'
11
+
12
+ module GitlabInternalEventsCli
13
+ module Helpers
14
+ include CliInputs
15
+ include Files
16
+ include Formatting
17
+ include GroupOwnership
18
+ include EventOptions
19
+ include MetricOptions
20
+ include ServicePingDashboards
21
+
22
+ NAME_REGEX = /\A[a-z0-9_]+\z/
23
+
24
+ def milestone
25
+ GitlabInternalEventsCli.configuration.milestone
26
+ end
27
+
28
+ def new_page!(on_step: nil, steps: [])
29
+ cli.say TTY::Cursor.clear_screen
30
+ cli.say TTY::Cursor.move_to(0, 0)
31
+ cli.say "#{progress_bar(on_step, steps)}\n" if on_step && steps&.any?
32
+ end
33
+
34
+ def feedback_notice
35
+ format_heading <<~TEXT.chomp
36
+ Thanks for using the Internal Events CLI!
37
+
38
+ Please reach out with any feedback!
39
+ About Internal Events: https://gitlab.com/gitlab-org/analytics-section/analytics-instrumentation/internal/-/issues/687
40
+ About CLI: https://gitlab.com/gitlab-org/gitlab/-/issues/434038
41
+ In Slack: #g_analyze_analytics_instrumentation
42
+
43
+ Let us know that you used the CLI! React with 👍 on the feedback issue or post in Slack!
44
+ TEXT
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ class HttpCache
5
+ class << self
6
+ def instance
7
+ @instance ||= new
8
+ end
9
+
10
+ def get(url, timeout: 5)
11
+ instance.get(url, timeout: timeout)
12
+ end
13
+
14
+ def clear!
15
+ @instance = nil
16
+ end
17
+
18
+ def preload!
19
+ instance.preload!
20
+ end
21
+ end
22
+
23
+ def initialize
24
+ @cache = {}
25
+ end
26
+
27
+ def get(url, timeout: 5)
28
+ return @cache[url] if @cache.key?(url)
29
+
30
+ @cache[url] = Timeout.timeout(timeout) { Net::HTTP.get(URI(url)) }
31
+ rescue StandardError
32
+ @cache[url] = nil
33
+ end
34
+
35
+ def preload!
36
+ config = GitlabInternalEventsCli.configuration
37
+
38
+ urls = [
39
+ config.event_schema_url,
40
+ config.metric_schema_url,
41
+ config.stages_url,
42
+ config.feature_categories_url
43
+ ]
44
+
45
+ threads = urls.map do |url|
46
+ Thread.new { get(url) }
47
+ end
48
+
49
+ threads.each(&:join)
50
+ end
51
+ end
52
+ end