File: metric_definer.rb

package info (click to toggle)
gitlab 17.6.5-19
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 629,368 kB
  • sloc: ruby: 1,915,304; javascript: 557,307; sql: 60,639; xml: 6,509; sh: 4,567; makefile: 1,239; python: 406
file content (574 lines) | stat: -rwxr-xr-x 18,765 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
# frozen_string_literal: true

require_relative '../helpers'
require_relative '../text/metric_definer'

# Entrypoint for flow to create an metric definition file
module InternalEventsCli
  module Flows
    class MetricDefiner
      include Helpers
      include Text::MetricDefiner

      SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json'))
      STEPS = [
        'New Metric',
        'Type',
        'Events',
        'Scope',
        'Descriptions',
        'Copy event',
        'Group',
        'URL',
        'Tiers',
        'Save files'
      ].freeze

      attr_reader :cli

      def initialize(cli, starting_event = nil)
        @cli = cli
        @selected_event_paths = Array(starting_event)
        @metrics = []
        @selected_filters = {}
      end

      def run
        type = prompt_for_metric_type
        prompt_for_events(type)

        return unless @selected_event_paths.any?

        prompt_for_metrics
        prompt_for_event_filters

        return unless @metrics.any?

        prompt_for_descriptions
        defaults = prompt_for_copying_event_properties
        prompt_for_product_group(defaults)
        prompt_for_url(defaults)
        prompt_for_tier(defaults)
        outcomes = create_metric_files
        prompt_for_next_steps(outcomes)
      end

      private

      # ----- Memoization Helpers -----------------

      def events
        @events ||= events_by_filepath(@selected_event_paths)
      end

      def selected_events
        @selected_events ||= events.values_at(*@selected_event_paths)
      end

      # ----- Prompts -----------------------------

      def prompt_for_metric_type
        return if @selected_event_paths.any?

        new_page!(1, 9, STEPS)

        cli.select("Which best describes what the metric should track?", **select_opts) do |menu|
          menu.enum "."

          menu.choice 'Single event    -- count occurrences of a specific event or user interaction',
            :event_metric
          menu.choice 'Multiple events -- count occurrences of several separate events or interactions',
            :aggregate_metric
          menu.choice 'Database        -- record value of a particular field or count of database rows',
            :database_metric
        end
      end

      def prompt_for_events(type)
        return if @selected_event_paths.any?

        new_page!(2, 9, STEPS)

        case type
        when :event_metric
          cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n"

          @selected_event_paths = [cli.select(
            'Which event does this metric track?',
            get_event_options(events),
            **select_opts,
            **filter_opts(header_size: 7)
          )]
        when :aggregate_metric
          cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n"

          @selected_event_paths = cli.multi_select(
            'Which events does this metric track? (Space to select)',
            get_event_options(events),
            **multiselect_opts,
            **filter_opts(header_size: 7)
          )
        when :database_metric
          cli.error DATABASE_METRIC_NOTICE
          cli.say feedback_notice
        end
      end

      def prompt_for_metrics
        eligible_metrics = get_metric_options(selected_events)

        if eligible_metrics.all? { |metric| metric[:disabled] }
          cli.error ALL_METRICS_EXIST_NOTICE
          cli.say feedback_notice

          return
        end

        new_page!(3, 9, STEPS)
        cli.say format_info('SELECTED EVENTS')
        cli.say selected_events_filter_options.join
        cli.say "\n"

        @metrics = cli.select(
          'Which metrics do you want to add?',
          eligible_metrics,
          **select_opts,
          **filter_opts,
          per_page: 20,
          &disabled_format_callback
        )

        assign_shared_attrs(:actions, :milestone) do
          {
            actions: selected_events.map(&:action).sort,
            milestone: MILESTONE
          }
        end
      end

      def prompt_for_event_filters
        return if @metrics.none?(&:filters_expected?)

        selected_unique_identifier = @metrics.first.identifier.value
        event_count = selected_events.length
        previous_inputs = {
          'label' => nil,
          'property' => nil,
          'value' => nil
        }

        event_filters = selected_events.dup.flat_map.with_index do |event, idx|
          print_event_filter_header(event, idx, event_count)

          next if deselect_nonfilterable_event?(event) # prompts user

          filter_values = event.additional_properties&.filter_map do |property, _|
            next if selected_unique_identifier == property

            prompt_for_property_filter(
              event.action,
              property,
              previous_inputs[property]
            )
          end

          previous_inputs.merge!(@selected_filters[event.action] || {})

          find_filter_permutations(event.action, filter_values)
        end.compact

        bulk_assign(filters: event_filters)
      end

      def prompt_for_descriptions
        default_description = nil
        default_key = nil

        separate_page_per_metric = @metrics.any? do |metric|
          name_requirement_reason(metric)
        end

        @metrics.each_with_index do |metric, idx|
          if idx == 0 || separate_page_per_metric
            new_page!(4, 9, STEPS)

            cli.say DESCRIPTION_INTRO
            cli.say selected_event_descriptions.join
          end

          cli.say "\n"
          cli.say format_prompt(format_subheader(
            'DESCRIBING METRIC',
            metric.technical_description,
            idx,
            @metrics.length
          ))

          prompt_for_description(metric, default_description).tap do |description|
            default_description = description
            metric.description = "#{metric.description_prefix} #{description}"
          end

          prompt_for_metric_name(metric, default_key)&.tap do |key|
            default_key = key
            metric.key = key
          end
        end
      end

      def file_saved_context_message(attributes)
        format_prefix "  ", <<~TEXT.chomp
          - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric
          - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))}
        TEXT
      end

      # Check existing event files for attributes to copy over
      def prompt_for_copying_event_properties
        shared_values = collect_values_for_shared_event_properties
        defaults = shared_values.except(:stage, :section)

        return {} if shared_values.none?

        return shared_values if defaults.none?

        new_page!(5, 9, STEPS)

        cli.say <<~TEXT
          #{format_info('Convenient! We can copy these attributes from the event definition(s):')}

          #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)}
          #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')}

        TEXT

        cli.select('What would you like to do?', **select_opts) do |menu|
          menu.enum '.'
          menu.choice 'Copy & continue', -> { bulk_assign(defaults) }
          menu.choice 'Modify attributes'
        end

        shared_values
      end

      def prompt_for_product_group(defaults)
        assign_shared_attr(:product_group) do
          new_page!(6, 9, STEPS)

          prompt_for_group_ownership('Which group owns the metric?', defaults)
        end
      end

      def prompt_for_url(defaults)
        assign_shared_attr(:introduced_by_url) do
          new_page!(7, 9, STEPS)

          prompt_for_text(
            'Which MR URL introduced the metric?',
            defaults[:introduced_by_url]
          )
        end
      end

      def prompt_for_tier(defaults)
        assign_shared_attr(:tier) do
          new_page!(8, 9, STEPS)

          prompt_for_array_selection(
            'Which tiers will the metric be reported from?',
            [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]],
            defaults[:tier]
          )
        end

        assign_shared_attr(:tiers) { |metric| [*metric.tier] }

        assign_shared_attr(:distribution) do |metric|
          metric.tier.include?('free') ? %w[ce ee] : %w[ee]
        end
      end

      def create_metric_files
        @metrics.map.with_index do |metric, idx|
          new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter

          cli.say format_prompt(format_subheader('SAVING FILE', metric.description, idx, @metrics.length))
          cli.say "\n"

          prompt_to_save_file(metric.file_path, metric.formatted_output)
        end
      end

      def prompt_for_next_steps(outcomes = [])
        new_page!

        outcome = outcomes.any? ? outcomes.compact.join("\n") : '  No files saved.'
        metric = @metrics.first

        cli.say <<~TEXT
          #{divider}
          #{format_info('Done with metric definitions!')}

          #{outcome}
          #{divider}

            Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation!

            Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau
              Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed
              Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))}

            Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake

        TEXT

        actions = selected_events.map(&:action).join(', ')
        next_step = cli.select("How would you like to proceed?", **select_opts) do |menu|
          menu.enum "."

          menu.choice "New Event -- define a new event", :new_event
          menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events
          menu.choice "New Metric -- define another metric", :new_metric
          choice = "View Usage -- look at code examples for event #{selected_events.first.action}"
          menu.default choice
          menu.choice choice, :view_usage
          menu.choice 'Exit', :exit
        end

        case next_step
        when :new_event
          EventDefiner.new(cli).run
        when :new_metric_with_events
          MetricDefiner.new(cli, @selected_event_paths).run
        when :new_metric
          MetricDefiner.new(cli).run
        when :view_usage
          UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run
        when :exit
          cli.say feedback_notice
        end
      end

      # ----- Prompt-specific Helpers -------------

      # Helper for #prompt_for_metrics
      def selected_events_filter_options
        filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? }

        selected_events.map do |event|
          filters = event.additional_properties&.keys
          filter_phrase = if filters
                            " (filterable by #{filters&.join(', ')})"
                          elsif filterable_events_selected
                            ' -- not filterable'
                          end

          "  - #{event.action}#{format_help(filter_phrase)}\n"
        end
      end

      # Helper for #prompt_for_event_filters
      def print_event_filter_header(event, idx, total)
        cli.say "\n"
        cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total))

        return unless event.additional_properties&.any?

        event_filter_options = event.additional_properties.map do |property, attrs|
          "  #{property}: #{attrs['description']}\n"
        end

        cli.say event_filter_options.join
      end

      # Helper for #prompt_for_event_filters
      def deselect_nonfilterable_event?(event)
        cli.say "\n"

        return false if event.additional_properties&.any?
        return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts)

        selected_events.delete(event)
        bulk_assign(actions: selected_events.map(&:action).sort)

        true
      end

      # Helper for #prompt_for_event_filters
      def prompt_for_property_filter(action, property, default)
        formatted_prop = format_info(property)
        prompt = "Count where #{formatted_prop} equals any of (comma-sep):"

        inputs = prompt_for_text(prompt, default, **input_opts) do |q|
          if property == 'value'
            q.convert ->(input) { input.split(',').map(&:to_i).uniq }
            q.validate %r{^(\d|\s|,)*$}
            q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric"
          elsif property == 'property' || property == 'label'
            q.convert ->(input) { input.split(',').map(&:strip).uniq }
          else
            q.convert ->(input) do
              input.split(',').map do |value|
                val = value.strip
                cast_if_numeric(val)
              end.uniq
            end
          end
        end

        return unless inputs&.any?

        @selected_filters[action] ||= {}
        @selected_filters[action][property] = inputs.join(',')

        inputs.map { |input| { property => input } }.uniq
      end

      def cast_if_numeric(text)
        float = Float(text)
        float % 1 == 0 ? float.to_i : float
      rescue ArgumentError
        text
      end

      # Helper for #prompt_for_event_filters
      #
      # Gets all the permutations of the provided property values.
      # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }]
      # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }]
      def find_filter_permutations(action, filters)
        # Define a filter for all events, regardless of the available props so NewMetric#events is correct
        return [[action, {}]] unless filters&.any?

        # Uses proc syntax to avoid spliting & type-checking `filters`
        :product.to_proc.call(*filters).map do |filter|
          [action, filter.reduce(&:merge)]
        end
      end

      # Helper for #prompt_for_descriptions
      def selected_event_descriptions
        selected_events.map do |event|
          filters = @selected_filters[event.action]

          if filters&.any?
            filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ')
            filter_phrase = format_help("(#{filter_phrase})")
          end

          "  #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n"
        end
      end

      # Helper for #prompt_for_descriptions
      def prompt_for_description(metric, default)
        description_start = format_info("#{metric.description_prefix}...")

        cli.say <<~TEXT

          #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text}

        TEXT

        prompt_for_text("  Finish the description: #{description_start}", default, multiline: true) do |q|
          q.required true
          q.modify :trim
          q.messages[:required?] = DESCRIPTION_HELP
        end
      end

      # Helper for #prompt_for_descriptions
      def prompt_for_metric_name(metric, default)
        name_reason = name_requirement_reason(metric)
        default_name = metric.key.value
        display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m")
        empty_name = metric.key.value('')

        return unless name_reason

        cli.say <<~TEXT

          #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text}

                      ID:  #{format_info(display_name)}
                Filename:  #{format_info(display_name)}#{format_info('.yml')}

        TEXT

        max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length
        help_tokens = { name: default_name, count: max_length }

        prompt_for_text('  Replace with: ', default, multiline: true) do |q|
          q.required true
          q.messages[:required?] = name_reason[:help] % help_tokens
          q.messages[:valid?] = NAME_ERROR % help_tokens
          q.validate ->(input) do
            input.length <= max_length &&
              input.match?(NAME_REGEX) &&
              !conflicting_key_path?(metric.key.value(input))
          end
        end
      end

      # Helper for #prompt_for_descriptions
      def name_requirement_reason(metric)
        if metric.filters.assigned?
          NAME_REQUIREMENT_REASONS[:filters]
        elsif metric.file_name.length > MAX_FILENAME_LENGTH
          NAME_REQUIREMENT_REASONS[:length]
        elsif conflicting_key_path?(metric.key_path)
          NAME_REQUIREMENT_REASONS[:conflict]
        end
      end

      # Helper for #prompt_for_descriptions
      def conflicting_key_path?(key_path)
        cli.global.metrics.any? do |existing_metric|
          existing_metric.key_path == key_path
        end
      end

      # Helper for #prompt_for_copying_event_properties
      def collect_values_for_shared_event_properties
        fields = Hash.new { |h, k| h[k] = [] }

        selected_events.each do |event|
          fields[:introduced_by_url] << event.introduced_by_url
          fields[:product_group] << event.product_group
          fields[:stage] << find_stage(event.product_group)
          fields[:section] << find_section(event.product_group)
          fields[:distribution] << event.distributions&.sort
          fields[:tier] << event.tiers&.sort
          fields[:tiers] << event.tiers&.sort
        end

        # Keep event values if every selected event is the same
        fields.each_with_object({}) do |(attr, values), defaults|
          next unless values.compact.uniq.length == 1

          defaults[attr] ||= values.first
        end
      end

      # ----- Shared Helpers ----------------------

      def assign_shared_attrs(...)
        metric = @metrics.first
        attrs = metric.to_h.slice(...)
        attrs = yield(metric) unless attrs.values.all?

        bulk_assign(attrs)
      end

      def assign_shared_attr(key)
        assign_shared_attrs(key) do |metric|
          { key => yield(metric) }
        end
      end

      def bulk_assign(attrs)
        @metrics.each { |metric| metric.bulk_assign(attrs) }
      end
    end
  end
end