Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-12-16 03:10:37 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-12-16 03:10:37 +0300
commit84025108bca604c1428d2cf6a6b69616ee90956c (patch)
tree7c0fd8ec032c12bee563daeee6c721e2d7f7d44f /scripts
parent73778b9c53d13a2e06a693c30073366deedead8f (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/internal_events/cli.rb135
-rwxr-xr-xscripts/internal_events/cli/event.rb55
-rwxr-xr-xscripts/internal_events/cli/event_definer.rb180
-rwxr-xr-xscripts/internal_events/cli/helpers.rb27
-rwxr-xr-xscripts/internal_events/cli/helpers/cli_inputs.rb60
-rwxr-xr-xscripts/internal_events/cli/helpers/event_options.rb80
-rwxr-xr-xscripts/internal_events/cli/helpers/files.rb36
-rwxr-xr-xscripts/internal_events/cli/helpers/formatting.rb89
-rwxr-xr-xscripts/internal_events/cli/helpers/group_ownership.rb71
-rwxr-xr-xscripts/internal_events/cli/helpers/metric_options.rb124
-rwxr-xr-xscripts/internal_events/cli/metric.rb155
-rwxr-xr-xscripts/internal_events/cli/metric_definer.rb310
-rwxr-xr-xscripts/internal_events/cli/text.rb216
-rwxr-xr-xscripts/internal_events/cli/usage_viewer.rb247
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