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,406 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ NEW_METRIC_FIELDS = %i[
5
+ key_path
6
+ description
7
+ product_group
8
+ product_categories
9
+ performance_indicator_type
10
+ value_type
11
+ status
12
+ milestone
13
+ introduced_by_url
14
+ time_frame
15
+ data_source
16
+ data_category
17
+ tiers
18
+ events
19
+ instrumentation_class
20
+ ].freeze
21
+
22
+ ADDITIONAL_METRIC_FIELDS = %i[
23
+ milestone_removed
24
+ removed_by_url
25
+ removed_by
26
+ repair_issue_url
27
+ value_json_schema
28
+ name
29
+ ].freeze
30
+
31
+ METRIC_DEFAULTS = {
32
+ product_group: nil,
33
+ introduced_by_url: 'TODO',
34
+ value_type: 'number',
35
+ status: 'active',
36
+ data_source: 'internal_events',
37
+ data_category: 'optional',
38
+ performance_indicator_type: []
39
+ }.freeze
40
+
41
+ # These keys will always be included in the definition yaml
42
+ ExistingMetric = Struct.new(*NEW_METRIC_FIELDS, *ADDITIONAL_METRIC_FIELDS, :file_path, keyword_init: true) do
43
+ def identifier
44
+ events&.dig(0, 'unique')&.chomp('.id')
45
+ end
46
+
47
+ def actions
48
+ events&.map { |event| event['name'] }
49
+ end
50
+
51
+ def filters
52
+ events&.map do |event|
53
+ [event['name'], event['filter'] || {}]
54
+ end
55
+ end
56
+
57
+ def filtered?
58
+ !!filters&.any? { |(_action, filter)| filter&.any? }
59
+ end
60
+
61
+ def time_frame
62
+ self[:time_frame] || 'all'
63
+ end
64
+
65
+ # Enables comparison with new metrics
66
+ def unique_ids
67
+ prefix = [
68
+ operator,
69
+ (actions || []).sort.join('+'),
70
+ 'filter-',
71
+ filtered?
72
+ ].join('_')
73
+
74
+ Array(time_frame).map { |t| prefix + t }
75
+ end
76
+
77
+ def operator
78
+ events&.dig(0, 'operator') || "count(#{identifier})"
79
+ end
80
+ end
81
+
82
+ NewMetric = Struct.new(*NEW_METRIC_FIELDS, :identifier, :actions, :key, :filters, :operator, keyword_init: true) do
83
+ def formatted_output
84
+ extra_keys = event_metric? ? { events: events } : {}
85
+
86
+ # These keys will always be included in the definition yaml
87
+ METRIC_DEFAULTS
88
+ .merge(to_h.compact)
89
+ .merge(
90
+ time_frame: assign_time_frame,
91
+ key_path: key_path
92
+ ).merge(extra_keys)
93
+ .slice(*NEW_METRIC_FIELDS)
94
+ .transform_keys(&:to_s)
95
+ .to_yaml(line_width: 150)
96
+ end
97
+
98
+ def file_path
99
+ File.join(
100
+ *[
101
+ distribution_path,
102
+ 'config',
103
+ 'metrics',
104
+ time_frame.directory_name,
105
+ file_name
106
+ ].compact
107
+ )
108
+ end
109
+
110
+ def distribution_path
111
+ 'ee' unless tiers.include?('free')
112
+ end
113
+
114
+ def file_name
115
+ name = event_metric? ? key.value : key_path
116
+ "#{name}.yml"
117
+ end
118
+
119
+ def key_path
120
+ event_metric? ? key.full_path : self[:key]
121
+ end
122
+
123
+ def time_frame
124
+ Metric::TimeFrames.new(self[:time_frame])
125
+ end
126
+
127
+ def identifier
128
+ Metric::Identifier.new(self[:identifier])
129
+ end
130
+
131
+ def key
132
+ Metric::Key.new(self[:key] || actions, time_frame, identifier, operator)
133
+ end
134
+
135
+ def filters
136
+ Metric::Filters.new(self[:filters])
137
+ end
138
+
139
+ def operator
140
+ Metric::Operator.new(self[:operator])
141
+ end
142
+
143
+ # Enables comparison with existing metrics
144
+ def unique_ids
145
+ prefix = [
146
+ operator.reference(identifier),
147
+ actions.sort.join('+'),
148
+ 'filter-',
149
+ filtered?
150
+ ].join('_')
151
+
152
+ time_frame.value.map { |t| prefix + t }
153
+ end
154
+
155
+ # Returns value for the `events` key in the metric definition.
156
+ # Requires #actions or #filters to be set by the caller first.
157
+ #
158
+ # @return [Hash]
159
+ def events
160
+ if filters.assigned?
161
+ self[:filters].map { |(action, filter)| event_params(action, filter) }
162
+ else
163
+ actions.map { |action| event_params(action) }
164
+ end
165
+ end
166
+
167
+ def event_params(action, filter = nil)
168
+ params = { 'name' => action }
169
+ params['unique'] = identifier.reference if operator.value == 'unique_count'
170
+ params['filter'] = filter if filter&.any?
171
+ params['operator'] = operator.reference(identifier) if operator.value == 'sum'
172
+
173
+ params
174
+ end
175
+
176
+ def actions
177
+ self[:actions] || []
178
+ end
179
+
180
+ # How to interpret different values for filters:
181
+ # nil --> not expected, assigned or filtered
182
+ # (metric not initialized with filters)
183
+ # [] --> both expected and filtered
184
+ # (metric initialized with filters, but not yet assigned by user)
185
+ # [['event', {}]] --> not expected, assigned or filtered
186
+ # (filters were expected, but then skipped by user)
187
+ # [['event', { 'label' => 'a' }]] --> both assigned and filtered
188
+ # (filters exist for any event; user is done assigning)
189
+ def filtered?
190
+ filters.assigned? || filters.expected?
191
+ end
192
+
193
+ def filters_expected?
194
+ filters.expected?
195
+ end
196
+
197
+ # Automatically prepended to all new descriptions
198
+ # ex) Total count of
199
+ # ex) Weekly/Monthly count of unique
200
+ # ex) Count of
201
+ def description_prefix
202
+ return unless event_metric?
203
+
204
+ [
205
+ (time_frame.description if time_frame.single?),
206
+ operator.description,
207
+ *(identifier.plural if identifier.default?)
208
+ ].compact.join(' ').capitalize
209
+ end
210
+
211
+ # Provides simplified but technically accurate description
212
+ # to be used before the user has provided a description
213
+ def technical_description
214
+ return unless event_metric?
215
+
216
+ event_name = actions.first if events.length == 1 && !filtered?
217
+ event_name ||= 'the selected events'
218
+ [
219
+ (time_frame.description if time_frame.single?),
220
+ operator.description,
221
+ (identifier.description % event_name).to_s
222
+ ].compact.join(' ').capitalize
223
+ end
224
+
225
+ def bulk_assign(key_value_pairs)
226
+ key_value_pairs.each { |key, value| self[key] = value }
227
+ end
228
+
229
+ # Maintain current functionality of string time_frame for backward compatibility
230
+ # TODO: Remove once we can deduplicate and merge metric files
231
+ def assign_time_frame
232
+ time_frame.single? ? time_frame.value.first : time_frame.value
233
+ end
234
+
235
+ def event_metric?
236
+ data_source == 'internal_events'
237
+ end
238
+ end
239
+
240
+ class Metric
241
+ TimeFrames = Struct.new(:value) do
242
+ def description
243
+ (%w[all 28d 7d] & value).map do |time_trame|
244
+ TimeFramedKeyPath::METRIC_TIME_FRAME_DESC[time_trame].capitalize
245
+ end.join('/')
246
+ end
247
+
248
+ def directory_name
249
+ return 'counts_all' unless single?
250
+
251
+ "counts_#{value.first}"
252
+ end
253
+
254
+ def key_path
255
+ description&.downcase if single? && %w[7d 28d].include?(value.first)
256
+ end
257
+
258
+ # TODO: Delete once we are able to deduplicate and merge metric files
259
+ def single?
260
+ !value.is_a?(Array) || value.length == 1
261
+ end
262
+ end
263
+
264
+ Identifier = Struct.new(:value) do
265
+ # returns a description of the identifier with appropriate
266
+ # grammar to interpolate a description of events
267
+ def description
268
+ if value.nil?
269
+ '%s occurrences'
270
+ elsif value == 'user'
271
+ 'users who triggered %s'
272
+ elsif %w[project namespace].include?(value)
273
+ "#{plural} where %s occurred"
274
+ else
275
+ "#{plural} from %s occurrences"
276
+ end
277
+ end
278
+
279
+ # handles generic pluralization for unknown indentifers
280
+ def plural
281
+ default? ? "#{value}s" : "values for '#{value}'"
282
+ end
283
+
284
+ # returns a slug which can be used in the
285
+ # metric's key_path and filepath
286
+ def key_path(operator)
287
+ case operator.value
288
+ when 'unique_count'
289
+ "distinct_#{reference.tr('.', '_')}_from"
290
+ when 'count'
291
+ 'total'
292
+ when 'sum'
293
+ "#{reference.tr('.', '_')}_from"
294
+ end
295
+ end
296
+
297
+ # Returns the identifier string that will be included in the yml
298
+ def reference
299
+ default? ? "#{value}.id" : value
300
+ end
301
+
302
+ # Refers to the top-level identifiers not included in
303
+ # additional_properties
304
+ def default?
305
+ %w[user project namespace].include?(value)
306
+ end
307
+ end
308
+
309
+ Key = Struct.new(:events, :time_frame, :identifier, :operator) do
310
+ # @param name_to_display [String] return the key with the
311
+ # provided name instead of a list of event names
312
+ def value(name_to_display = nil)
313
+ [
314
+ operator.verb,
315
+ identifier&.key_path(operator),
316
+ name_to_display || name_for_events,
317
+ time_frame&.key_path
318
+ ].compact.join('_')
319
+ end
320
+
321
+ def full_path
322
+ "#{operator.key_path}.#{value}"
323
+ end
324
+
325
+ private
326
+
327
+ # Refers to the middle portion of a metric's `key_path`
328
+ # pertaining to the relevent events; This does not include
329
+ # identifier/time_frame/etc
330
+ def name_for_events
331
+ # user may have defined a different name for events
332
+ return events unless events.respond_to?(:join)
333
+
334
+ events.join('_and_')
335
+ end
336
+ end
337
+
338
+ Filters = Struct.new(:filters) do
339
+ def expected?
340
+ filters == []
341
+ end
342
+
343
+ def assigned?
344
+ !!filters&.any? { |(_action, filter)| filter.any? }
345
+ end
346
+
347
+ def descriptions
348
+ Array(filters).filter_map do |(action, filter)|
349
+ next action if filter.none?
350
+
351
+ "#{action}(#{describe_filter(filter)})"
352
+ end.sort_by(&:length)
353
+ end
354
+
355
+ def describe_filter(filter)
356
+ filter.map { |k, v| "#{k}=#{v}" }.join(',')
357
+ end
358
+ end
359
+
360
+ Operator = Struct.new(:value) do
361
+ def description
362
+ if qualifier
363
+ "#{verb} of #{qualifier}"
364
+ else
365
+ "#{verb} of"
366
+ end
367
+ end
368
+
369
+ def verb
370
+ value == 'unique_count' ? 'count' : value
371
+ end
372
+
373
+ def reference(identifier)
374
+ "#{verb}(#{identifier.value})"
375
+ end
376
+
377
+ def key_path
378
+ case value
379
+ when 'unique_count'
380
+ 'redis_hll_counters'
381
+ when 'count'
382
+ 'counts'
383
+ when 'sum'
384
+ 'sums'
385
+ end
386
+ end
387
+
388
+ def qualifier
389
+ case value
390
+ when 'unique_count'
391
+ 'unique'
392
+ when 'sum'
393
+ 'all'
394
+ end
395
+ end
396
+ end
397
+
398
+ def self.parse(**args)
399
+ ExistingMetric.new(**args)
400
+ end
401
+
402
+ def self.new(**args)
403
+ NewMetric.new(**args)
404
+ end
405
+ end
406
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ class SchemaResolver
5
+ def initialize(base_uri)
6
+ @base_uri = base_uri
7
+ end
8
+
9
+ def call(uri)
10
+ full_url = uri.to_s.sub('json-schemer://', 'https://')
11
+
12
+ unless full_url.start_with?('http')
13
+ ref_path = uri.respond_to?(:path) ? uri.path.sub(%r{^/}, '') : uri.to_s
14
+ full_url = URI.join(@base_uri, ref_path).to_s
15
+ end
16
+
17
+ schema_content = HttpCache.get(full_url)
18
+ return nil unless schema_content
19
+
20
+ JSON.parse(schema_content)
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ module Subflows
5
+ class DatabaseMetricDefiner
6
+ include Helpers
7
+ include Text::MetricDefiner
8
+
9
+ CLASS_NAME_REGEX = /\A[a-zA-Z]+\z/
10
+
11
+ attr_reader :metric
12
+
13
+ def initialize(cli)
14
+ @cli = cli
15
+ @metric = nil
16
+ end
17
+
18
+ def run
19
+ @metric = Metric.new
20
+ metric.data_source = 'database'
21
+
22
+ prompt_for_instrumentation_class
23
+ prompt_for_time_frame
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :cli
29
+
30
+ def prompt_for_instrumentation_class
31
+ cli.say INSTRUMENTATION_CLASS_INTRO
32
+ cli.say <<~TEXT
33
+
34
+ #{input_opts[:prefix]} What should be the instrumentation class for this metric? #{input_required_text}
35
+
36
+ TEXT
37
+
38
+ metric.instrumentation_class = prompt_for_text(' Instrumentation class: ') do |q|
39
+ q.required true
40
+ q.messages[:required?] = INSTRUMENTATION_CLASS_HELP
41
+ q.messages[:valid?] = INSTRUMENTATION_CLASS_ERROR
42
+ q.validate ->(input) { input.match?(CLASS_NAME_REGEX) }
43
+ end
44
+ end
45
+
46
+ def prompt_for_time_frame
47
+ metric.time_frame = cli.multi_select(
48
+ 'For which time frames do you want the metric to be calculated? (Space to select)',
49
+ time_frame_options,
50
+ **multiselect_opts,
51
+ **filter_opts(header_size: 7)
52
+ )
53
+ end
54
+
55
+ def time_frame_options
56
+ [
57
+ {
58
+ name: 'Weekly',
59
+ value: '7d'
60
+ }, {
61
+ name: 'Monthly',
62
+ value: '28d'
63
+ }, {
64
+ name: 'Total (no time restriction)',
65
+ value: 'all'
66
+ }
67
+ ]
68
+ end
69
+ end
70
+ end
71
+ end