diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-16 03:10:37 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-16 03:10:37 +0300 |
commit | 84025108bca604c1428d2cf6a6b69616ee90956c (patch) | |
tree | 7c0fd8ec032c12bee563daeee6c721e2d7f7d44f /scripts | |
parent | 73778b9c53d13a2e06a693c30073366deedead8f (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'scripts')
-rwxr-xr-x | scripts/internal_events/cli.rb | 135 | ||||
-rwxr-xr-x | scripts/internal_events/cli/event.rb | 55 | ||||
-rwxr-xr-x | scripts/internal_events/cli/event_definer.rb | 180 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers.rb | 27 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers/cli_inputs.rb | 60 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers/event_options.rb | 80 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers/files.rb | 36 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers/formatting.rb | 89 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers/group_ownership.rb | 71 | ||||
-rwxr-xr-x | scripts/internal_events/cli/helpers/metric_options.rb | 124 | ||||
-rwxr-xr-x | scripts/internal_events/cli/metric.rb | 155 | ||||
-rwxr-xr-x | scripts/internal_events/cli/metric_definer.rb | 310 | ||||
-rwxr-xr-x | scripts/internal_events/cli/text.rb | 216 | ||||
-rwxr-xr-x | scripts/internal_events/cli/usage_viewer.rb | 247 |
14 files changed, 1785 insertions, 0 deletions
diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb new file mode 100755 index 00000000000..6cc9f599608 --- /dev/null +++ b/scripts/internal_events/cli.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# !/usr/bin/env ruby +# +# Generate a metric/event files in the correct locations. + +require 'tty-prompt' +require 'net/http' +require 'yaml' + +require_relative './cli/helpers' +require_relative './cli/usage_viewer' +require_relative './cli/metric_definer' +require_relative './cli/event_definer' +require_relative './cli/metric' +require_relative './cli/event' +require_relative './cli/text' + +class Cli + include ::InternalEventsCli::Helpers + + attr_reader :cli + + def initialize(cli) + @cli = cli + end + + def run + cli.say InternalEventsCli::Text::FEEDBACK_NOTICE + cli.say InternalEventsCli::Text::CLI_INSTRUCTIONS + + task = cli.select("What would you like to do?", **select_opts) do |menu| + menu.enum "." + + menu.choice "New Event -- track when a specific scenario occurs on gitlab instances\n " \ + "ex) a user applies a label to an issue", :new_event + menu.choice "New Metric -- track the count of existing events over time\n " \ + "ex) count unique users who assign labels to issues per month", :new_metric + menu.choice 'View Usage -- look at code examples for an existing event', :view_usage + menu.choice '...am I in the right place?', :help_decide + end + + case task + when :new_event + InternalEventsCli::EventDefiner.new(cli).run + when :new_metric + InternalEventsCli::MetricDefiner.new(cli).run + when :view_usage + InternalEventsCli::UsageViewer.new(cli).run + when :help_decide + help_decide + end + end + + private + + def help_decide + return use_case_error unless goal_is_tracking_usage? + return use_case_error unless usage_trackable_with_internal_events? + + event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition + end + + def goal_is_tracking_usage? + new_page! + + cli.say format_info("First, let's check your objective.\n") + + cli.yes?('Are you trying to track customer usage of a GitLab feature?', **yes_no_opts) + end + + def usage_trackable_with_internal_events? + new_page! + + cli.say format_info("Excellent! Let's check that this tool will fit your needs.\n") + cli.say InternalEventsCli::Text::EVENT_TRACKING_EXAMPLES + + cli.yes?( + 'Can usage for the feature be measured with a count of specific user actions or events? ' \ + 'Or counting a set of events?', **yes_no_opts + ) + end + + def event_already_tracked? + new_page! + + cli.say format_info("Super! Let's figure out if the event is already tracked & usable.\n") + cli.say InternalEventsCli::Text::EVENT_EXISTENCE_CHECK_INSTRUCTIONS + + cli.yes?('Is the event already tracked?', **yes_no_opts) + end + + def use_case_error + new_page! + + cli.error("Oh no! This probably isn't the tool you need!\n") + cli.say InternalEventsCli::Text::ALTERNATE_RESOURCES_NOTICE + cli.say InternalEventsCli::Text::FEEDBACK_NOTICE + end + + def proceed_to_metric_definition + new_page! + + cli.say format_info("Amazing! The next step is adding a new metric! (~8 min)\n") + + return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?')) + + InternalEventsCli::MetricDefiner.new(cli).run + end + + def proceed_to_event_definition + new_page! + + cli.say format_info("Okay! The next step is adding a new event! (~5 min)\n") + + return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?')) + + InternalEventsCli::EventDefiner.new(cli).run + end + + def not_ready_error(description) + cli.say "\nNo problem! When you're ready, run the CLI & select '#{description}'\n" + cli.say InternalEventsCli::Text::FEEDBACK_NOTICE + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Cli.new(TTY::Prompt.new).run + rescue Interrupt + puts "\n" + end +end + +# vim: ft=ruby diff --git a/scripts/internal_events/cli/event.rb b/scripts/internal_events/cli/event.rb new file mode 100755 index 00000000000..d98aa8a6bd1 --- /dev/null +++ b/scripts/internal_events/cli/event.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module InternalEventsCli + NEW_EVENT_FIELDS = [ + :description, + :category, + :action, + :label_description, + :property_description, + :value_description, + :value_type, + :extra_properties, + :identifiers, + :product_section, + :product_stage, + :product_group, + :milestone, + :introduced_by_url, + :distributions, + :tiers + ].freeze + + EVENT_DEFAULTS = { + product_section: nil, + product_stage: nil, + product_group: nil, + introduced_by_url: 'TODO', + category: 'InternalEventTracking' + }.freeze + + Event = Struct.new(*NEW_EVENT_FIELDS, keyword_init: true) do + def formatted_output + EVENT_DEFAULTS + .merge(to_h.compact) + .slice(*NEW_EVENT_FIELDS) + .transform_keys(&:to_s) + .to_yaml(line_width: 150) + end + + def file_path + File.join( + *[ + ('ee' unless distributions.include?('ce')), + 'config', + 'events', + "#{action}.yml" + ].compact + ) + end + + def bulk_assign(key_value_pairs) + key_value_pairs.each { |key, value| self[key] = value } + end + end +end diff --git a/scripts/internal_events/cli/event_definer.rb b/scripts/internal_events/cli/event_definer.rb new file mode 100755 index 00000000000..e029f0e7cf6 --- /dev/null +++ b/scripts/internal_events/cli/event_definer.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require_relative './helpers' + +module InternalEventsCli + class EventDefiner + include Helpers + + STEPS = [ + 'New Event', + 'Description', + 'Name', + 'Context', + 'URL', + 'Group', + 'Tiers', + 'Save files' + ].freeze + + IDENTIFIER_OPTIONS = { + %w[project namespace user] => 'Use case: For project-level user actions ' \ + '(ex - issue_assignee_changed) [MOST COMMON]', + %w[namespace user] => 'Use case: For namespace-level user actions (ex - epic_assigned_to_milestone)', + %w[user] => 'Use case: For user-only actions (ex - admin_impersonated_user)', + %w[project namespace] => 'Use case: For project-level events without user interaction ' \ + '(ex - service_desk_request_received)', + %w[namespace] => 'Use case: For namespace-level events without user interaction ' \ + '(ex - stale_runners_cleaned_up)', + %w[] => "Use case: For instance-level events without user interaction [LEAST COMMON]" + }.freeze + + IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.max_by(&:length).join(', ')}]".length + + attr_reader :cli, :event + + def initialize(cli) + @cli = cli + @event = Event.new(milestone: MILESTONE) + end + + def run + prompt_for_description + prompt_for_action + prompt_for_identifiers + prompt_for_url + prompt_for_product_ownership + prompt_for_tier + + outcome = create_event_file + display_result(outcome) + + prompt_for_next_steps + end + + private + + def prompt_for_description + new_page!(1, 7, STEPS) + cli.say Text::EVENT_DESCRIPTION_INTRO + + event.description = cli.ask("Describe what the event tracks: #{input_required_text}", **input_opts) do |q| + q.required true + q.modify :trim + q.messages[:required?] = Text::EVENT_DESCRIPTION_HELP + end + end + + def prompt_for_action + new_page!(2, 7, STEPS) + cli.say Text::EVENT_ACTION_INTRO + + event.action = cli.ask("Define the event name: #{input_required_text}", **input_opts) do |q| + q.required true + q.validate ->(input) { input =~ /\A\w+\z/ && !events_by_filepath.values.map(&:action).include?(input) } # rubocop:disable Rails/NegateInclude -- Not rails + q.modify :trim + q.messages[:valid?] = format_warning("Invalid event name. Only letters/numbers/underscores allowed. " \ + "Ensure %{value} is not an existing event.") + q.messages[:required?] = Text::EVENT_ACTION_HELP + end + end + + def prompt_for_identifiers + new_page!(3, 7, STEPS) + cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action + + identifiers = prompt_for_array_selection( + 'Which identifiers are available when the event occurs?', + IDENTIFIER_OPTIONS.keys + ) { |choice| format_identifier_choice(choice) } + + event.identifiers = identifiers if identifiers.any? + end + + def format_identifier_choice(choice) + formatted_choice = choice.empty? ? 'None' : "[#{choice.sort.join(', ')}]" + buffer = IDENTIFIER_FORMATTING_BUFFER - formatted_choice.length + + "#{formatted_choice}#{' ' * buffer} -- #{IDENTIFIER_OPTIONS[choice]}" + end + + def prompt_for_url + new_page!(4, 7, STEPS) + + event.introduced_by_url = prompt_for_text('Which MR URL will merge the event definition?') + end + + def prompt_for_product_ownership + new_page!(5, 7, STEPS) + + ownership = prompt_for_group_ownership({ + product_section: 'Which section will own the event?', + product_stage: 'Which stage will own the event?', + product_group: 'Which group will own the event?' + }) + + event.bulk_assign(ownership) + end + + def prompt_for_tier + new_page!(6, 7, STEPS) + + event.tiers = prompt_for_array_selection( + 'Which tiers will the event be recorded on?', + [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]] + ) + + event.distributions = event.tiers.include?('free') ? %w[ce ee] : %w[ee] + end + + def create_event_file + new_page!(7, 7, STEPS) + + prompt_to_save_file(event.file_path, event.formatted_output) + end + + def display_result(outcome) + new_page! + + cli.say <<~TEXT + #{divider} + #{format_info('Done with event definition!')} + + #{outcome || ' No files saved.'} + + #{divider} + + Want to have data reported in Snowplow/Sisense/ServicePing? Add a new metric for your event! + + TEXT + end + + def prompt_for_next_steps + next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + + if File.exist?(event.file_path) + menu.choice "Create Metric -- define a new metric using #{event.action}.yml", :add_metric + else + menu.choice "Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add + end + + menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage + menu.choice 'Exit', :exit + end + + case next_step + when :add_metric + MetricDefiner.new(cli, event.file_path).run + when :save_and_add + write_to_file(event.file_path, event.formatted_output, 'create') + + MetricDefiner.new(cli, event.file_path).run + when :view_usage + UsageViewer.new(cli, event.file_path, event).run + when :exit + cli.say Text::FEEDBACK_NOTICE + end + end + end +end diff --git a/scripts/internal_events/cli/helpers.rb b/scripts/internal_events/cli/helpers.rb new file mode 100755 index 00000000000..95672325652 --- /dev/null +++ b/scripts/internal_events/cli/helpers.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative './helpers/cli_inputs' +require_relative './helpers/files' +require_relative './helpers/formatting' +require_relative './helpers/group_ownership' +require_relative './helpers/event_options' +require_relative './helpers/metric_options' + +module InternalEventsCli + module Helpers + include CliInputs + include Files + include Formatting + include GroupOwnership + include EventOptions + include MetricOptions + + MILESTONE = File.read('VERSION').strip.match(/(\d+\.\d+)/).captures.first + + def new_page!(page = nil, total = nil, steps = []) + cli.say TTY::Cursor.clear_screen + cli.say TTY::Cursor.move_to(0, 0) + cli.say "#{progress_bar(page, total, steps)}\n" if page && total + end + end +end diff --git a/scripts/internal_events/cli/helpers/cli_inputs.rb b/scripts/internal_events/cli/helpers/cli_inputs.rb new file mode 100755 index 00000000000..106d854d0b3 --- /dev/null +++ b/scripts/internal_events/cli/helpers/cli_inputs.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Helpers related to configuration of TTY::Prompt prompts +module InternalEventsCli + module Helpers + module CliInputs + def prompt_for_array_selection(message, choices, default = nil, &formatter) + formatter ||= ->(choice) { choice.sort.join(", ") } + + choices = choices.map do |choice| + { name: formatter.call(choice), value: choice } + end + + cli.select(message, choices, **select_opts) do |menu| + menu.enum "." + menu.default formatter.call(default) if default + end + end + + def prompt_for_text(message, value = nil) + help_message = "(enter to #{value ? 'submit' : 'skip'})" + + cli.ask( + "#{message} #{format_help(help_message)}", + value: value || '', + **input_opts + ) + end + + def input_opts + { prefix: format_prompt('Input text: ') } + end + + def yes_no_opts + { prefix: format_prompt('Yes/No: ') } + end + + def select_opts + { prefix: format_prompt('Select one: '), cycle: true, show_help: :always } + end + + def multiselect_opts + { prefix: format_prompt('Select multiple: '), cycle: true, show_help: :always, min: 1 } + end + + # Accepts a number of lines occupied by text, so remaining + # screen real estate can be filled with select options + def filter_opts(header_size: nil) + { + filter: true, + per_page: header_size ? [(window_height - header_size), 10].max : 30 + } + end + + def input_required_text + format_help("(leave blank for help)") + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/event_options.rb b/scripts/internal_events/cli/helpers/event_options.rb new file mode 100755 index 00000000000..f53127798aa --- /dev/null +++ b/scripts/internal_events/cli/helpers/event_options.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Helpers related to listing existing event definitions +module InternalEventsCli + module Helpers + module EventOptions + def get_event_options(events) + options = events.filter_map do |(path, event)| + next if duplicate_events?(event.action, events.values) + + description = format_help(" - #{trim_description(event.description)}") + + { + name: "#{format_event_name(event)}#{description}", + value: path + } + end + + options.sort_by do |option| + category = events.dig(option[:value], 'category') + event_sort_param(category, option[:name]) + end + end + + def events_by_filepath(event_paths = []) + event_paths = load_event_paths if event_paths.none? + + get_existing_events_for_paths(event_paths) + end + + private + + def trim_description(description) + return description if description.to_s.length < 50 + + "#{description[0, 50]}..." + end + + def format_event_name(event) + case event.category + when 'InternalEventTracking', 'default' + event.action + else + "#{event.category}:#{event.action}" + end + end + + def event_sort_param(category, name) + case category + when 'InternalEventTracking' + "0#{name}" + when 'default' + "1#{name}" + else + "2#{category}#{name}" + end + end + + def get_existing_events_for_paths(event_paths) + event_paths.each_with_object({}) do |filepath, events| + details = YAML.safe_load(File.read(filepath)) + fields = InternalEventsCli::NEW_EVENT_FIELDS.map(&:to_s) + + events[filepath] = Event.new(**details.slice(*fields)) + end + end + + def duplicate_events?(action, events) + events.count { |event| action == event.action } > 1 + end + + def load_event_paths + [ + Dir["config/events/*.yml"], + Dir["ee/config/events/*.yml"] + ].flatten + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/files.rb b/scripts/internal_events/cli/helpers/files.rb new file mode 100755 index 00000000000..b613350353f --- /dev/null +++ b/scripts/internal_events/cli/helpers/files.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Helpers related reading/writing definition files +module InternalEventsCli + module Helpers + module Files + def prompt_to_save_file(filepath, content) + cli.say <<~TEXT.chomp + #{format_info('Preparing to generate definition with these attributes:')} + #{filepath} + #{content} + TEXT + + if File.exist?(filepath) + cli.error("Oh no! This file already exists!\n") + + return if cli.no?(format_prompt('Overwrite file?')) + + write_to_file(filepath, content, 'update') + elsif cli.yes?(format_prompt('Create file?')) + write_to_file(filepath, content, 'create') + end + end + + def file_saved_message(verb, filepath) + " #{format_selection(verb)} #{filepath}" + end + + def write_to_file(filepath, content, verb) + File.write(filepath, content) + + file_saved_message(verb, filepath).tap { |message| cli.say "\n#{message}\n" } + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/formatting.rb b/scripts/internal_events/cli/helpers/formatting.rb new file mode 100755 index 00000000000..87be585c739 --- /dev/null +++ b/scripts/internal_events/cli/helpers/formatting.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Helpers related to visual formatting of outputs +module InternalEventsCli + module Helpers + module Formatting + DEFAULT_WINDOW_WIDTH = 100 + DEFAULT_WINDOW_HEIGHT = 30 + + def format_info(string) + pastel.cyan(string) + end + + def format_warning(string) + pastel.yellow(string) + end + + def format_selection(string) + pastel.green(string) + end + + def format_help(string) + pastel.bright_black(string) + end + + def format_prompt(string) + pastel.magenta(string) + end + + def format_error(string) + pastel.red(string) + end + + def format_heading(string) + [divider, pastel.cyan(string), divider].join("\n") + end + + def divider + "-" * window_size + end + + def progress_bar(step, total, titles = []) + breadcrumbs = [ + titles[0..(step - 1)], + format_selection(titles[step]), + titles[(step + 1)..] + ] + + status = " Step #{step} / #{total} : #{breadcrumbs.flatten.join(' > ')}" + total_length = window_size - 4 + step_length = step / total.to_f * total_length + + incomplete = '-' * [(total_length - step_length - 1), 0].max + complete = '=' * [(step_length - 1), 0].max + "#{status}\n|==#{complete}>#{incomplete}|\n" + end + + def counter(idx, total) + format_prompt("(#{idx + 1}/#{total})") if total > 1 + end + + private + + def pastel + @pastel ||= Pastel.new + end + + def window_size + Integer(fetch_window_size) + rescue StandardError + DEFAULT_WINDOW_WIDTH + end + + def window_height + Integer(fetch_window_height) + rescue StandardError + DEFAULT_WINDOW_HEIGHT + end + + def fetch_window_size + `tput cols` + end + + def fetch_window_height + `tput lines` + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/group_ownership.rb b/scripts/internal_events/cli/helpers/group_ownership.rb new file mode 100755 index 00000000000..9846f0ca2f5 --- /dev/null +++ b/scripts/internal_events/cli/helpers/group_ownership.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Helpers related to Stage/Section/Group ownership +module InternalEventsCli + module Helpers + module GroupOwnership + STAGES_YML = 'https://gitlab.com/gitlab-com/www-gitlab-com/-/raw/master/data/stages.yml' + + def prompt_for_group_ownership(messages, defaults = {}) + groups = fetch_group_choices + + if groups + prompt_for_ownership_from_ssot(messages[:product_group], defaults, groups) + else + prompt_for_ownership_manually(messages, defaults) + end + end + + private + + def prompt_for_ownership_from_ssot(prompt, defaults, groups) + sorted_defaults = defaults.values_at(:product_section, :product_stage, :product_group) + default = sorted_defaults.join(':') + + cli.select(prompt, groups, **select_opts, **filter_opts) do |menu| + if sorted_defaults.all? + if groups.any? { |group| group[:name] == default } + # We have a complete group selection -> set as default in menu + menu.default(default) + else + cli.error format_error(">>> Failed to find group matching #{default}. Select another.\n") + end + elsif sorted_defaults.any? + # We have a partial selection -> filter the list by the most unique field + menu.instance_variable_set(:@filter, sorted_defaults.compact.last.split('')) + end + end + end + + def prompt_for_ownership_manually(messages, defaults) + { + product_section: prompt_for_text(messages[:product_section], defaults[:product_section]), + product_stage: prompt_for_text(messages[:product_stage], defaults[:product_stage]), + product_group: prompt_for_text(messages[:product_group], defaults[:product_group]) + } + end + + # @return Array[<Hash - matches #prompt_for_ownership_manually output format>] + def fetch_group_choices + response = Timeout.timeout(5) { Net::HTTP.get(URI(STAGES_YML)) } + stages = YAML.safe_load(response) + + stages['stages'].flat_map do |stage, value| + value['groups'].map do |group, _| + section = value['section'] + + { + name: [section, stage, group].join(':'), + value: { + product_group: group, + product_section: section, + product_stage: stage + } + } + end + end + rescue StandardError + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/metric_options.rb b/scripts/internal_events/cli/helpers/metric_options.rb new file mode 100755 index 00000000000..01512115e05 --- /dev/null +++ b/scripts/internal_events/cli/helpers/metric_options.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Helpers related to listing existing metric definitions +module InternalEventsCli + module Helpers + module MetricOptions + EVENT_PHRASES = { + 'user' => "who triggered %s", + 'namespace' => "where %s occurred", + 'project' => "where %s occurred", + nil => "%s occurrences" + }.freeze + + def get_metric_options(events) + options = get_all_metric_options + identifiers = get_identifiers_for_events(events) + existing_metrics = get_existing_metrics_for_events(events) + metric_name = format_metric_name_for_events(events) + + options = options.group_by do |metric| + [ + metric.identifier, + metric_already_exists?(existing_metrics, metric), + metric.time_frame == 'all' + ] + end + + options.map do |(identifier, defined, _), metrics| + format_metric_option( + identifier, + metric_name, + metrics, + defined: defined, + supported: [*identifiers, nil].include?(identifier) + ) + end + end + + private + + def get_all_metric_options + [ + Metric.new(time_frame: '28d', identifier: 'user'), + Metric.new(time_frame: '7d', identifier: 'user'), + Metric.new(time_frame: '28d', identifier: 'project'), + Metric.new(time_frame: '7d', identifier: 'project'), + Metric.new(time_frame: '28d', identifier: 'namespace'), + Metric.new(time_frame: '7d', identifier: 'namespace'), + Metric.new(time_frame: '28d'), + Metric.new(time_frame: '7d'), + Metric.new(time_frame: 'all') + ] + end + + def load_metric_paths + [ + Dir["config/metrics/counts_all/*.yml"], + Dir["config/metrics/counts_7d/*.yml"], + Dir["config/metrics/counts_28d/*.yml"], + Dir["ee/config/metrics/counts_all/*.yml"], + Dir["ee/config/metrics/counts_7d/*.yml"], + Dir["ee/config/metrics/counts_28d/*.yml"] + ].flatten + end + + def get_existing_metrics_for_events(events) + actions = events.map(&:action).sort + + load_metric_paths.filter_map do |path| + details = YAML.safe_load(File.read(path)) + fields = InternalEventsCli::NEW_METRIC_FIELDS.map(&:to_s) + + metric = Metric.new(**details.slice(*fields)) + next unless metric.actions + + metric if (metric.actions & actions).any? + end + end + + def format_metric_name_for_events(events) + return events.first.action if events.length == 1 + + "any of #{events.length} events" + end + + # Get only the identifiers in common for all events + def get_identifiers_for_events(events) + events.map(&:identifiers).reduce(&:&) || [] + end + + def metric_already_exists?(existing_metrics, metric) + existing_metrics.any? do |existing_metric| + time_frame = existing_metric.time_frame || 'all' + identifier = existing_metric.events&.dig(0, 'unique')&.chomp('.id') + + metric.time_frame == time_frame && metric.identifier == identifier + end + end + + def format_metric_option(identifier, event_name, metrics, defined:, supported:) + time_frame = metrics.map(&:time_frame_prefix).join('/') + unique_by = "unique #{identifier}s " if identifier + event_phrase = EVENT_PHRASES[identifier] % event_name + + if supported && !defined + time_frame = format_info(time_frame) + unique_by = format_info(unique_by) + end + + name = "#{time_frame} count of #{unique_by}[#{event_phrase}]" + + if supported && defined + disabled = format_warning("(already defined)") + name = format_help(name) + elsif !supported + disabled = format_warning("(#{identifier} unavailable)") + name = format_help(name) + end + + { name: name, value: metrics, disabled: disabled }.compact + end + end + end +end diff --git a/scripts/internal_events/cli/metric.rb b/scripts/internal_events/cli/metric.rb new file mode 100755 index 00000000000..63961d29810 --- /dev/null +++ b/scripts/internal_events/cli/metric.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module InternalEventsCli + NEW_METRIC_FIELDS = [ + :key_path, + :description, + :product_section, + :product_stage, + :product_group, + :performance_indicator_type, + :value_type, + :status, + :milestone, + :introduced_by_url, + :time_frame, + :data_source, + :data_category, + :product_category, + :instrumentation_class, + :distribution, + :tier, + :options, + :events + ].freeze + + ADDITIONAL_METRIC_FIELDS = [ + :milestone_removed, + :removed_by_url, + :removed_by, + :repair_issue_url, + :value_json_schema, + :name + ].freeze + + METRIC_DEFAULTS = { + product_section: nil, + product_stage: nil, + product_group: nil, + introduced_by_url: 'TODO', + value_type: 'number', + status: 'active', + data_source: 'internal_events', + data_category: 'optional', + performance_indicator_type: [] + }.freeze + + Metric = Struct.new(*NEW_METRIC_FIELDS, *ADDITIONAL_METRIC_FIELDS, :identifier, keyword_init: true) do + def formatted_output + METRIC_DEFAULTS + .merge(to_h.compact) + .merge( + key_path: key_path, + instrumentation_class: instrumentation_class, + events: events) + .slice(*NEW_METRIC_FIELDS) + .transform_keys(&:to_s) + .to_yaml(line_width: 150) + end + + def file_path + File.join( + *[ + ('ee' unless distribution.include?('ce')), + 'config', + 'metrics', + "counts_#{time_frame}", + "#{key}.yml" + ].compact + ) + end + + def key + [ + 'count', + (identifier ? "distinct_#{identifier}_id_from" : 'total'), + actions.join('_and_'), + (time_frame_prefix&.downcase if time_frame != 'all') + ].compact.join('_') + end + + def key_path + self[:key_path] ||= "#{key_path_prefix}.#{key}" + end + + def instrumentation_class + self[:instrumentation_class] ||= identifier ? 'RedisHLLMetric' : 'TotalCountMetric' + end + + def events + self[:events] ||= actions.map do |action| + if identifier + { + 'name' => action, + 'unique' => "#{identifier}.id" + } + else + { 'name' => action } + end + end + end + + def key_path_prefix + case instrumentation_class + when 'RedisHLLMetric' + 'redis_hll_counters' + when 'TotalCountMetric' + 'counts' + end + end + + def actions + options&.dig('events')&.sort || [] + end + + def identifier_prefix + if identifier + "count of unique #{identifier}s" + else + "count of" + end + end + + def time_frame_prefix + case time_frame + when '7d' + 'Weekly' + when '28d' + 'Monthly' + when 'all' + 'Total' + end + end + + def prefix + [time_frame_prefix, identifier_prefix].join(' ') + end + + def technical_description + simple_event_list = actions.join(' or ') + + case identifier + when 'user' + "#{prefix} who triggered #{simple_event_list}" + when 'project', 'namespace' + "#{prefix} where #{simple_event_list} occurred" + else + "#{prefix} #{simple_event_list} occurrences" + end + end + + def bulk_assign(key_value_pairs) + key_value_pairs.each { |key, value| self[key] = value } + end + end +end diff --git a/scripts/internal_events/cli/metric_definer.rb b/scripts/internal_events/cli/metric_definer.rb new file mode 100755 index 00000000000..7688f03200f --- /dev/null +++ b/scripts/internal_events/cli/metric_definer.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +require_relative './helpers' + +module InternalEventsCli + class MetricDefiner + include Helpers + + 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 = [] + end + + def run + type = prompt_for_metric_type + prompt_for_events(type) + + return unless @selected_event_paths.any? + + prompt_for_metrics + + return unless @metrics.any? + + prompt_for_description + defaults = prompt_for_copying_event_properties + prompt_for_product_ownership(defaults) + prompt_for_url(defaults) + prompt_for_tier(defaults) + outcomes = create_metric_files + prompt_for_next_steps(outcomes) + end + + private + + def events + @events ||= events_by_filepath(@selected_event_paths) + end + + def selected_events + @selected_events ||= events.values_at(*@selected_event_paths) + end + + 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 Text::DATABASE_METRIC_NOTICE + cli.say Text::FEEDBACK_NOTICE + end + end + + def prompt_for_metrics + eligible_metrics = get_metric_options(selected_events) + + if eligible_metrics.all? { |metric| metric[:disabled] } + cli.error Text::ALL_METRICS_EXIST_NOTICE + cli.say Text::FEEDBACK_NOTICE + + return + end + + new_page!(3, 9, STEPS) + + @metrics = cli.select('Which metrics do you want to add?', eligible_metrics, **select_opts) + + assign_shared_attrs(:options, :milestone) do + { + options: { 'events' => selected_events.map(&:action) }, + milestone: MILESTONE + } + end + end + + def prompt_for_description + new_page!(4, 9, STEPS) + + cli.say Text::METRIC_DESCRIPTION_INTRO + cli.say selected_event_descriptions.join('') + + base_description = nil + + @metrics.each_with_index do |metric, idx| + multiline_prompt = [ + counter(idx, @metrics.length), + format_prompt("Complete the text:"), + "How would you describe this metric to a non-technical person?", + input_required_text, + "\n\n Technical description: #{metric.technical_description}" + ].compact.join(' ') + + last_line_of_prompt = "\n Finish the description: #{format_info("#{metric.prefix}...")}" + + cli.say("\n") + cli.say(multiline_prompt) + + description_help_message = [ + Text::METRIC_DESCRIPTION_HELP, + multiline_prompt, + "\n\n" + ].join("\n") + + # Reassign base_description so the next metric's default value is their own input + base_description = cli.ask(last_line_of_prompt, value: base_description.to_s) do |q| + q.required true + q.modify :trim + q.messages[:required?] = description_help_message + end + + cli.say("\n") # looks like multiline input, but isn't. Spacer improves clarity. + + metric.description = "#{metric.prefix} #{base_description}" + end + end + + def selected_event_descriptions + @selected_event_descriptions ||= selected_events.map do |event| + " #{event.action} - #{format_selection(event.description)}\n" + end + end + + # Check existing event files for attributes to copy over + def prompt_for_copying_event_properties + defaults = collect_values_for_shared_event_properties + + return {} 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 + + defaults + end + + 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_section] << event.product_section + fields[:product_stage] << event.product_stage + fields[:product_group] << event.product_group + fields[:distribution] << event.distributions&.sort + fields[:tier] << 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 + + def prompt_for_product_ownership(defaults) + assign_shared_attrs(:product_section, :product_stage, :product_group) do + new_page!(6, 9, STEPS) + + prompt_for_group_ownership( + { + product_section: 'Which section owns the metric?', + product_stage: 'Which stage owns the metric?', + product_group: 'Which group owns the metric?' + }, + defaults.slice(:product_section, :product_stage, :product_group) + ) + 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(: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("SAVING FILE #{counter(idx, @metrics.length)}: #{metric.technical_description}\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.' + + cli.say <<~TEXT + #{divider} + #{format_info('Done with metric definitions!')} + + #{outcome} + + #{divider} + TEXT + + cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + menu.choice "View Usage -- look at code examples for #{@selected_event_paths.first}", -> do + UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run + end + menu.choice 'Exit', -> { cli.say Text::FEEDBACK_NOTICE } + end + end + + 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 diff --git a/scripts/internal_events/cli/text.rb b/scripts/internal_events/cli/text.rb new file mode 100755 index 00000000000..4cb1cc23326 --- /dev/null +++ b/scripts/internal_events/cli/text.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +# Blocks of text rendered in CLI +module InternalEventsCli + module Text + extend Helpers + + CLI_INSTRUCTIONS = <<~TEXT.freeze + #{format_info('INSTRUCTIONS:')} + To start tracking usage of a feature... + + 1) Define event (using CLI) + 2) Trigger event (from code) + 3) Define metric (using CLI) + 4) View data in Sisense (after merge & deploy) + + This CLI will help you create the correct defintion files, then provide code examples for instrumentation and testing. + + Learn more: https://docs.gitlab.com/ee/development/internal_analytics/#fundamental-concepts + + TEXT + + # TODO: Remove "NEW TOOL" comment after 3 months + FEEDBACK_NOTICE = format_heading <<~TEXT.chomp + Thanks for using the Internal Events CLI! + + Please reach out with any feedback! + About Internal Events: https://gitlab.com/gitlab-org/analytics-section/analytics-instrumentation/internal/-/issues/687 + About CLI: https://gitlab.com/gitlab-org/gitlab/-/issues/434038 + In Slack: #g_analyze_analytics_instrumentation + + Let us know that you used the CLI! React with 👍 on the feedback issue or post in Slack! + TEXT + + ALTERNATE_RESOURCES_NOTICE = <<~TEXT.freeze + Other resources: + + #{format_warning('Tracking GitLab feature usage from database info:')} + https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html#database-metrics + + #{format_warning('Migrating existing metrics to use Internal Events:')} + https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html + + #{format_warning('Remove an existing metric:')} + https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_lifecycle.html + + #{format_warning('Finding existing usage data for GitLab features:')} + https://metrics.gitlab.com/ (Customize Table > Sisense query) + https://app.periscopedata.com/app/gitlab/1049395/Service-Ping-Exploration-Dashboard + + #{format_warning('Customer wants usage data for their own GitLab instance:')} + https://docs.gitlab.com/ee/user/analytics/ + + #{format_warning('Customer wants usage data for their own products:')} + https://docs.gitlab.com/ee/user/product_analytics/ + TEXT + + EVENT_TRACKING_EXAMPLES = <<~TEXT + Product usage can be tracked in several ways. + + By tracking events: ex) a user changes the assignee on an issue + ex) a user uploads a CI template + ex) a service desk request is received + ex) all stale runners are cleaned up + ex) a user copies code to the clipboard from markdown + ex) a user uploads an issue template OR a user uploads an MR template + + From database data: ex) track whether each gitlab instance allows signups + ex) query how many projects are on each gitlab instance + + TEXT + + EVENT_EXISTENCE_CHECK_INSTRUCTIONS = <<~TEXT.freeze + To determine what to do next, let's figure out if the event is already tracked & usable. + + If you're unsure whether an event exists, you can check the existing defintions. + + #{format_info('FROM GDK')}: Check `config/events/` or `ee/config/events` + #{format_info('FROM BROWSER')}: Check https://metrics.gitlab.com/snowplow + + Find one? Create a new metric for the event. + Otherwise? Create a new event. + + If you find a relevant event that has a different category from 'InternalEventTracking', it can be migrated to + Internal Events. See https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html + + TEXT + + EVENT_DESCRIPTION_INTRO = <<~TEXT.freeze + #{format_info('EVENT DESCRIPTION')} + Include what the event is supposed to track, where, and when. + + The description field helps others find & reuse this event. This will be used by Engineering, Product, Data team, Support -- and also GitLab customers directly. Be specific and explicit. + ex - Debian package published to the registry using a deploy token + ex - Issue confidentiality was changed + + TEXT + + EVENT_DESCRIPTION_HELP = <<~TEXT.freeze + #{format_warning('Required. 10+ words likely, but length may vary.')} + + #{format_info('GOOD EXAMPLES:')} + - Pipeline is created with a CI Template file included in its configuration + - Quick action `/assign @user1` used to assign a single individual to an issuable + - Quick action `/target_branch` used on a Merge Request + - Quick actions `/unlabel` or `/remove_label` used to remove one or more specific labels + - User edits file using the single file editor + - User edits file using the Web IDE + - User removed issue link between issue and incident + - Debian package published to the registry using a deploy token + + #{format_info('GUT CHECK:')} + For your description... + 1. Would two different engineers likely instrument the event from the same code locations? + 2. Would a new GitLab user find where the event is triggered in the product? + 3. Would a GitLab customer understand what the description says? + + + TEXT + + EVENT_ACTION_INTRO = <<~TEXT.freeze + #{format_info('EVENT NAME')} + The event name is a unique identifier used from both a) app code and b) metric definitions. + The name should concisely communicate the same information as the event description. + + ex - change_time_estimate_on_issue + ex - push_package_to_repository + ex - publish_go_module_to_the_registry_from_pipeline + ex - admin_user_comments_on_issue_while_impersonating_blocked_user + + #{format_info('EXPECTED FORMAT:')} #{format_selection('<action>_<target_of_action>_<where/when>')} + + ex) click_save_button_in_issue_description_within_15s_of_page_load + - TARGET: save button + - ACTION: click + - WHERE: in issue description + - WHEN: within 15s of page load + + TEXT + + EVENT_ACTION_HELP = <<~TEXT.freeze + #{format_warning('Required. Must be globally unique. Must use only letters/numbers/underscores.')} + + #{format_info('FAQs:')} + - Q: Present tense or past tense? + A: Prefer present tense! But it's up to you. + - Q: Other event names have prefixes like `i_` or the `g_group_name`. Why? + A: Those are leftovers from legacy naming schemes. Changing the names of old events/metrics can break dashboards, so stability is better than uniformity. + + + TEXT + + EVENT_IDENTIFIERS_INTRO = <<~TEXT.freeze + #{format_info('EVENT CONTEXT')} + Identifies the attributes recorded when the event occurs. Generally, we want to include every identifier available to us when the event is triggered. + + #{format_info('BACKEND')}: Attributes must be specified when the event is triggered + ex) If the backend event was instrumentuser/project/namespace are the identifiers for this backend instrumentation: + + Gitlab::InternalEvents.track_event( + '%s', + user: user, + project: project, + namespace: project.namespace + ) + + #{format_info('FRONTEND')}: Attributes are automatically included from the URL + ex) When a user takes an action on the MR list page, the URL is https://gitlab.com/gitlab-org/gitlab/-/merge_requests + Because this URL is for a project, we know that all of user/project/namespace are available for the event + + #{format_info('NOTE')}: If you're planning to instrument a unique-by-user metric, you should still include project & namespace when possible. This is especially helpful in the data warehouse, where namespace and project can make events relevant for CSM use-cases. + + TEXT + + DATABASE_METRIC_NOTICE = <<~TEXT + + For right now, this script can only define metrics for internal events. + + For more info on instrumenting database-backed metrics, see https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html + TEXT + + ALL_METRICS_EXIST_NOTICE = <<~TEXT + + Looks like the potential metrics for this event either already exist or are unsupported. + + Check out https://metrics.gitlab.com/ for improved event/metric search capabilities. + TEXT + + METRIC_DESCRIPTION_INTRO = <<~TEXT.freeze + #{format_info('METRIC DESCRIPTION')} + Describes which occurrences of an event are tracked in the metric and how they're grouped. + + The description field is critical for helping others find & reuse this event. This will be used by Engineering, Product, Data team, Support -- and also GitLab customers directly. Be specific and explicit. + + #{format_info('GOOD EXAMPLES:')} + - Total count of analytics dashboard list views + - Weekly count of unique users who viewed the analytics dashboard list + - Monthly count of unique projects where the analytics dashboard list was viewed + - Total count of issue updates + + #{format_info('SELECTED EVENT(S):')} + TEXT + + METRIC_DESCRIPTION_HELP = <<~TEXT.freeze + #{format_warning('Required. 10+ words likely, but length may vary.')} + + An event description can often be rearranged to work as a metric description. + + ex) Event description: A merge request was created + Metric description: Total count of merge requests created + Metric description: Weekly count of unqiue users who created merge requests + + Look at the event descriptions above to get ideas! + TEXT + end +end diff --git a/scripts/internal_events/cli/usage_viewer.rb b/scripts/internal_events/cli/usage_viewer.rb new file mode 100755 index 00000000000..6a9be38e25d --- /dev/null +++ b/scripts/internal_events/cli/usage_viewer.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +require_relative './helpers' + +module InternalEventsCli + class UsageViewer + include Helpers + + IDENTIFIER_EXAMPLES = { + %w[namespace project user] => { "namespace" => "project.namespace" }, + %w[namespace user] => { "namespace" => "group" } + }.freeze + + attr_reader :cli, :event + + def initialize(cli, event_path = nil, event = nil) + @cli = cli + @event = event + @selected_event_path = event_path + end + + def run + prompt_for_eligible_event + prompt_for_usage_location + end + + def prompt_for_eligible_event + return if event + + event_details = events_by_filepath + + @selected_event_path = cli.select( + "Show examples for which event?", + get_event_options(event_details), + **select_opts, + **filter_opts + ) + + @event = event_details[@selected_event_path] + end + + def prompt_for_usage_location(default = 'ruby/rails') + choices = [ + { name: 'ruby/rails', value: :rails }, + { name: 'rspec', value: :rspec }, + { name: 'javascript (vue)', value: :vue }, + { name: 'javascript (plain)', value: :js }, + { name: 'vue template', value: :vue_template }, + { name: 'haml', value: :haml }, + { name: 'View examples for a different event', value: :other_event }, + { name: 'Exit', value: :exit } + ] + + usage_location = cli.select( + 'Select a use-case to view examples for:', + choices, + **select_opts, + per_page: 10 + ) do |menu| + menu.enum '.' + menu.default default + end + + case usage_location + when :rails + rails_examples + prompt_for_usage_location('ruby/rails') + when :rspec + rspec_examples + prompt_for_usage_location('rspec') + when :haml + haml_examples + prompt_for_usage_location('haml') + when :js + js_examples + prompt_for_usage_location('javascript (plain)') + when :vue + vue_examples + prompt_for_usage_location('javascript (vue)') + when :vue_template + vue_template_examples + prompt_for_usage_location('vue template') + when :other_event + self.class.new(cli).run + when :exit + cli.say(Text::FEEDBACK_NOTICE) + end + end + + def rails_examples + args = Array(event['identifiers']).map do |identifier| + " #{identifier}: #{identifier_examples[identifier]}" + end + action = args.any? ? "\n '#{event['action']}',\n" : "'#{event['action']}'" + + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RAILS')} + + Gitlab::InternalEvents.track_event(#{action}#{args.join(",\n")}#{"\n" unless args.empty?}) + + #{divider} + TEXT + end + + def rspec_examples + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RSPEC')} + + it_behaves_like 'internal event tracking' do + let(:event) { '#{event['action']}' } + #{ + Array(event['identifiers']).map do |identifier| + " let(:#{identifier}) { #{identifier_examples[identifier]} }\n" + end.join('') + }end + + #{divider} + TEXT + end + + def identifier_examples + event['identifiers'] + .to_h { |identifier| [identifier, identifier] } + .merge(IDENTIFIER_EXAMPLES[event['identifiers'].sort] || {}) + end + + def haml_examples + cli.say <<~TEXT + #{divider} + #{format_help('# HAML -- ON-CLICK')} + + .gl-display-inline-block{ #{format_warning("data: { event_tracking: '#{event['action']}' }")} } + = _('Important Text') + + #{divider} + #{format_help('# HAML -- COMPONENT ON-CLICK')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking: '#{event['action']}' }")} }) + + #{divider} + #{format_help('# HAML -- COMPONENT ON-LOAD')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, event_tracking: '#{event['action']}' }")} }) + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def vue_template_examples + cli.say <<~TEXT + #{divider} + #{format_help('// VUE TEMPLATE -- ON-CLICK')} + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button #{format_warning("data-event-tracking=\"#{event['action']}\"")}> + Click Me + </gl-button> + </template> + + #{divider} + #{format_help('// VUE TEMPLATE -- ON-LOAD')} + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button #{format_warning("data-event-tracking-load=\"#{event['action']}\"")}> + Click Me + </gl-button> + </template> + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def js_examples + cli.say <<~TEXT + #{divider} + #{format_help('// FRONTEND -- RAW JAVASCRIPT')} + + #{format_warning("import { InternalEvents } from '~/tracking';")} + + export const performAction = () => { + #{format_warning("InternalEvents.trackEvent('#{event['action']}');")} + + return true; + }; + + #{divider} + TEXT + + # https://docs.snowplow.io/docs/understanding-your-pipeline/schemas/ + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def vue_examples + cli.say <<~TEXT + #{divider} + #{format_help('// VUE')} + + <script> + #{format_warning("import { InternalEvents } from '~/tracking';")} + import { GlButton } from '@gitlab/ui'; + + #{format_warning('const trackingMixin = InternalEvents.mixin();')} + + export default { + #{format_warning('mixins: [trackingMixin]')}, + components: { GlButton }, + methods: { + performAction() { + #{format_warning("this.trackEvent('#{event['action']}');")} + }, + }, + }; + </script> + + <template> + <gl-button @click=performAction>Click Me</gl-button> + </template> + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + end +end |