diff --git a/doc/development/internal_analytics/internal_event_instrumentation/migration.md b/doc/development/internal_analytics/internal_event_instrumentation/migration.md index 32a68011e5a6c42ebfa828dbfd45299b37aaea8a..2ef439e21e977cbcc5539652df6993c9bc719f28 100644 --- a/doc/development/internal_analytics/internal_event_instrumentation/migration.md +++ b/doc/development/internal_analytics/internal_event_instrumentation/migration.md @@ -42,17 +42,14 @@ Gitlab::InternalEvents.track_event('ci_templates_unique', namespace: namespace, In addition, you have to create definitions for the metrics that you would like to track. -To generate metric definitions, you can use the generator like this: +To generate metric definitions, you can use the generator: ```shell -bin/rails g gitlab:analytics:internal_events \ - --time_frames=7d 28d\ - --group=project_management \ - --event=ci_templates_unique \ - --unique=user.id \ - --mr=https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121544 +ruby scripts/internal_events/cli.rb ``` +The generator walks you through the required inputs step-by-step. + ### Frontend If you are using the `Tracking` mixin in the Vue component, you can replace it with the `InternalEvents` mixin. diff --git a/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md b/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md index 34be2080e9537650d771df3260a80fb574c4ee91..6f48f83e7ca320b14326a15dfd3aa85a2e4874dc 100644 --- a/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md +++ b/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md @@ -17,30 +17,13 @@ In order to instrument your code with Internal Events Tracking you need to do th ## Defining event and metrics -<div class="video-fallback"> - See the video about <a href="https://www.youtube.com/watch?v=QICKWznLyy0">adding events and metrics using the generator</a> -</div> -<figure class="video_container"> - <iframe src="https://www.youtube-nocookie.com/embed/QICKWznLyy0" frameborder="0" allowfullscreen="true"> </iframe> -</figure> - -To create an event and metric definitions you can use the `internal_events` generator. - -This example creates an event definition for an event called `project_created` and two metric definitions, which are aggregated every 7 and 28 days. +To create event and/or metric definitions, use the `internal_events` generator from the `gitlab` directory: ```shell -bundle exec rails generate gitlab:analytics:internal_events \ ---time_frames=7d 28d \ ---group=project_management \ ---event=project_created \ ---unique=user.id \ ---mr=https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121544 +ruby scripts/internal_events/cli.rb ``` -Where: - -- `time_frames`: Valid options are `7d` and `28d` if you provide a `unique` value and `7d`, `28d` and `all` for metrics without `unique`. -- `unique`: Valid options are `user.id`, `project.id`, and `namespace.id`, as they are logged as part of the standard context. We [are actively working](https://gitlab.com/gitlab-org/gitlab/-/issues/411255) on a way to define uniqueness on arbitrary properties sent with the event, such as `merge_request.id`. +This CLI will help you create the correct defintion files based on your specific use-case, then provide code examples for instrumentation and testing. ## Trigger events @@ -52,11 +35,11 @@ To trigger an event, call the `Gitlab::InternalEvents.track_event` method with t ```ruby Gitlab::InternalEvents.track_event( - "i_code_review_user_apply_suggestion", - user: user, - namespace: namespace, - project: project - ) + "i_code_review_user_apply_suggestion", + user: user, + namespace: namespace, + project: project +) ``` This method automatically increments all RedisHLL metrics relating to the event `i_code_review_user_apply_suggestion`, and sends a corresponding Snowplow event with all named arguments and standard context (SaaS only). diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb new file mode 100755 index 0000000000000000000000000000000000000000..6cc9f599608e989aeb539caaf12d163a5f32e338 --- /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 0000000000000000000000000000000000000000..d98aa8a6bd156a1c12921997370d168a0a35f5a5 --- /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 0000000000000000000000000000000000000000..e029f0e7cf64a4fd1df4c7401179ee9209ee2265 --- /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 0000000000000000000000000000000000000000..95672325652e7c91f81807466639672dba4aebab --- /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 0000000000000000000000000000000000000000..106d854d0b32e326e199787386ff3f17f039e540 --- /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 0000000000000000000000000000000000000000..f53127798aa06912c24bbeb1dddc352f1a77ef02 --- /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 0000000000000000000000000000000000000000..b613350353f469e36459a84c58785fb84bdede39 --- /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 0000000000000000000000000000000000000000..87be585c7397bc994fd41b48b9f1b75cb2db56a6 --- /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 0000000000000000000000000000000000000000..9846f0ca2f507ffb96aff79ac7a3d74fc9727cc9 --- /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 0000000000000000000000000000000000000000..01512115e05d7acf60c4de315cfdb031dfb68b15 --- /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 0000000000000000000000000000000000000000..63961d29810278e55fcc3479b7509322db0052fe --- /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 0000000000000000000000000000000000000000..7688f03200fc3961a6c1c6f9b3e12d8bb17d92a7 --- /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 0000000000000000000000000000000000000000..4cb1cc233267843beaf4d9f3f987698e8dc4d225 --- /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 0000000000000000000000000000000000000000..6a9be38e25d780fa170bedf0eea894af4017b2a1 --- /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 diff --git a/spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml b/spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml new file mode 100644 index 0000000000000000000000000000000000000000..07f606fbe33c15d3c012fd6ce092556e5bf843fd --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml @@ -0,0 +1,14 @@ +--- +description: Internal Event CLI is opened +category: InternalEventTracking +action: internal_events_cli_opened +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ee +tiers: +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml b/spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml new file mode 100644 index 0000000000000000000000000000000000000000..5050953920dec4b1297b2c7b62d03967dad43b34 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml @@ -0,0 +1,20 @@ +--- +description: Engineer uses Internal Event CLI to define a new event +category: InternalEventTracking +action: internal_events_cli_used +identifiers: +- project +- namespace +- user +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml b/spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..c0ccbc03af762b9d50f0ffbc4be73c85392fda90 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml @@ -0,0 +1,20 @@ +--- +description: random event string +category: InternalEventTracking +action: random_name +identifiers: +- project +- namespace +- user +product_section: core_platform +product_stage: manage +product_group: import_and_integrate +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml b/spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml new file mode 100644 index 0000000000000000000000000000000000000000..4e2e77e0c5c9d020d1fe94c75a8812a2a52a78f8 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml @@ -0,0 +1,20 @@ +--- +description: Engineer closes Internal Event CLI +category: InternalEventTracking +action: internal_events_cli_closed +identifiers: +- project +- namespace +- user +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/metrics/ee_total_28d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/ee_total_28d_single_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..ba56d7828714fe95dd4b0681fd7d5556b743694a --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/ee_total_28d_single_event.yml @@ -0,0 +1,25 @@ +--- +key_path: counts.count_total_internal_events_cli_used_monthly +description: Monthly count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ee +tier: +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..e6bdcb9d2ae34dc85992ee377637ef834968991e --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml @@ -0,0 +1,25 @@ +--- +key_path: counts.count_total_internal_events_cli_used_weekly +description: Weekly count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ee +tier: +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..b1bf89dc09567ab6fd225e2757c7929e096901e0 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml @@ -0,0 +1,25 @@ +--- +key_path: counts.count_total_internal_events_cli_used +description: Total count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: all +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ee +tier: +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml new file mode 100644 index 0000000000000000000000000000000000000000..8476cb8561b9b78bd11a933f7bff58c51510f2a4 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_random_name_monthly +description: Monthly count of unique users random metric string +product_section: core_platform +product_stage: manage +product_group: import_and_integrate +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - random_name +events: +- name: random_name + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml new file mode 100644 index 0000000000000000000000000000000000000000..b4cc2fc8b55aabd3ec460d41d8a3ce5ebbdb55a0 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_random_name_weekly +description: Weekly count of unique users random metric string +product_section: core_platform +product_stage: manage +product_group: import_and_integrate +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - random_name +events: +- name: random_name + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml b/spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml new file mode 100644 index 0000000000000000000000000000000000000000..754702c8c7497df90d555a9a31a5bec110db1dc3 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml @@ -0,0 +1,31 @@ +--- +key_path: redis_hll_counters.count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly +description: Monthly count of unique projects where a defition file was created with the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_closed + - internal_events_cli_used +events: +- name: internal_events_cli_closed + unique: project.id +- name: internal_events_cli_used + unique: project.id diff --git a/spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml b/spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml new file mode 100644 index 0000000000000000000000000000000000000000..95f429e9b40e78fc332edde2f402b4bb7c23aa69 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml @@ -0,0 +1,31 @@ +--- +key_path: redis_hll_counters.count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly +description: Weekly count of unique projects where a defition file was created with the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_closed + - internal_events_cli_used +events: +- name: internal_events_cli_closed + unique: project.id +- name: internal_events_cli_used + unique: project.id diff --git a/spec/fixtures/scripts/internal_events/metrics/total_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/total_single_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..5bdb4c45a5238f36ad8d11c9f22b45fccb5e175a --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/total_single_event.yml @@ -0,0 +1,27 @@ +--- +key_path: counts.count_total_internal_events_cli_used +description: Total count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: all +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..b176b23b46af601e046c89dd64bb845794143849 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_internal_events_cli_used_monthly +description: Monthly count of unique users who defined an internal event using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml new file mode 100644 index 0000000000000000000000000000000000000000..8a0fca2cbdc88164e2a069b7a941bca6f8b39525 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_internal_events_cli_used_weekly +description: Weekly count of unique users who defined an internal event using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/new_events.yml b/spec/fixtures/scripts/internal_events/new_events.yml new file mode 100644 index 0000000000000000000000000000000000000000..6f39fc5e93cefadc931fe920cf72a73228d1ff7f --- /dev/null +++ b/spec/fixtures/scripts/internal_events/new_events.yml @@ -0,0 +1,183 @@ +- description: Creates a new event and flows directly into metric creation + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select: [free, premium, ultimate] + - "y\n" # Create file + - "1\n" # Select: Create Metric --- define a new metric + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: Requires description & action before continuing + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "\n" # Attempt to skip writing description --> should get help message + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "\n" # Attempt to skip naming action --> should get help message + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select [free, premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + +- description: Does not allow existing events for action + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer closes Internal Event CLI\n" # Submit description + - "internal_events_cli_used\n" # Submit already-existing action name + - "internal_events_cli_closed\n" # Submit alterred action name + - "1\n" # Select [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select [free, premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_closed.yml + content: spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml + +- description: Creates a new event without identifiers + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Internal Event CLI is opened\n" # Submit description + - "internal_events_cli_opened\n" # Submit action name + - "6\n" # Select: None + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "2\n" # Select [premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: ee/config/events/internal_events_cli_opened.yml + content: spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml + +- description: Smashing the keyboard/return creates an event & metrics with the most common attributes, then shows usage + inputs: + keystrokes: + - "\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "random event string\n" # Submit keyboard-smashing description + - "random_name\n" # Submit keyboard-smashing action name + - "\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "\n" # Select core_platform:manage:import_and_integrate + - "\n" # Select [free, premium, ultimate] + - "\n" # Create file + - "\n" # Select: Create Metric --- define a new metric + - "\n" # Select: Weekly/Monthly count of unique users + - "random metric string\n" # Submit keyboard-smashing description + - "\n" # Accept weekly description for monthly + - "\n" # Select: Copy & continue + - "\n" # Skip URL + - "\n" # Create file + - "\n" # Create file + - "\n" # Select: View Usage -- look at code examples + - "\n" # Select: Ruby/Rails + - "8\n" # Exit + outputs: + files: + - path: config/events/random_name.yml + content: spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml + - path: config/metrics/counts_28d/count_distinct_user_id_from_random_name_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml + - path: config/metrics/counts_7d/count_distinct_user_id_from_random_name_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml + +- description: Creates an event after helping the user figure out next steps + inputs: + keystrokes: + - "4\n" # Enum-select: ...am I in the right place? + - "y\n" # Yes --> Are you trying to track customer usage of a GitLab feature? + - "y\n" # Yes --> Can usage for the feature be measured by tracking a specific user action? + - "n\n" # No --> Is the event already tracked? + - "y\n" # Yes --> Ready to start? + - "Internal Event CLI is opened\n" # Submit description + - "internal_events_cli_opened\n" # Submit action name + - "6\n" # Select: None + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "2\n" # Select [premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: ee/config/events/internal_events_cli_opened.yml + content: spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml + +- description: Creates a new event and flows directly into usage examples + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select: [free, premium, ultimate] + - "y\n" # Create file + - "2\n" # Select: View Usage + - "8\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + +- description: Skips event creation, then saves event & flows directly into metric creation + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select: [free, premium, ultimate] + - "n\n" # Create file + - "1\n" # Select: Save event & create Metric --- define a new metric + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml diff --git a/spec/fixtures/scripts/internal_events/new_metrics.yml b/spec/fixtures/scripts/internal_events/new_metrics.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a207ee84f407a56e033d1aa64c7ee98bb85d266 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/new_metrics.yml @@ -0,0 +1,196 @@ +- description: Create a weekly/monthly metric for a single event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\n" # Select: Weekly count of unique users + - "who defined an internal event using the CLI\n" # Input description + - "\n" # Submit weekly description for monthly + - "1\n" # Enum-select: Copy & continue + - "y\n" # Create file + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_28d/count_distinct_user_id_from_internal_events_cli_used_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml + - path: config/metrics/counts_7d/count_distinct_user_id_from_internal_events_cli_used_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml + +- description: Create a weekly/monthly metric for a multiple events, but select only one event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "2\n" # Enum-select: Multiple events -- count occurrences of several separate events or interactions + - 'internal_events_cli_used' # Filters to this event + - " " # Multi-select: config/events/internal_events_cli_used.yml + - "\n" # Submit selections + - "\n" # Select: Weekly count of unique projects + - "who defined an internal event using the CLI\n" # Input description + - "\n" # Submit weekly description for monthly + - "1\n" # Enum-select: Copy & continue + - "y\n" # Create file + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_28d/count_distinct_user_id_from_internal_events_cli_used_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml + - path: config/metrics/counts_7d/count_distinct_user_id_from_internal_events_cli_used_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml + +- description: Create a weekly/monthly metric for multiple events + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/events/internal_events_cli_closed.yml + content: spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "2\n" # Enum-select: Multiple events -- count occurrences of several separate events or interactions + - 'internal_events_cli' # Filters to the relevant events + - ' ' # Multi-select: internal_events_cli_closed + - "\e[B" # Arrow down to: internal_events_cli_used + - ' ' # Multi-select: internal_events_cli_used + - "\n" # Submit selections + - "\e[B" # Arrow down to: Weekly count of unique projects + - "\n" # Select: Weekly count of unique projects + - "where a defition file was created with the CLI\n" # Input description + - "\n" # Submit weekly description for monthly + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_28d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml + - path: config/metrics/counts_7d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml + +- description: Create an all time total metric for a single event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: Try to create a database metric + inputs: + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "3\n" # Enum-select: Database -- record value of a particular field or count of database rows + +- description: Create an all time total metric for a single event, and confirm each attribute copied from event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "2\n" # Enum-select: Modify attributes + - "\n" # Accept group/section/stage from event definition + - "\n" # Accept URL from event definition + - "2\n" # Override tier -> Select: [premium, ultimate] + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: ee/config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml + +- description: Create a metric after helping the user figure out next steps + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "4\n" # Enum-select: ...am I in the right place? + - "y\n" # Yes --> Are you trying to track customer usage of a GitLab feature? + - "y\n" # Yes --> Can usage for the feature be measured by tracking a specific user action? + - "y\n" # Yes --> Is the event already tracked? + - "y\n" # Yes --> Ready to start? + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: User overwrites metric that already exists + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml # wrong content + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Overwrite file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: User opts not to overwrite metric that already exists + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml # wrong content + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "n\n" # Don't overwrite file + - "2\n" # Exit diff --git a/spec/fixtures/scripts/internal_events/stages.yml b/spec/fixtures/scripts/internal_events/stages.yml new file mode 100644 index 0000000000000000000000000000000000000000..d5db9dcbe6d308326208ab95ad87d86c96cd2809 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/stages.yml @@ -0,0 +1,78 @@ +stages: + manage: + display_name: "Manage" + section: core_platform + groups: + import_and_integrate: + name: Import and Integrate + foundations: + name: Foundations + + plan: + display_name: "Plan" + section: dev + groups: + project_management: + name: Project Management + product_planning: + name: Product Planning + knowledge: + name: Knowledge + optimize: + name: Optimize + + create: + display_name: "Create" + section: dev + slack: + channel: s_create + groups: + source_code: + name: Source Code + code_review: + name: Code Review + ide: + name: IDE + editor_extensions: + name: Editor Extensions + code_creation: + name: Code Creation + + verify: + display_name: "Verify" + section: ci + slack: + channel: s_verify + groups: + pipeline_execution: + name: "Pipeline Execution" + pipeline_authoring: + name: "Pipeline Authoring" + runner: + name: "Runner" + runner_saas: + name: "Runner SaaS" + pipeline_security: + name: "Pipeline Security" + + package: + display_name: "Package" + section: ci + slack: + channel: s_package + groups: + package_registry: + name: Package Registry + container_registry: + name: Container Registry + + monitor: + display_name: Monitor + section: analytics + groups: + analytics_instrumentation: + name: Analytics Instrumentation + product_analytics: + name: Product Analytics + observability: + name: "Observability" diff --git a/spec/scripts/internal_events/cli_spec.rb b/spec/scripts/internal_events/cli_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d84a4498fe8f6d7f2898fa24d7814d31a6bd1d41 --- /dev/null +++ b/spec/scripts/internal_events/cli_spec.rb @@ -0,0 +1,866 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'tty/prompt/test' +require_relative '../../../scripts/internal_events/cli' + +RSpec.describe Cli, feature_category: :service_ping do + let(:prompt) { TTY::Prompt::Test.new } + let(:files_to_cleanup) { [] } + + let(:event1_filepath) { 'config/events/internal_events_cli_used.yml' } + let(:event1_content) { internal_event_fixture('events/event_with_identifiers.yml') } + let(:event2_filepath) { 'ee/config/events/internal_events_cli_opened.yml' } + let(:event2_content) { internal_event_fixture('events/ee_event_without_identifiers.yml') } + let(:event3_filepath) { 'config/events/internal_events_cli_closed.yml' } + let(:event3_content) { internal_event_fixture('events/secondary_event_with_identifiers.yml') } + + before do + stub_milestone('16.6') + collect_file_writes(files_to_cleanup) + stub_product_groups(File.read('spec/fixtures/scripts/internal_events/stages.yml')) + stub_helper(:fetch_window_size, '50') + end + + after do + delete_files(files_to_cleanup) + end + + shared_examples 'creates the right defintion files' do |description, test_case = {}| + # For expected keystroke mapping, see https://github.com/piotrmurach/tty-reader/blob/master/lib/tty/reader/keys.rb + let(:keystrokes) { test_case.dig('inputs', 'keystrokes') || [] } + let(:input_files) { test_case.dig('inputs', 'files') || [] } + let(:output_files) { test_case.dig('outputs', 'files') || [] } + + subject { run_with_verbose_timeout } + + it "in scenario: #{description}" do + delete_old_ouputs # just in case + prep_input_files + queue_cli_inputs(keystrokes) + expect_file_creation + + subject + end + + private + + def delete_old_ouputs + [input_files, output_files].flatten.each do |file_info| + FileUtils.rm_f(Rails.root.join(file_info['path'])) + end + end + + def prep_input_files + input_files.each do |file| + File.write( + Rails.root.join(file['path']), + File.read(Rails.root.join(file['content'])) + ) + end + end + + def expect_file_creation + if output_files.any? + output_files.each do |file| + expect(File).to receive(:write).with(file['path'], File.read(file['content'])) + end + else + expect(File).not_to receive(:write) + end + end + end + + context 'when creating new events' do + YAML.safe_load(File.read('spec/fixtures/scripts/internal_events/new_events.yml')).each do |test_case| + it_behaves_like 'creates the right defintion files', test_case['description'], test_case + end + end + + context 'when creating new metrics' do + YAML.safe_load(File.read('spec/fixtures/scripts/internal_events/new_metrics.yml')).each do |test_case| + it_behaves_like 'creates the right defintion files', test_case['description'], test_case + end + + context 'when creating a metric from multiple events' do + let(:events) do + [{ + action: '00_event1', category: 'InternalEventTracking', + product_section: 'dev', product_stage: 'plan', product_group: 'optimize' + }, { + action: '00_event2', category: 'InternalEventTracking', + product_section: 'dev', product_stage: 'create', product_group: 'ide' + }, { + action: '00_event3', category: 'InternalEventTracking', + product_section: 'dev', product_stage: 'create', product_group: 'source_code' + }] + end + + before do + events.each do |event| + File.write("config/events/#{event[:action]}.yml", event.transform_keys(&:to_s).to_yaml) + end + end + + it 'filters the product group options based on common section' do + # Select 00_event1 & #00_event2 + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions + " ", # Multi-select: __event1 + "\e[B", # Arrow down to: __event2 + " ", # Multi-select: __event2 + "\n", # Submit selections + "\n", # Select: Weekly/Monthly count of unique users + "aggregate metric description\n", # Submit description + "\n", # Accept description for weekly + "\n" # Copy & continue + ]) + + run_with_timeout + + # Filter down to "dev" options + expect(plain_last_lines(9)).to eq <<~TEXT.chomp + ‣ dev:plan:project_management + dev:plan:product_planning + dev:plan:knowledge + dev:plan:optimize + dev:create:source_code + dev:create:code_review + dev:create:ide + dev:create:editor_extensions + dev:create:code_creation + TEXT + end + + it 'filters the product group options based on common section & stage' do + # Select 00_event2 & #00_event3 + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions + "\e[B", # Arrow down to: __event2 + " ", # Multi-select: __event2 + "\e[B", # Arrow down to: __event3 + " ", # Multi-select: __event3 + "\n", # Submit selections + "\n", # Select: Weekly/Monthly count of unique users + "aggregate metric description\n", # Submit description + "\n", # Accept description for weekly + "\n" # Copy & continue + ]) + + run_with_timeout + + # Filter down to "dev:create" options + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ‣ dev:create:source_code + dev:create:code_review + dev:create:ide + dev:create:editor_extensions + dev:create:code_creation + TEXT + end + end + + context 'when product group for event no longer exists' do + let(:event) do + { + action: '00_event1', category: 'InternalEventTracking', + product_section: 'other', product_stage: 'other', product_group: 'other' + } + end + + before do + File.write("config/events/#{event[:action]}.yml", event.transform_keys(&:to_s).to_yaml) + end + + it 'prompts user to select another group' do + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + "\n", # Select: 00__event1 + "\n", # Select: Weekly/Monthly count of unique users + "aggregate metric description\n", # Submit description + "\n", # Accept description for weekly + "2\n" # Modify attributes + ]) + + run_with_timeout + + # Filter down to "dev" options + expect(plain_last_lines(50)).to include 'Select one: Which group owns the metric?' + end + end + + context 'when creating a metric for an event which has metrics' do + before do + File.write(event1_filepath, File.read(event1_content)) + end + + it 'shows all metrics options' do + select_event_from_list + + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] + Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] + Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] + Monthly/Weekly count of [internal_events_cli_used occurrences] + Total count of [internal_events_cli_used occurrences] + TEXT + end + + context 'with an existing weekly metric' do + before do + File.write( + 'ee/config/metrics/counts_7d/count_total_internal_events_cli_used_weekly.yml', + File.read('spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml') + ) + end + + it 'partially filters metric options' do + select_event_from_list + + expect(plain_last_lines(6)).to eq <<~TEXT.chomp + ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] + Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] + Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] + Monthly count of [internal_events_cli_used occurrences] + ✘ Weekly count of [internal_events_cli_used occurrences] (already defined) + Total count of [internal_events_cli_used occurrences] + TEXT + end + end + + context 'with an existing total metric' do + before do + File.write( + 'ee/config/metrics/counts_all/count_total_internal_events_cli_used.yml', + File.read('spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml') + ) + end + + it 'filters whole metric options' do + select_event_from_list + + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] + Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] + Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] + Monthly/Weekly count of [internal_events_cli_used occurrences] + ✘ Total count of [internal_events_cli_used occurrences] (already defined) + TEXT + end + end + + private + + def select_event_from_list + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + 'internal_events_cli_used', # Filters to this event + "\n" # Select: config/events/internal_events_cli_used.yml + ]) + + run_with_timeout + end + end + + context 'when event excludes identifiers' do + before do + File.write(event2_filepath, File.read(event2_content)) + end + + it 'filters unavailable identifiers' do + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + 'internal_events_cli_opened', # Filters to this event + "\n" # Select: config/events/internal_events_cli_opened.yml + ]) + + run_with_timeout + + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ✘ Monthly/Weekly count of unique users [who triggered internal_events_cli_opened] (user unavailable) + ✘ Monthly/Weekly count of unique projects [where internal_events_cli_opened occurred] (project unavailable) + ✘ Monthly/Weekly count of unique namespaces [where internal_events_cli_opened occurred] (namespace unavailable) + ‣ Monthly/Weekly count of [internal_events_cli_opened occurrences] + Total count of [internal_events_cli_opened occurrences] + TEXT + end + end + + context 'when all metrics already exist' do + let(:event) { { action: '00_event1', category: 'InternalEventTracking' } } + let(:metric) { { options: { 'events' => ['00_event1'] }, events: [{ 'name' => '00_event1' }] } } + + let(:files) do + [ + ['config/events/00_event1.yml', event], + ['config/metrics/counts_all/count_total_00_event1.yml', metric.merge(time_frame: 'all')], + ['config/metrics/counts_7d/count_total_00_event1_weekly.yml', metric.merge(time_frame: '7d')], + ['config/metrics/counts_28d/count_total_00_event1_monthly.yml', metric.merge(time_frame: '28d')] + ] + end + + before do + files.each do |path, content| + File.write(path, content.transform_keys(&:to_s).to_yaml) + end + end + + it 'exits the script and directs user to search for existing metrics' do + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + '00_event1', # Filters to this event + "\n" # Select: config/events/00_event1.yml + ]) + + run_with_timeout + + expect(plain_last_lines(15)).to include 'Looks like the potential metrics for this event ' \ + 'either already exist or are unsupported.' + end + end + end + + context 'when showing usage examples' do + let(:expected_example_prompt) do + <<~TEXT.chomp + Select one: Select a use-case to view examples for: (Press ↑/↓ arrow or 1-8 number to move and Enter to select) + ‣ 1. ruby/rails + 2. rspec + 3. javascript (vue) + 4. javascript (plain) + 5. vue template + 6. haml + 7. View examples for a different event + 8. Exit + TEXT + end + + context 'for an event with identifiers' do + let(:expected_rails_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event( + 'internal_events_cli_used', + project: project, + namespace: project.namespace, + user: user + ) + + -------------------------------------------------- + TEXT + end + + let(:expected_rspec_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RSPEC + + it_behaves_like 'internal event tracking' do + let(:event) { 'internal_events_cli_used' } + let(:project) { project } + let(:namespace) { project.namespace } + let(:user) { user } + end + + -------------------------------------------------- + TEXT + end + + before do + File.write(event1_filepath, File.read(event1_content)) + end + + it 'shows backend examples' do + queue_cli_inputs([ + "3\n", # Enum-select: View Usage -- look at code examples for an existing event + 'internal_events_cli_used', # Filters to this event + "\n", # Select: config/events/internal_events_cli_used.yml + "\n", # Select: ruby/rails + "\e[B", # Arrow down to: rspec + "\n", # Select: rspec + "8\n" # Exit + ]) + + run_with_timeout + + output = plain_last_lines(100) + + expect(output).to include expected_example_prompt + expect(output).to include expected_rails_example + expect(output).to include expected_rspec_example + end + end + + context 'for an event without identifiers' do + let(:expected_rails_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event('internal_events_cli_opened') + + -------------------------------------------------- + TEXT + end + + let(:expected_rspec_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RSPEC + + it_behaves_like 'internal event tracking' do + let(:event) { 'internal_events_cli_opened' } + end + + -------------------------------------------------- + TEXT + end + + let(:expected_vue_example) do + <<~TEXT.chomp + -------------------------------------------------- + // VUE + + <script> + import { InternalEvents } from '~/tracking'; + import { GlButton } from '@gitlab/ui'; + + const trackingMixin = InternalEvents.mixin(); + + export default { + mixins: [trackingMixin], + components: { GlButton }, + methods: { + performAction() { + this.trackEvent('internal_events_cli_opened'); + }, + }, + }; + </script> + + <template> + <gl-button @click=performAction>Click Me</gl-button> + </template> + + -------------------------------------------------- + TEXT + end + + let(:expected_js_example) do + <<~TEXT.chomp + -------------------------------------------------- + // FRONTEND -- RAW JAVASCRIPT + + import { InternalEvents } from '~/tracking'; + + export const performAction = () => { + InternalEvents.trackEvent('internal_events_cli_opened'); + + return true; + }; + + -------------------------------------------------- + TEXT + end + + let(:expected_vue_template_example) do + <<~TEXT.chomp + -------------------------------------------------- + // VUE TEMPLATE -- ON-CLICK + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button data-event-tracking="internal_events_cli_opened"> + Click Me + </gl-button> + </template> + + -------------------------------------------------- + // VUE TEMPLATE -- ON-LOAD + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button data-event-tracking-load="internal_events_cli_opened"> + Click Me + </gl-button> + </template> + + -------------------------------------------------- + TEXT + end + + let(:expected_haml_example) do + <<~TEXT.chomp + -------------------------------------------------- + # HAML -- ON-CLICK + + .gl-display-inline-block{ data: { event_tracking: 'internal_events_cli_opened' } } + = _('Important Text') + + -------------------------------------------------- + # HAML -- COMPONENT ON-CLICK + + = render Pajamas::ButtonComponent.new(button_options: { data: { event_tracking: 'internal_events_cli_opened' } }) + + -------------------------------------------------- + # HAML -- COMPONENT ON-LOAD + + = render Pajamas::ButtonComponent.new(button_options: { data: { event_tracking_load: true, event_tracking: 'internal_events_cli_opened' } }) + + -------------------------------------------------- + TEXT + end + + before do + File.write(event2_filepath, File.read(event2_content)) + end + + it 'shows all examples' do + queue_cli_inputs([ + "3\n", # Enum-select: View Usage -- look at code examples for an existing event + 'internal_events_cli_opened', # Filters to this event + "\n", # Select: config/events/internal_events_cli_used.yml + "\n", # Select: ruby/rails + "\e[B", # Arrow down to: rspec + "\n", # Select: rspec + "\e[B", # Arrow down to: js vue + "\n", # Select: js vue + "\e[B", # Arrow down to: js plain + "\n", # Select: js plain + "\e[B", # Arrow down to: vue template + "\n", # Select: vue template + "\e[B", # Arrow down to: haml + "\n", # Select: haml + "8\n" # Exit + ]) + + run_with_timeout + + output = plain_last_lines(1000) + + expect(output).to include expected_example_prompt + expect(output).to include expected_rails_example + expect(output).to include expected_rspec_example + expect(output).to include expected_vue_example + expect(output).to include expected_js_example + expect(output).to include expected_vue_template_example + expect(output).to include expected_haml_example + end + end + + context 'when viewing examples for multiple events' do + let(:expected_event1_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event( + 'internal_events_cli_used', + project: project, + namespace: project.namespace, + user: user + ) + + -------------------------------------------------- + TEXT + end + + let(:expected_event2_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event('internal_events_cli_opened') + + -------------------------------------------------- + TEXT + end + + before do + File.write(event1_filepath, File.read(event1_content)) + File.write(event2_filepath, File.read(event2_content)) + end + + it 'switches between events gracefully' do + queue_cli_inputs([ + "3\n", # Enum-select: View Usage -- look at code examples for an existing event + 'internal_events_cli_used', # Filters to this event + "\n", # Select: config/events/internal_events_cli_used.yml + "\n", # Select: ruby/rails + "7\n", # Select: View examples for a different event + 'internal_events_cli_opened', # Filters to this event + "\n", # Select: config/events/internal_events_cli_opened.yml + "\n", # Select: ruby/rails + "8\n" # Exit + ]) + + run_with_timeout + + output = plain_last_lines(300) + + expect(output).to include expected_example_prompt + expect(output).to include expected_event1_example + expect(output).to include expected_event2_example + end + end + end + + context 'when offline' do + before do + stub_product_groups(nil) + end + + it_behaves_like 'creates the right defintion files', + 'Creates a new event with product stage/section/group input manually' do + let(:keystrokes) do + [ + "1\n", # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + "Internal Event CLI is opened\n", # Submit description + "internal_events_cli_opened\n", # Submit action name + "6\n", # Select: None + "\n", # Skip MR URL + "analytics\n", # Input section + "monitor\n", # Input stage + "analytics_instrumentation\n", # Input group + "2\n", # Select [premium, ultimate] + "y\n", # Create file + "3\n" # Exit + ] + end + + let(:output_files) { [{ 'path' => event2_filepath, 'content' => event2_content }] } + end + + it_behaves_like 'creates the right defintion files', + 'Creates a new metric with product stage/section/group input manually' do + let(:keystrokes) do + [ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions + 'internal_events_cli', # Filters to the relevant events + ' ', # Multi-select: internal_events_cli_closed + "\e[B", # Arrow down to: internal_events_cli_used + ' ', # Multi-select: internal_events_cli_used + "\n", # Submit selections + "\e[B", # Arrow down to: Weekly count of unique projects + "\n", # Select: Weekly count of unique projects + "where a defition file was created with the CLI\n", # Input description + "\n", # Submit weekly description for monthly + "2\n", # Select: Modify attributes + "\n", # Accept section + "\n", # Accept stage + "\n", # Accept group + "\n", # Skip URL + "1\n", # Select: [free, premium, ultimate] + "y\n", # Create file + "y\n", # Create file + "2\n" # Exit + ] + end + + let(:input_files) do + [ + { 'path' => event1_filepath, 'content' => event1_content }, + { 'path' => event3_filepath, 'content' => event3_content } + ] + end + + let(:output_files) do + # rubocop:disable Layout/LineLength -- Long filepaths read better unbroken + [{ + 'path' => 'config/metrics/counts_28d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly.yml', + 'content' => 'spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml' + }, { + 'path' => 'config/metrics/counts_7d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly.yml', + 'content' => 'spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml' + }] + # rubocop:enable Layout/LineLength + end + end + end + + context 'when window size is unavailable' do + before do + # `tput <cmd>` returns empty string on error + stub_helper(:fetch_window_size, '') + stub_helper(:fetch_window_height, '') + end + + it_behaves_like 'creates the right defintion files', + 'Terminal size does not prevent file creation' do + let(:keystrokes) do + [ + "1\n", # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + "Internal Event CLI is opened\n", # Submit description + "internal_events_cli_opened\n", # Submit action name + "6\n", # Select: None + "\n", # Skip MR URL + "instrumentation\n", # Filter & select group + "2\n", # Select [premium, ultimate] + "y\n", # Create file + "3\n" # Exit + ] + end + + let(:output_files) { [{ 'path' => event2_filepath, 'content' => event2_content }] } + end + end + + context "when user doesn't know what they're trying to do" do + it "handles when user isn't trying to track product usage" do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "n\n" # No --> Are you trying to track customer usage of a GitLab feature? + ]) + + run_with_timeout + + expect(plain_last_lines(50)).to include("Oh no! This probably isn't the tool you need!") + end + + it "handles when product usage can't be tracked with events" do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? + "n\n" # No --> Can usage for the feature be measured by tracking a specific user action? + ]) + + run_with_timeout + + expect(plain_last_lines(50)).to include("Oh no! This probably isn't the tool you need!") + end + + it 'handles when user needs to add a new event' do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? + "y\n", # Yes --> Can usage for the feature be measured by tracking a specific user action? + "n\n", # No --> Is the event already tracked? + "n\n" # No --> Ready to start? + ]) + + run_with_timeout + + expect(plain_last_lines(30)).to include("Okay! The next step is adding a new event! (~5 min)") + end + + it 'handles when user needs to add a new metric' do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? + "y\n", # Yes --> Can usage for the feature be measured by tracking a specific user action? + "y\n", # Yes --> Is the event already tracked? + "n\n" # No --> Ready to start? + ]) + + run_with_timeout + + expect(plain_last_lines(30)).to include("Amazing! The next step is adding a new metric! (~8 min)") + end + end + + private + + def queue_cli_inputs(keystrokes) + prompt.input << keystrokes.join('') + prompt.input.rewind + end + + def run_with_timeout(duration = 1) + Timeout.timeout(duration) { described_class.new(prompt).run } + rescue Timeout::Error + # Timeout is needed to break out of the CLI, but we may want + # to make assertions afterwards + end + + def run_with_verbose_timeout(duration = 1) + Timeout.timeout(duration) { described_class.new(prompt).run } + rescue Timeout::Error => e + # Re-raise error so CLI output is printed with the error + message = <<~TEXT + Awaiting input too long. Entire CLI output: + + #{ + prompt.output.string.lines + .map { |line| "\e[0;37m#{line}\e[0m" } # wrap in white + .join('') + .gsub("\e[1G", "\e[1G ") # align to error indent + } + + + TEXT + + raise e.class, message, e.backtrace + end + + def plain_last_lines(size) + prompt.output.string + .lines + .last(size) + .join('') + .gsub(/\e[^\sm]{2,4}[mh]/, '') + end + + def collect_file_writes(collector) + allow(File).to receive(:write).and_wrap_original do |original_method, *args, &block| + filepath = args.first + collector << filepath + + dirname = Pathname.new(filepath).dirname + unless dirname.directory? + FileUtils.mkdir_p dirname + collector << dirname.to_s + end + + original_method.call(*args, &block) + end + end + + def stub_milestone(milestone) + stub_const("InternalEventsCli::Helpers::MILESTONE", milestone) + end + + def stub_product_groups(body) + allow(Net::HTTP).to receive(:get) + .with(URI('https://gitlab.com/gitlab-com/www-gitlab-com/-/raw/master/data/stages.yml')) + .and_return(body) + end + + def stub_helper(helper, value) + # rubocop:disable RSpec/AnyInstanceOf -- 'Next' helper not included in fast_spec_helper & next is insufficient + allow_any_instance_of(InternalEventsCli::Helpers).to receive(helper).and_return(value) + # rubocop:enable RSpec/AnyInstanceOf + end + + def delete_files(files) + files.each do |filepath| + FileUtils.rm_f(Rails.root.join(filepath)) + end + end + + def internal_event_fixture(filepath) + Rails.root.join('spec', 'fixtures', 'scripts', 'internal_events', filepath) + end +end