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,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entrypoint for flow to create a metric definition file
4
+ module GitlabInternalEventsCli
5
+ module Flows
6
+ class MetricDefiner
7
+ include Helpers
8
+ include Text::MetricDefiner
9
+
10
+ def self.schema
11
+ @schema ||= Helpers::SchemaLoader.load(
12
+ GitlabInternalEventsCli.configuration.metric_schema_url,
13
+ 'metric'
14
+ )
15
+ end
16
+
17
+ STEPS = [
18
+ 'New Metric',
19
+ 'Type',
20
+ 'Config',
21
+ 'Scope',
22
+ 'Description',
23
+ 'Defaults',
24
+ 'Group',
25
+ 'Categories',
26
+ 'URL',
27
+ 'Tiers',
28
+ 'Save files'
29
+ ].freeze
30
+
31
+ attr_reader :cli
32
+
33
+ def initialize(cli, starting_event = nil)
34
+ @cli = cli
35
+ @selected_event_paths = Array(starting_event)
36
+ @metric = nil
37
+ @selected_filters = nil
38
+ end
39
+
40
+ def run
41
+ type = prompt_for_metric_type
42
+
43
+ prompt_for_configuration(type)
44
+
45
+ return unless metric
46
+
47
+ metric.milestone = milestone
48
+ prompt_for_description
49
+ prompt_for_metric_name
50
+ defaults = prompt_for_copying_event_properties
51
+ prompt_for_product_group(defaults)
52
+ prompt_for_product_categories(defaults)
53
+ prompt_for_url(defaults)
54
+ prompt_for_tier(defaults)
55
+ outcome = create_metric_file
56
+ prompt_for_next_steps(outcome)
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :metric
62
+
63
+ # ----- Memoization Helpers -----------------
64
+
65
+ def events
66
+ @events ||= events_by_filepath(@selected_event_paths)
67
+ end
68
+
69
+ def selected_events
70
+ @selected_events ||= events.values_at(*@selected_event_paths)
71
+ end
72
+
73
+ # ----- Prompts -----------------------------
74
+
75
+ def prompt_for_metric_type
76
+ return :event_metric if @selected_event_paths.any?
77
+
78
+ new_page!(on_step: 'Type', steps: STEPS)
79
+
80
+ cli.select('Which best describes what the metric should track?', **select_opts) do |menu|
81
+ menu.enum '.'
82
+
83
+ menu.choice 'Single event -- count occurrences of a specific event or user interaction',
84
+ :event_metric
85
+ menu.choice 'Multiple events -- count occurrences of several separate events or interactions',
86
+ :aggregate_metric
87
+ menu.choice 'Database -- record value of a particular field or count of database rows',
88
+ :database_metric
89
+ end
90
+ end
91
+
92
+ def prompt_for_configuration(type)
93
+ case type
94
+ when :database_metric
95
+ # CLI doesn't load rails, so perform a simplified string <-> boolean check
96
+ if [nil, 'false', '0'].include? ENV['ENABLE_DATABASE_METRIC']
97
+ cli.error DATABASE_METRIC_NOTICE
98
+ cli.say feedback_notice
99
+ return
100
+ end
101
+
102
+ db_metric_definer = Subflows::DatabaseMetricDefiner.new(cli)
103
+ db_metric_definer.run
104
+ @metric = db_metric_definer.metric
105
+ when :event_metric, :aggregate_metric
106
+ event_metric_definer = Subflows::EventMetricDefiner.new(cli, @selected_event_paths, type)
107
+ event_metric_definer.run
108
+ @metric = event_metric_definer.metric
109
+ @selected_filters = event_metric_definer.selected_filters
110
+ @selected_event_paths = event_metric_definer.selected_event_paths
111
+ end
112
+ end
113
+
114
+ def file_saved_context_message(attributes)
115
+ format_prefix ' ', <<~TEXT.chomp
116
+ - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric
117
+ #{metric_dashboard_links(attributes)}
118
+ - Set up Tableau Alerts via the Metric Trend Dashboards to receive notifications when your metrics cross specified thresholds.
119
+ See the Tableau Documentation for details: #{format_info('https://help.tableau.com/current/pro/desktop/en-us/data_alerts.htm')}
120
+ TEXT
121
+ end
122
+
123
+ def metric_dashboard_links(attributes)
124
+ time_frames = attributes['time_frame']
125
+
126
+ unless time_frames.is_a?(Array)
127
+ return "- Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))}"
128
+ end
129
+
130
+ dashboards = time_frames.map do |time_frame|
131
+ key_path = TimeFramedKeyPath.build(attributes['key_path'], time_frame)
132
+ " - #{format_info(metric_trend_path(key_path))}"
133
+ end
134
+ ['- Metric trend dashboards:', *dashboards].join("\n")
135
+ end
136
+
137
+ # Check existing event files for attributes to copy over
138
+ def prompt_for_copying_event_properties
139
+ shared_values = collect_values_for_shared_event_properties
140
+ defaults = shared_values.except(:stage, :section)
141
+
142
+ return {} if shared_values.none?
143
+
144
+ return shared_values if defaults.none?
145
+
146
+ new_page!(on_step: 'Defaults', steps: STEPS)
147
+
148
+ cli.say <<~TEXT
149
+ #{format_info('Convenient! We can copy these attributes from the event definition(s):')}
150
+
151
+ #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)}
152
+ #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')}
153
+
154
+ TEXT
155
+
156
+ cli.select('What would you like to do?', **select_opts) do |menu|
157
+ menu.enum '.'
158
+ menu.choice 'Copy & continue', -> { bulk_assign(defaults) }
159
+ menu.choice 'Modify attributes'
160
+ end
161
+
162
+ shared_values
163
+ end
164
+
165
+ def prompt_for_product_group(defaults)
166
+ assign_shared_attr(:product_group) do
167
+ new_page!(on_step: 'Group', steps: STEPS)
168
+
169
+ prompt_for_group_ownership('Which group owns the metric?', defaults)
170
+ end
171
+ end
172
+
173
+ def prompt_for_product_categories(defaults)
174
+ assign_shared_attr(:product_categories) do
175
+ new_page!(on_step: 'Categories', steps: STEPS)
176
+ cli.say <<~TEXT
177
+ #{format_info('FEATURE CATEGORY')}
178
+ Refer to https://handbook.gitlab.com/handbook/product/categories for information on current product categories.
179
+
180
+ TEXT
181
+
182
+ potential_groups = [
183
+ metric.product_group,
184
+ *selected_events.map(&:product_group),
185
+ defaults[:product_group]
186
+ ]
187
+ prompt_for_feature_categories(
188
+ 'Which feature categories best fit this metric?',
189
+ potential_groups,
190
+ defaults[:product_categories]
191
+ )
192
+ end
193
+ end
194
+
195
+ def prompt_for_url(defaults)
196
+ assign_shared_attr(:introduced_by_url) do
197
+ new_page!(on_step: 'URL', steps: STEPS)
198
+
199
+ prompt_for_text(
200
+ 'Which MR URL introduced the metric?',
201
+ defaults[:introduced_by_url]
202
+ )
203
+ end
204
+ end
205
+
206
+ def prompt_for_tier(defaults)
207
+ assign_shared_attr(:tiers) do
208
+ new_page!(on_step: 'Tiers', steps: STEPS)
209
+
210
+ prompt_for_array_selection(
211
+ 'Which tiers will the metric be reported from?',
212
+ [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]],
213
+ defaults[:tiers]
214
+ )
215
+ end
216
+ end
217
+
218
+ def create_metric_file
219
+ new_page!(on_step: 'Save files', steps: STEPS)
220
+
221
+ cli.say show_all_metric_paths(metric)
222
+ cli.say "\n"
223
+
224
+ cli.say format_prompt(format_subheader('SAVING FILE', metric.description))
225
+ cli.say "\n"
226
+
227
+ prompt_to_save_file(metric.file_path, metric.formatted_output)
228
+ end
229
+
230
+ def show_all_metric_paths(metric)
231
+ time_frames = metric.time_frame.value
232
+
233
+ return unless time_frames.is_a?(Array) && time_frames.length > 1
234
+
235
+ cli.say <<~TEXT
236
+ #{format_info "This would create #{time_frames.length} metrics with the following key paths:"}
237
+
238
+ #{time_frames.map do |time_frame|
239
+ "#{TimeFramedKeyPath::METRIC_TIME_FRAME_DESC[time_frame]}: #{format_info(TimeFramedKeyPath.build(metric.key_path, time_frame))}"
240
+ end.join("\n")}
241
+ TEXT
242
+ end
243
+
244
+ def prompt_for_next_steps(outcome = nil)
245
+ new_page!
246
+
247
+ outcome ||= ' No files saved.'
248
+
249
+ event_metric_message = "\n Have you instrumented the application code to trigger the event yet? " \
250
+ "View usage examples to easily copy/paste implementation!\n"
251
+ cli.say <<~TEXT
252
+ #{divider}
253
+ #{format_info('Done with metric definitions!')}
254
+
255
+ #{outcome}
256
+ #{divider}
257
+ #{event_metric_message if metric.event_metric?}
258
+ Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau
259
+ Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed
260
+ Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))}
261
+
262
+ Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake
263
+
264
+ TEXT
265
+
266
+ next_step = get_next_step
267
+
268
+ case next_step
269
+ when :new_event
270
+ EventDefiner.new(cli).run
271
+ when :new_metric_with_events
272
+ MetricDefiner.new(cli, @selected_event_paths).run
273
+ when :new_metric
274
+ MetricDefiner.new(cli).run
275
+ when :view_usage
276
+ args = [cli]
277
+ args += [@selected_event_paths.first, selected_events.first] if metric.event_metric?
278
+ UsageViewer.new(*args).run
279
+ when :exit
280
+ cli.say feedback_notice
281
+ end
282
+ end
283
+
284
+ # ----- Prompt-specific Helpers -------------
285
+
286
+ # Helper for #prompt_for_description
287
+ def selected_event_descriptions
288
+ selected_events.map do |event|
289
+ filters = @selected_filters[event.action]
290
+
291
+ if filters&.any?
292
+ filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ')
293
+ filter_phrase = format_help("(#{filter_phrase})")
294
+ end
295
+
296
+ " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n"
297
+ end
298
+ end
299
+
300
+ def prompt_for_description
301
+ new_page!(on_step: 'Description', steps: STEPS)
302
+
303
+ if metric.event_metric?
304
+ cli.say EVENT_METRIC_DESCRIPTION_INTRO
305
+ cli.say selected_event_descriptions.join
306
+ else
307
+ cli.say DATABASE_METRIC_DESCRIPTION_INTRO
308
+ end
309
+
310
+ cli.say <<~TEXT
311
+
312
+ #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text}
313
+
314
+ TEXT
315
+
316
+ if metric.technical_description
317
+ cli.say <<~TEXT
318
+ #{format_info('Technical description:')} #{metric.technical_description}
319
+
320
+ TEXT
321
+ end
322
+
323
+ has_prefix = !metric.description_prefix.nil?
324
+
325
+ description_start = format_info("#{metric.description_prefix}...") if has_prefix
326
+ command = has_prefix ? 'Finish' : 'Write'
327
+
328
+ description = prompt_for_text(" #{command} the description: #{description_start}", multiline: true) do |q|
329
+ q.required true
330
+ q.modify :trim
331
+ q.messages[:required?] = DESCRIPTION_HELP
332
+ end
333
+
334
+ metric.description = has_prefix ? "#{metric.description_prefix} #{description}" : description
335
+ end
336
+
337
+ def prompt_for_metric_name
338
+ name_reason = name_requirement_reason
339
+
340
+ return unless name_reason
341
+
342
+ default_name = metric.key.value
343
+ display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m")
344
+ empty_name = metric.key.value('')
345
+ max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length
346
+ help_tokens = { name: default_name, count: max_length }
347
+
348
+ cli.say <<~TEXT
349
+
350
+ #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text}
351
+
352
+ ID: #{format_info(display_name)}
353
+ Filename: #{format_info(display_name)}#{format_info('.yml')}
354
+
355
+ TEXT
356
+
357
+ metric.key = prompt_for_text(' Replace with: ', multiline: true) do |q|
358
+ q.required true
359
+ q.messages[:required?] = name_reason[:help] % help_tokens
360
+ q.messages[:valid?] = NAME_ERROR % help_tokens
361
+ q.validate lambda { |input|
362
+ input.length <= max_length &&
363
+ input.match?(NAME_REGEX) &&
364
+ !conflicting_key_path?(metric.key.value(input))
365
+ }
366
+ end
367
+ end
368
+
369
+ # Helper for #prompt_for_description
370
+ def name_requirement_reason
371
+ if metric.filters.assigned?
372
+ NAME_REQUIREMENT_REASONS[:filters]
373
+ elsif metric.file_name.length > MAX_FILENAME_LENGTH
374
+ NAME_REQUIREMENT_REASONS[:length]
375
+ elsif conflicting_key_path?(metric.key_path)
376
+ NAME_REQUIREMENT_REASONS[:conflict]
377
+ elsif !metric.event_metric?
378
+ NAME_REQUIREMENT_REASONS[:database_metric]
379
+ end
380
+ end
381
+
382
+ # Helper for #prompt_for_description
383
+ def conflicting_key_path?(key_path)
384
+ cli.global.metrics.any? do |existing_metric|
385
+ existing_metric.key_path == key_path
386
+ end
387
+ end
388
+
389
+ # Helper for #prompt_for_copying_event_properties
390
+ def collect_values_for_shared_event_properties
391
+ fields = Hash.new { |h, k| h[k] = [] }
392
+
393
+ selected_events.each do |event|
394
+ fields[:introduced_by_url] << event.introduced_by_url
395
+ fields[:product_group] << event.product_group
396
+ fields[:stage] << find_stage(event.product_group)
397
+ fields[:section] << find_section(event.product_group)
398
+ fields[:product_categories] << event.product_categories
399
+ fields[:tiers] << event.tiers&.sort
400
+ end
401
+
402
+ defaults = {}
403
+
404
+ # Use event value as default if it's the same for all
405
+ # selected events because it's unlikely to be different
406
+ fields.each do |field, values|
407
+ next unless values.compact.uniq.length == 1
408
+
409
+ defaults[field] ||= values.first
410
+ end
411
+
412
+ # If an event is relevant to a category, then the metric
413
+ # will be too, so we'll collect all categories
414
+ defaults[:product_categories] = known_categories & fields[:product_categories].flatten
415
+ defaults.delete(:product_categories) if defaults[:product_categories].empty?
416
+
417
+ defaults
418
+ end
419
+
420
+ # Helper for #prompt_for_next_steps
421
+ def get_next_step
422
+ cli.select('How would you like to proceed?', **select_opts) do |menu|
423
+ menu.enum '.'
424
+
425
+ menu.choice 'New Event -- define a new event', :new_event
426
+
427
+ if metric.event_metric?
428
+ actions = selected_events.map(&:action).join(', ')
429
+ menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events
430
+ end
431
+
432
+ menu.choice 'New Metric -- define another metric', :new_metric
433
+
434
+ if metric.event_metric?
435
+ view_usage_message = "View Usage -- look at code examples for event #{selected_events.first.action}"
436
+ default = view_usage_message
437
+ else
438
+ view_usage_message = 'View Usage -- look at code examples'
439
+ default = 'Exit'
440
+ end
441
+
442
+ menu.choice view_usage_message, :view_usage
443
+ menu.choice 'Exit', :exit
444
+ menu.default default
445
+ end
446
+ end
447
+
448
+ # ----- Shared Helpers ----------------------
449
+
450
+ def assign_shared_attrs(...)
451
+ attrs = metric.to_h.slice(...)
452
+ attrs = yield(metric) unless attrs.values.all?
453
+
454
+ bulk_assign(attrs)
455
+ end
456
+
457
+ def assign_shared_attr(key)
458
+ assign_shared_attrs(key) do |metric|
459
+ { key => yield(metric) }
460
+ end
461
+ end
462
+
463
+ def bulk_assign(attrs)
464
+ metric.bulk_assign(attrs)
465
+ end
466
+ end
467
+ end
468
+ end