diff --git a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml index d04c54c44cd40d8656e460fd20ce30ab1c2a1bfd..f59795126adf4785b53f27a5bc39c8e5392bb3d7 100644 --- a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml +++ b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml @@ -490,8 +490,6 @@ Layout/LineEndStringConcatenationIndentation: - 'scripts/failed_tests.rb' - 'scripts/flaky_examples/prune-old-flaky-examples' - 'scripts/generate_rspec_pipeline.rb' - - 'scripts/internal_events/cli.rb' - - 'scripts/internal_events/cli/event_definer.rb' - 'scripts/lint-docs-redirects.rb' - 'scripts/qa/testcases-check' - 'scripts/trigger-build.rb' diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb index e101216b8c0589d9c658019f85803e16f44e49f2..ff2392e115c38797275161b099d3e1f227271dd6 100755 --- a/scripts/internal_events/cli.rb +++ b/scripts/internal_events/cli.rb @@ -10,10 +10,10 @@ require 'delegate' require_relative './cli/helpers' -require_relative './cli/usage_viewer' -require_relative './cli/metric_definer' -require_relative './cli/event_definer' -require_relative './cli/flow_advisor' +require_relative './cli/flows/usage_viewer' +require_relative './cli/flows/metric_definer' +require_relative './cli/flows/event_definer' +require_relative './cli/flows/flow_advisor' require_relative './cli/global_state' require_relative './cli/metric' require_relative './cli/event' @@ -36,22 +36,22 @@ def run 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 + "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 + "ex) count unique users who assign labels to issues per month", :new_metric menu.choice 'View Usage -- look at code and testing examples for existing events & metrics', :view_usage menu.choice '...am I in the right place?', :help_decide end case task when :new_event - InternalEventsCli::EventDefiner.new(cli).run + InternalEventsCli::Flows::EventDefiner.new(cli).run when :new_metric - InternalEventsCli::MetricDefiner.new(cli).run + InternalEventsCli::Flows::MetricDefiner.new(cli).run when :view_usage - InternalEventsCli::UsageViewer.new(cli).run + InternalEventsCli::Flows::UsageViewer.new(cli).run when :help_decide - InternalEventsCli::FlowAdvisor.new(cli).run + InternalEventsCli::Flows::FlowAdvisor.new(cli).run end end end diff --git a/scripts/internal_events/cli/event_definer.rb b/scripts/internal_events/cli/event_definer.rb deleted file mode 100755 index 0e5da5b35eb2249d9a60147e00ab3f9539defd81..0000000000000000000000000000000000000000 --- a/scripts/internal_events/cli/event_definer.rb +++ /dev/null @@ -1,280 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' - -module InternalEventsCli - class EventDefiner - include Helpers - - SCHEMA = ::JSONSchemer.schema(Pathname('config/events/schema.json')) - 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[feature_enabled_by_namespace_ids user] => 'Use case: For user actions attributable to multiple namespaces ' \ - '(ex - Code-Suggestions / Duo Pro)', - %w[] => "Use case: For instance-level events without user interaction [LEAST COMMON]" - }.freeze - - IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".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_context - prompt_for_url - prompt_for_product_group - 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 =~ NAME_REGEX && cli.global.events.map(&:action).none?(input) } - q.modify :trim - q.messages[:valid?] = format_warning("Invalid event name. Only lowercase/numbers/underscores allowed. " \ - "Ensure %{value} is not an existing event.") - q.messages[:required?] = Text::EVENT_ACTION_HELP - end - end - - def prompt_for_context - new_page!(3, 7, STEPS) - cli.say format_prompt("EVENT CONTEXT #{counter(0, 2)}") - prompt_for_identifiers - - new_page!(3, 7, STEPS) # Same "step" but increment counter - cli.say format_prompt("EVENT CONTEXT #{counter(1, 2)}") - prompt_for_additional_properties - end - - def prompt_for_identifiers - cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action - - identifiers = prompt_for_array_selection( - 'Which identifiers are available when the event occurs?', - IDENTIFIER_OPTIONS.keys, - per_page: IDENTIFIER_OPTIONS.length - ) { |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_additional_properties - cli.say Text::ADDITIONAL_PROPERTIES_INTRO - - available_props = [:label, :property, :value, :add_extra_prop] - - while available_props.any? - disabled = format_help('(already defined)') - - # rubocop:disable Rails/NegateInclude -- this isn't Rails - options = [ - { value: :none, name: 'None! Continue to next section!' }, - disableable_option( - value: :label, - name: 'String 1 (attribute will be named `label`)', - disabled: disabled - ) { !available_props.include?(:label) }, - disableable_option( - value: :property, - name: 'String 2 (attribute will be named `property`)', - disabled: disabled - ) { !available_props.include?(:property) }, - disableable_option( - value: :value, - name: 'Number (attribute will be named `value`)', - disabled: disabled - ) { !available_props.include?(:value) }, - disableable_option( - value: :add_extra_prop, - name: 'Add extra property (attribute will be named the input custom name)', - disabled: format_warning('(option disabled - use label/property/value first)') - ) do - !((!available_props.include?(:label) && - !available_props.include?(:property)) || - !available_props.include?(:value)) - end - ] - # rubocop:enable Rails/NegateInclude - - selected_property = cli.select( - "Which additional property do you want to add to the event?", - options, - help: format_help("(will reprompt for multiple)"), - **select_opts, - &disabled_format_callback - ) - - if selected_property == :none - available_props.clear - elsif selected_property == :add_extra_prop - property_name = prompt_for_add_extra_properties - property_description = prompt_for_text('Describe what the field will include:') - assign_extra_properties(property_name, property_description) - else - available_props.delete(selected_property) - property_description = prompt_for_text('Describe what the field will include:') - assign_extra_properties(selected_property, property_description) - end - end - end - - def assign_extra_properties(property, description = nil) - event.additional_properties ||= {} - event.additional_properties[property.to_s] = { - 'description' => description || 'TODO' - } - end - - def prompt_for_add_extra_properties - primary_props = %w[label property value] - - prompt_for_text('Define a name for the attribute:', **input_opts) do |q| - q.required true - q.validate ->(input) { input =~ NAME_REGEX && primary_props.none?(input) } - q.modify :trim - q.messages[:required?] = Text::ADDITIONAL_PROPERTIES_ADD_MORE_HELP - q.messages[:valid?] = format_warning("Invalid property name. Only lowercase/numbers/underscores allowed. " \ - "Ensure %{value} is not one of `property, label, value`.") - end - 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_group - new_page!(5, 7, STEPS) - - product_group = prompt_for_group_ownership('Which group will own the event?') - - event.product_group = product_group - 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} - - Do you need to create a metric? Probably! - - Metrics are required to pull any usage data from self-managed instances or GitLab-Dedicated through Service Ping. Collected metric data can viewed in Tableau. Individual event details from GitLab.com can also be accessed through Snowflake. - - Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake - - TEXT - end - - def prompt_for_next_steps - next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| - menu.enum "." - - menu.choice "New Event -- define another event", :new_event - - choice = if File.exist?(event.file_path) - ["Create Metric -- define a new metric using #{event.action}.yml", :add_metric] - else - ["Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add] - end - - menu.default choice[0] - menu.choice(*choice) - - menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage - menu.choice 'Exit', :exit - end - - case next_step - when :new_event - InternalEventsCli::EventDefiner.new(cli).run - 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/flow_advisor.rb b/scripts/internal_events/cli/flow_advisor.rb deleted file mode 100644 index 5c8037aa684229049d5a1e2de7623e5b054e9c1a..0000000000000000000000000000000000000000 --- a/scripts/internal_events/cli/flow_advisor.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' - -# Entrypoint for help flow, which directs the user to the -# correct flow or documentation based on their goal -module InternalEventsCli - class FlowAdvisor - include Helpers - - attr_reader :cli - - def initialize(cli) - @cli = cli - end - - def run - 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 - - private - - 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 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 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 Text::ALTERNATE_RESOURCES_NOTICE - cli.say 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-15 min)\n") - - return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?')) - - 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-10 min)\n") - - return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?')) - - 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 Text::FEEDBACK_NOTICE - end - end -end diff --git a/scripts/internal_events/cli/flows/event_definer.rb b/scripts/internal_events/cli/flows/event_definer.rb new file mode 100644 index 0000000000000000000000000000000000000000..1a5bae24f246ecef94b298468b4cdc562ace612d --- /dev/null +++ b/scripts/internal_events/cli/flows/event_definer.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require_relative '../helpers' + +# Entrypoint for flow to create an event definition file +module InternalEventsCli + module Flows + class EventDefiner + include Helpers + + SCHEMA = ::JSONSchemer.schema(Pathname('config/events/schema.json')) + 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[feature_enabled_by_namespace_ids user] => + 'Use case: For user actions attributable to multiple namespaces (ex - Code-Suggestions / Duo Pro)', + %w[] => + 'Use case: For instance-level events without user interaction [LEAST COMMON]' + }.freeze + + IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.map { |k| k.join(', ') }.max_by(&:length)}]".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_context + prompt_for_url + prompt_for_product_group + 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 =~ NAME_REGEX && cli.global.events.map(&:action).none?(input) } + q.modify :trim + q.messages[:valid?] = format_warning( + "Invalid event name. Only lowercase/numbers/underscores allowed. " \ + "Ensure %{value} is not an existing event.") + q.messages[:required?] = Text::EVENT_ACTION_HELP + end + end + + def prompt_for_context + new_page!(3, 7, STEPS) + cli.say format_prompt("EVENT CONTEXT #{counter(0, 2)}") + prompt_for_identifiers + + new_page!(3, 7, STEPS) # Same "step" but increment counter + cli.say format_prompt("EVENT CONTEXT #{counter(1, 2)}") + prompt_for_additional_properties + end + + def prompt_for_identifiers + cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action + + identifiers = prompt_for_array_selection( + 'Which identifiers are available when the event occurs?', + IDENTIFIER_OPTIONS.keys, + per_page: IDENTIFIER_OPTIONS.length + ) { |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_additional_properties + cli.say Text::ADDITIONAL_PROPERTIES_INTRO + + available_props = [:label, :property, :value, :add_extra_prop] + + while available_props.any? + disabled = format_help('(already defined)') + + # rubocop:disable Rails/NegateInclude -- this isn't Rails + options = [ + { value: :none, name: 'None! Continue to next section!' }, + disableable_option( + value: :label, + name: 'String 1 (attribute will be named `label`)', + disabled: disabled + ) { !available_props.include?(:label) }, + disableable_option( + value: :property, + name: 'String 2 (attribute will be named `property`)', + disabled: disabled + ) { !available_props.include?(:property) }, + disableable_option( + value: :value, + name: 'Number (attribute will be named `value`)', + disabled: disabled + ) { !available_props.include?(:value) }, + disableable_option( + value: :add_extra_prop, + name: 'Add extra property (attribute will be named the input custom name)', + disabled: format_warning('(option disabled - use label/property/value first)') + ) do + !((!available_props.include?(:label) && + !available_props.include?(:property)) || + !available_props.include?(:value)) + end + ] + # rubocop:enable Rails/NegateInclude + + selected_property = cli.select( + "Which additional property do you want to add to the event?", + options, + help: format_help("(will reprompt for multiple)"), + **select_opts, + &disabled_format_callback + ) + + if selected_property == :none + available_props.clear + elsif selected_property == :add_extra_prop + property_name = prompt_for_add_extra_properties + property_description = prompt_for_text('Describe what the field will include:') + assign_extra_properties(property_name, property_description) + else + available_props.delete(selected_property) + property_description = prompt_for_text('Describe what the field will include:') + assign_extra_properties(selected_property, property_description) + end + end + end + + def assign_extra_properties(property, description = nil) + event.additional_properties ||= {} + event.additional_properties[property.to_s] = { + 'description' => description || 'TODO' + } + end + + def prompt_for_add_extra_properties + primary_props = %w[label property value] + + prompt_for_text('Define a name for the attribute:', **input_opts) do |q| + q.required true + q.validate ->(input) { input =~ NAME_REGEX && primary_props.none?(input) } + q.modify :trim + q.messages[:required?] = Text::ADDITIONAL_PROPERTIES_ADD_MORE_HELP + q.messages[:valid?] = format_warning( + "Invalid property name. Only lowercase/numbers/underscores allowed. " \ + "Ensure %{value} is not one of `property, label, value`.") + end + 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_group + new_page!(5, 7, STEPS) + + product_group = prompt_for_group_ownership('Which group will own the event?') + + event.product_group = product_group + 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} + + Do you need to create a metric? Probably! + + Metrics are required to pull any usage data from self-managed instances or GitLab-Dedicated through Service Ping. Collected metric data can viewed in Tableau. Individual event details from GitLab.com can also be accessed through Snowflake. + + Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake + + TEXT + end + + def prompt_for_next_steps + next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + + menu.choice "New Event -- define another event", :new_event + + choice = if File.exist?(event.file_path) + ["Create Metric -- define a new metric using #{event.action}.yml", :add_metric] + else + ["Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add] + end + + menu.default choice[0] + menu.choice(*choice) + + menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage + menu.choice 'Exit', :exit + end + + case next_step + when :new_event + EventDefiner.new(cli).run + 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 +end diff --git a/scripts/internal_events/cli/flows/flow_advisor.rb b/scripts/internal_events/cli/flows/flow_advisor.rb new file mode 100755 index 0000000000000000000000000000000000000000..79435a08af5fa6baed27cd4a9cc4c0ded20a403f --- /dev/null +++ b/scripts/internal_events/cli/flows/flow_advisor.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative '../helpers' + +# Entrypoint for help flow, which directs the user to the +# correct flow or documentation based on their goal +module InternalEventsCli + module Flows + class FlowAdvisor + include Helpers + + attr_reader :cli + + def initialize(cli) + @cli = cli + end + + def run + 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 + + private + + 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 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 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 Text::ALTERNATE_RESOURCES_NOTICE + cli.say 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-15 min)\n") + + return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?')) + + 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-10 min)\n") + + return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?')) + + 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 Text::FEEDBACK_NOTICE + end + end + end +end diff --git a/scripts/internal_events/cli/flows/metric_definer.rb b/scripts/internal_events/cli/flows/metric_definer.rb new file mode 100755 index 0000000000000000000000000000000000000000..17ea14772d0b73060fcea8b0bfcc3263de007062 --- /dev/null +++ b/scripts/internal_events/cli/flows/metric_definer.rb @@ -0,0 +1,574 @@ +# frozen_string_literal: true + +require_relative '../helpers' +require_relative '../text' + +# Entrypoint for flow to create an metric definition file +module InternalEventsCli + module Flows + class MetricDefiner + include Helpers + + SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json')) + STEPS = [ + 'New Metric', + 'Type', + 'Events', + 'Scope', + 'Descriptions', + 'Copy event', + 'Group', + 'URL', + 'Tiers', + 'Save files' + ].freeze + + NAME_REQUIREMENT_REASONS = { + filters: { + text: 'Metrics using filters are too complex for default naming.', + help: Text::METRIC_NAME_FILTER_HELP + }, + length: { + text: 'The default filename will be too long.', + help: Text::METRIC_NAME_LENGTH_HELP + }, + conflict: { + text: 'The default key path is already in use.', + help: Text::METRIC_NAME_CONFLICT_HELP + } + }.freeze + + attr_reader :cli + + def initialize(cli, starting_event = nil) + @cli = cli + @selected_event_paths = Array(starting_event) + @metrics = [] + @selected_filters = {} + end + + def run + type = prompt_for_metric_type + prompt_for_events(type) + + return unless @selected_event_paths.any? + + prompt_for_metrics + prompt_for_event_filters + + return unless @metrics.any? + + prompt_for_descriptions + defaults = prompt_for_copying_event_properties + prompt_for_product_group(defaults) + prompt_for_url(defaults) + prompt_for_tier(defaults) + outcomes = create_metric_files + prompt_for_next_steps(outcomes) + end + + private + + # ----- Memoization Helpers ----------------- + + def events + @events ||= events_by_filepath(@selected_event_paths) + end + + def selected_events + @selected_events ||= events.values_at(*@selected_event_paths) + end + + # ----- Prompts ----------------------------- + + def prompt_for_metric_type + return if @selected_event_paths.any? + + new_page!(1, 9, STEPS) + + cli.select("Which best describes what the metric should track?", **select_opts) do |menu| + menu.enum "." + + menu.choice 'Single event -- count occurrences of a specific event or user interaction', + :event_metric + menu.choice 'Multiple events -- count occurrences of several separate events or interactions', + :aggregate_metric + menu.choice 'Database -- record value of a particular field or count of database rows', + :database_metric + end + end + + def prompt_for_events(type) + return if @selected_event_paths.any? + + new_page!(2, 9, STEPS) + + case type + when :event_metric + cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" + + @selected_event_paths = [cli.select( + 'Which event does this metric track?', + get_event_options(events), + **select_opts, + **filter_opts(header_size: 7) + )] + when :aggregate_metric + cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" + + @selected_event_paths = cli.multi_select( + 'Which events does this metric track? (Space to select)', + get_event_options(events), + **multiselect_opts, + **filter_opts(header_size: 7) + ) + when :database_metric + cli.error 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) + cli.say format_info('SELECTED EVENTS') + cli.say selected_events_filter_options.join + cli.say "\n" + + @metrics = cli.select( + 'Which metrics do you want to add?', + eligible_metrics, + **select_opts, + **filter_opts, + per_page: 20, + &disabled_format_callback + ) + + assign_shared_attrs(:actions, :milestone) do + { + actions: selected_events.map(&:action).sort, + milestone: MILESTONE + } + end + end + + def prompt_for_event_filters + return if @metrics.none?(&:filters_expected?) + + selected_unique_identifier = @metrics.first.identifier.value + event_count = selected_events.length + previous_inputs = { + 'label' => nil, + 'property' => nil, + 'value' => nil + } + + event_filters = selected_events.dup.flat_map.with_index do |event, idx| + print_event_filter_header(event, idx, event_count) + + next if deselect_nonfilterable_event?(event) # prompts user + + filter_values = event.additional_properties&.filter_map do |property, _| + next if selected_unique_identifier == property + + prompt_for_property_filter( + event.action, + property, + previous_inputs[property] + ) + end + + previous_inputs.merge!(@selected_filters[event.action] || {}) + + find_filter_permutations(event.action, filter_values) + end.compact + + bulk_assign(filters: event_filters) + end + + def prompt_for_descriptions + default_description = nil + default_key = nil + + separate_page_per_metric = @metrics.any? do |metric| + name_requirement_reason(metric) + end + + @metrics.each_with_index do |metric, idx| + if idx == 0 || separate_page_per_metric + new_page!(4, 9, STEPS) + + cli.say Text::METRIC_DESCRIPTION_INTRO + cli.say selected_event_descriptions.join + end + + cli.say "\n" + cli.say format_prompt(format_subheader( + 'DESCRIBING METRIC', + metric.technical_description, + idx, + @metrics.length + )) + + prompt_for_description(metric, default_description).tap do |description| + default_description = description + metric.description = "#{metric.description_prefix} #{description}" + end + + prompt_for_metric_name(metric, default_key)&.tap do |key| + default_key = key + metric.key = key + end + end + end + + def file_saved_context_message(attributes) + format_prefix " ", <<~TEXT.chomp + - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric + - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))} + TEXT + end + + # Check existing event files for attributes to copy over + def prompt_for_copying_event_properties + shared_values = collect_values_for_shared_event_properties + defaults = shared_values.except(:stage, :section) + + return {} if shared_values.none? + + return shared_values if defaults.none? + + new_page!(5, 9, STEPS) + + cli.say <<~TEXT + #{format_info('Convenient! We can copy these attributes from the event definition(s):')} + + #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)} + #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')} + + TEXT + + cli.select('What would you like to do?', **select_opts) do |menu| + menu.enum '.' + menu.choice 'Copy & continue', -> { bulk_assign(defaults) } + menu.choice 'Modify attributes' + end + + shared_values + end + + def prompt_for_product_group(defaults) + assign_shared_attr(:product_group) do + new_page!(6, 9, STEPS) + + prompt_for_group_ownership('Which group owns the metric?', defaults) + end + end + + def prompt_for_url(defaults) + assign_shared_attr(:introduced_by_url) do + new_page!(7, 9, STEPS) + + prompt_for_text( + 'Which MR URL introduced the metric?', + defaults[:introduced_by_url] + ) + end + end + + def prompt_for_tier(defaults) + assign_shared_attr(:tier) do + new_page!(8, 9, STEPS) + + prompt_for_array_selection( + 'Which tiers will the metric be reported from?', + [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]], + defaults[:tier] + ) + end + + assign_shared_attr(:tiers) { |metric| [*metric.tier] } + + assign_shared_attr(:distribution) do |metric| + metric.tier.include?('free') ? %w[ce ee] : %w[ee] + end + end + + def create_metric_files + @metrics.map.with_index do |metric, idx| + new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter + + cli.say format_prompt(format_subheader('SAVING FILE', metric.description, idx, @metrics.length)) + cli.say "\n" + + prompt_to_save_file(metric.file_path, metric.formatted_output) + end + end + + def prompt_for_next_steps(outcomes = []) + new_page! + + outcome = outcomes.any? ? outcomes.compact.join("\n") : ' No files saved.' + metric = @metrics.first + + cli.say <<~TEXT + #{divider} + #{format_info('Done with metric definitions!')} + + #{outcome} + #{divider} + + Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation! + + Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau + Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed + Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))} + + Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake + + TEXT + + actions = selected_events.map(&:action).join(', ') + next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + + menu.choice "New Event -- define a new event", :new_event + menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events + menu.choice "New Metric -- define another metric", :new_metric + choice = "View Usage -- look at code examples for event #{selected_events.first.action}" + menu.default choice + menu.choice choice, :view_usage + menu.choice 'Exit', :exit + end + + case next_step + when :new_event + EventDefiner.new(cli).run + when :new_metric_with_events + MetricDefiner.new(cli, @selected_event_paths).run + when :new_metric + MetricDefiner.new(cli).run + when :view_usage + UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run + when :exit + cli.say Text::FEEDBACK_NOTICE + end + end + + # ----- Prompt-specific Helpers ------------- + + # Helper for #prompt_for_metrics + def selected_events_filter_options + filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? } + + selected_events.map do |event| + filters = event.additional_properties&.keys + filter_phrase = if filters + " (filterable by #{filters&.join(', ')})" + elsif filterable_events_selected + ' -- not filterable' + end + + " - #{event.action}#{format_help(filter_phrase)}\n" + end + end + + # Helper for #prompt_for_event_filters + def print_event_filter_header(event, idx, total) + cli.say "\n" + cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total)) + + return unless event.additional_properties&.any? + + event_filter_options = event.additional_properties.map do |property, attrs| + " #{property}: #{attrs['description']}\n" + end + + cli.say event_filter_options.join + end + + # Helper for #prompt_for_event_filters + def deselect_nonfilterable_event?(event) + cli.say "\n" + + return false if event.additional_properties&.any? + return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts) + + selected_events.delete(event) + bulk_assign(actions: selected_events.map(&:action).sort) + + true + end + + # Helper for #prompt_for_event_filters + def prompt_for_property_filter(action, property, default) + formatted_prop = format_info(property) + prompt = "Count where #{formatted_prop} equals any of (comma-sep):" + + inputs = prompt_for_text(prompt, default, **input_opts) do |q| + if property == 'value' + q.convert ->(input) { input.split(',').map(&:to_i).uniq } + q.validate %r{^(\d|\s|,)*$} + q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric" + else + q.convert ->(input) { input.split(',').map(&:strip).uniq } + end + end + + return unless inputs&.any? + + @selected_filters[action] ||= {} + @selected_filters[action][property] = inputs.join(',') + + inputs.map { |input| { property => input } }.uniq + end + + # Helper for #prompt_for_event_filters + # + # Gets all the permutations of the provided property values. + # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }] + # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }] + def find_filter_permutations(action, filters) + # Define a filter for all events, regardless of the available props so NewMetric#events is correct + return [[action, {}]] unless filters&.any? + + # Uses proc syntax to avoid spliting & type-checking `filters` + :product.to_proc.call(*filters).map do |filter| + [action, filter.reduce(&:merge)] + end + end + + # Helper for #prompt_for_descriptions + def selected_event_descriptions + selected_events.map do |event| + filters = @selected_filters[event.action] + + if filters&.any? + filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ') + filter_phrase = format_help("(#{filter_phrase})") + end + + " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n" + end + end + + # Helper for #prompt_for_descriptions + def prompt_for_description(metric, default) + description_start = format_info("#{metric.description_prefix}...") + + cli.say <<~TEXT + + #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text} + + TEXT + + prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q| + q.required true + q.modify :trim + q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP + end + end + + # Helper for #prompt_for_descriptions + def prompt_for_metric_name(metric, default) + name_reason = name_requirement_reason(metric) + default_name = metric.key.value + display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m") + empty_name = metric.key.value('') + + return unless name_reason + + cli.say <<~TEXT + + #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text} + + ID: #{format_info(display_name)} + Filename: #{format_info(display_name)}#{format_info('.yml')} + + TEXT + + max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length + help_tokens = { name: default_name, count: max_length } + + prompt_for_text(' Replace with: ', default, multiline: true) do |q| + q.required true + q.messages[:required?] = name_reason[:help] % help_tokens + q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens + q.validate ->(input) do + input.length <= max_length && + input.match?(NAME_REGEX) && + !conflicting_key_path?(metric.key.value(input)) + end + end + end + + # Helper for #prompt_for_descriptions + def name_requirement_reason(metric) + if metric.filters.assigned? + NAME_REQUIREMENT_REASONS[:filters] + elsif metric.file_name.length > MAX_FILENAME_LENGTH + NAME_REQUIREMENT_REASONS[:length] + elsif conflicting_key_path?(metric.key_path) + NAME_REQUIREMENT_REASONS[:conflict] + end + end + + # Helper for #prompt_for_descriptions + def conflicting_key_path?(key_path) + cli.global.metrics.any? do |existing_metric| + existing_metric.key_path == key_path + end + end + + # Helper for #prompt_for_copying_event_properties + def collect_values_for_shared_event_properties + fields = Hash.new { |h, k| h[k] = [] } + + selected_events.each do |event| + fields[:introduced_by_url] << event.introduced_by_url + fields[:product_group] << event.product_group + fields[:stage] << find_stage(event.product_group) + fields[:section] << find_section(event.product_group) + fields[:distribution] << event.distributions&.sort + fields[:tier] << event.tiers&.sort + fields[:tiers] << event.tiers&.sort + end + + # Keep event values if every selected event is the same + fields.each_with_object({}) do |(attr, values), defaults| + next unless values.compact.uniq.length == 1 + + defaults[attr] ||= values.first + end + end + + # ----- Shared Helpers ---------------------- + + def assign_shared_attrs(...) + metric = @metrics.first + attrs = metric.to_h.slice(...) + attrs = yield(metric) unless attrs.values.all? + + bulk_assign(attrs) + end + + def assign_shared_attr(key) + assign_shared_attrs(key) do |metric| + { key => yield(metric) } + end + end + + def bulk_assign(attrs) + @metrics.each { |metric| metric.bulk_assign(attrs) } + end + end + end +end diff --git a/scripts/internal_events/cli/flows/usage_viewer.rb b/scripts/internal_events/cli/flows/usage_viewer.rb new file mode 100755 index 0000000000000000000000000000000000000000..1b84769a51555de56ddd91f4a8e22a27ad6caa07 --- /dev/null +++ b/scripts/internal_events/cli/flows/usage_viewer.rb @@ -0,0 +1,438 @@ +# frozen_string_literal: true + +require_relative '../helpers' + +# Entrypoint for flow to print examples of how to trigger an +# event in different languages & different methods of testing +module InternalEventsCli + module Flows + class UsageViewer + include Helpers + + PROPERTY_EXAMPLES = { + 'label' => "'string'", + 'property' => "'string'", + 'value' => '72', + 'custom_key' => 'custom_value' + }.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 = '1. ruby/rails') + choices = [ + { name: '1. ruby/rails', value: :rails }, + { name: '2. rspec', value: :rspec }, + { name: '3. javascript (vue)', value: :vue }, + { name: '4. javascript (plain)', value: :js }, + { name: '5. vue template', value: :vue_template }, + { name: '6. haml', value: :haml }, + { name: '7. Manual testing in GDK', value: :gdk }, + { name: '8. Data verification in Tableau', value: :tableau }, + { name: '9. View examples for a different event', value: :other_event }, + { name: '10. Exit', value: :exit } + ] + + usage_location = cli.select( + 'Select a use-case to view examples for:', + choices, + **select_opts, + **filter_opts, + per_page: 10 + ) do |menu| + menu.default default + end + + case usage_location + when :rails + rails_examples + prompt_for_usage_location('1. ruby/rails') + when :rspec + rspec_examples + prompt_for_usage_location('2. rspec') + when :haml + haml_examples + prompt_for_usage_location('6. haml') + when :js + js_examples + prompt_for_usage_location('4. javascript (plain)') + when :vue + vue_examples + prompt_for_usage_location('3. javascript (vue)') + when :vue_template + vue_template_examples + prompt_for_usage_location('5. vue template') + when :gdk + gdk_examples + prompt_for_usage_location('7. Manual testing in GDK') + when :tableau + service_ping_dashboard_examples + prompt_for_usage_location('8. Data verification in Tableau') + when :other_event + self.class.new(cli).run + when :exit + cli.say(Text::FEEDBACK_NOTICE) + end + end + + def rails_examples + identifier_args = identifiers.map do |identifier| + " #{identifier}: #{identifier}" + end + + property_args = format_additional_properties do |property, value, description| + " #{property}: #{value}, # #{description}" + end + + if property_args.any? + # remove trailing comma after last arg but keep any other commas + property_args.last.sub!(',', '') + property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }" + end + + args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n") + args = "\n #{args}\n" if args.lines.count > 1 + + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RAILS')} + + include Gitlab::InternalEventsTracking + + track_internal_event(#{args}) + + #{divider} + TEXT + end + + def rspec_examples + identifier_args = identifiers.map do |identifier| + " let(:#{identifier}) { create(:#{identifier}) }\n" + end.join('') + + property_args = format_additional_properties do |property, value| + " #{property}: #{value}" + end + + if property_args.any? + property_arg = format_prefix ' ', <<~TEXT + let(:additional_properties) do + { + #{property_args.join(",\n")} + } + end + TEXT + end + + args = [*identifier_args, *property_arg].join('') + + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RSPEC')} + + it_behaves_like 'internal event tracking' do + let(:event) { '#{action}' } + #{args}end + + #{divider} + TEXT + end + + def haml_examples + property_args = format_additional_properties do |property, value, _| + "event_#{property}: #{value}" + end + + args = ["event_tracking: '#{action}'", *property_args].join(', ') + + cli.say <<~TEXT + #{divider} + #{format_help('# HAML -- ON-CLICK')} + + .inline-block{ #{format_warning("data: { #{args} }")} } + = _('Important Text') + + #{divider} + #{format_help('# HAML -- COMPONENT ON-CLICK')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} }) + + #{divider} + #{format_help('# HAML -- COMPONENT ON-LOAD')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} }) + + #{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 + on_click_args = template_formatted_args('data-event-tracking', indent: 2) + on_load_args = template_formatted_args('data-event-tracking-load', indent: 2) + + cli.say <<~TEXT + #{divider} + #{format_help('// VUE TEMPLATE -- ON-CLICK')} + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button#{on_click_args} + 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#{on_load_args} + 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 + args = js_formatted_args(indent: 2) + + cli.say <<~TEXT + #{divider} + #{format_help('// FRONTEND -- RAW JAVASCRIPT')} + + #{format_warning("import { InternalEvents } from '~/tracking';")} + + export const performAction = () => { + #{format_warning("InternalEvents.trackEvent#{args}")} + + 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 + args = js_formatted_args(indent: 6) + + 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#{args}")} + }, + }, + }; + </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 + + private + + def action + event['action'] + end + + def identifiers + Array(event['identifiers']).tap do |ids| + # We always auto assign namespace if project is provided + ids.delete('namespace') if ids.include?('project') + end + end + + def additional_properties + Array(event['additional_properties']) + end + + def format_additional_properties + additional_properties.map do |property, details| + example_value = PROPERTY_EXAMPLES[property] + description = details['description'] || 'TODO' + + yield(property, example_value, description) + end + end + + def js_formatted_args(indent:) + return "('#{action}');" if additional_properties.none? + + property_args = format_additional_properties do |property, value, description| + " #{property}: #{value}, // #{description}" + end + + [ + '(', + " '#{action}',", + ' {', + *property_args, + ' },', + ');' + ].join("\n#{' ' * indent}") + end + + def service_ping_metrics_info + product_group = related_metrics.map(&:product_group).uniq + + <<~TEXT + #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")} + + #{divider} + #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")} + + #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")} + TEXT + end + + def service_ping_no_metric_info + <<~TEXT + #{format_help("# Warning: There are no metrics for #{event.action} yet.")} + #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))} + TEXT + end + + def template_formatted_args(data_attr, indent:) + return " #{data_attr}=\"#{action}\">" if additional_properties.none? + + spacer = ' ' * indent + property_args = format_additional_properties do |property, value, _| + " data-event-#{property}=#{value.tr("'", '"')}" + end + + args = [ + '', # start args on next line + " #{data_attr}=\"#{action}\"", + *property_args + ].join("\n#{spacer}") + + "#{format_warning(args)}\n#{spacer}>" + end + + def related_metrics + cli.global.metrics.select { |metric| metric.actions&.include?(event.action) } + end + + def service_ping_dashboard_examples + cli.say <<~TEXT + #{divider} + #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')} + + #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info} + #{divider} + Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')} + + Not what you're looking for? Check this doc: + - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')} + + TEXT + end + + def gdk_examples + key_paths = related_metrics.map(&:key_path) + + cli.say <<~TEXT + #{divider} + #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')} + + 1. From `gitlab/` directory, run the monitor script: + + #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")} + + 2. View metric updates within the terminal + + 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/snowplow_micro.md + + #{divider} + #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')} + + #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")} + + #{format_help('# Get current value of a metric')} + #{ + if key_paths.any? + key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n") + else + format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace <key_path> below.\n") + + format_warning('ServicePingHelpers.get_current_usage_metric_value(<key_path>)') + end + } + + #{format_help('# View entire service ping payload')} + #{format_warning('ServicePingHelpers.get_current_service_ping_payload')} + #{divider} + Need to test something else? Check these docs: + - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html + - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html + - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html + + TEXT + end + end + end +end diff --git a/scripts/internal_events/cli/metric_definer.rb b/scripts/internal_events/cli/metric_definer.rb deleted file mode 100755 index f8e9d3f090be07b8fd00a8dd4add4a93d3b78428..0000000000000000000000000000000000000000 --- a/scripts/internal_events/cli/metric_definer.rb +++ /dev/null @@ -1,568 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' -require_relative './text' - -module InternalEventsCli - class MetricDefiner - include Helpers - - SCHEMA = ::JSONSchemer.schema(Pathname('config/metrics/schema/base.json')) - STEPS = [ - 'New Metric', - 'Type', - 'Events', - 'Scope', - 'Descriptions', - 'Copy event', - 'Group', - 'URL', - 'Tiers', - 'Save files' - ].freeze - - NAME_REQUIREMENT_REASONS = { - filters: { - text: 'Metrics using filters are too complex for default naming.', - help: Text::METRIC_NAME_FILTER_HELP - }, - length: { - text: 'The default filename will be too long.', - help: Text::METRIC_NAME_LENGTH_HELP - }, - conflict: { - text: 'The default key path is already in use.', - help: Text::METRIC_NAME_CONFLICT_HELP - } - }.freeze - - attr_reader :cli - - def initialize(cli, starting_event = nil) - @cli = cli - @selected_event_paths = Array(starting_event) - @metrics = [] - @selected_filters = {} - end - - def run - type = prompt_for_metric_type - prompt_for_events(type) - - return unless @selected_event_paths.any? - - prompt_for_metrics - prompt_for_event_filters - - return unless @metrics.any? - - prompt_for_descriptions - defaults = prompt_for_copying_event_properties - prompt_for_product_group(defaults) - prompt_for_url(defaults) - prompt_for_tier(defaults) - outcomes = create_metric_files - prompt_for_next_steps(outcomes) - end - - private - - # ----- Memoization Helpers ----------------- - - def events - @events ||= events_by_filepath(@selected_event_paths) - end - - def selected_events - @selected_events ||= events.values_at(*@selected_event_paths) - end - - # ----- Prompts ----------------------------- - - def prompt_for_metric_type - return if @selected_event_paths.any? - - new_page!(1, 9, STEPS) - - cli.select("Which best describes what the metric should track?", **select_opts) do |menu| - menu.enum "." - - menu.choice 'Single event -- count occurrences of a specific event or user interaction', :event_metric - menu.choice 'Multiple events -- count occurrences of several separate events or interactions', :aggregate_metric - menu.choice 'Database -- record value of a particular field or count of database rows', :database_metric - end - end - - def prompt_for_events(type) - return if @selected_event_paths.any? - - new_page!(2, 9, STEPS) - - case type - when :event_metric - cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" - - @selected_event_paths = [cli.select( - 'Which event does this metric track?', - get_event_options(events), - **select_opts, - **filter_opts(header_size: 7) - )] - when :aggregate_metric - cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" - - @selected_event_paths = cli.multi_select( - 'Which events does this metric track? (Space to select)', - get_event_options(events), - **multiselect_opts, - **filter_opts(header_size: 7) - ) - when :database_metric - cli.error 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) - cli.say format_info('SELECTED EVENTS') - cli.say selected_events_filter_options.join - cli.say "\n" - - @metrics = cli.select( - 'Which metrics do you want to add?', - eligible_metrics, - **select_opts, - **filter_opts, - per_page: 20, - &disabled_format_callback - ) - - assign_shared_attrs(:actions, :milestone) do - { - actions: selected_events.map(&:action).sort, - milestone: MILESTONE - } - end - end - - def prompt_for_event_filters - return if @metrics.none?(&:filters_expected?) - - selected_unique_identifier = @metrics.first.identifier.value - event_count = selected_events.length - previous_inputs = { - 'label' => nil, - 'property' => nil, - 'value' => nil - } - - event_filters = selected_events.dup.flat_map.with_index do |event, idx| - print_event_filter_header(event, idx, event_count) - - next if deselect_nonfilterable_event?(event) # prompts user - - filter_values = event.additional_properties&.filter_map do |property, _| - next if selected_unique_identifier == property - - prompt_for_property_filter( - event.action, - property, - previous_inputs[property] - ) - end - - previous_inputs.merge!(@selected_filters[event.action] || {}) - - find_filter_permutations(event.action, filter_values) - end.compact - - bulk_assign(filters: event_filters) - end - - def prompt_for_descriptions - default_description = nil - default_key = nil - - separate_page_per_metric = @metrics.any? do |metric| - name_requirement_reason(metric) - end - - @metrics.each_with_index do |metric, idx| - if idx == 0 || separate_page_per_metric - new_page!(4, 9, STEPS) - - cli.say Text::METRIC_DESCRIPTION_INTRO - cli.say selected_event_descriptions.join - end - - cli.say "\n" - cli.say format_prompt(format_subheader( - 'DESCRIBING METRIC', - metric.technical_description, - idx, - @metrics.length - )) - - prompt_for_description(metric, default_description).tap do |description| - default_description = description - metric.description = "#{metric.description_prefix} #{description}" - end - - prompt_for_metric_name(metric, default_key)&.tap do |key| - default_key = key - metric.key = key - end - end - end - - def file_saved_context_message(attributes) - format_prefix " ", <<~TEXT.chomp - - Visit #{format_info('https://metrics.gitlab.com')} to find dashboard links for this metric - - Metric trend dashboard: #{format_info(metric_trend_path(attributes['key_path']))} - TEXT - end - - # Check existing event files for attributes to copy over - def prompt_for_copying_event_properties - shared_values = collect_values_for_shared_event_properties - defaults = shared_values.except(:stage, :section) - - return {} if shared_values.none? - - return shared_values if defaults.none? - - new_page!(5, 9, STEPS) - - cli.say <<~TEXT - #{format_info('Convenient! We can copy these attributes from the event definition(s):')} - - #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)} - #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')} - - TEXT - - cli.select('What would you like to do?', **select_opts) do |menu| - menu.enum '.' - menu.choice 'Copy & continue', -> { bulk_assign(defaults) } - menu.choice 'Modify attributes' - end - - shared_values - end - - def prompt_for_product_group(defaults) - assign_shared_attr(:product_group) do - new_page!(6, 9, STEPS) - - prompt_for_group_ownership('Which group owns the metric?', defaults) - end - end - - def prompt_for_url(defaults) - assign_shared_attr(:introduced_by_url) do - new_page!(7, 9, STEPS) - - prompt_for_text( - 'Which MR URL introduced the metric?', - defaults[:introduced_by_url] - ) - end - end - - def prompt_for_tier(defaults) - assign_shared_attr(:tier) do - new_page!(8, 9, STEPS) - - prompt_for_array_selection( - 'Which tiers will the metric be reported from?', - [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]], - defaults[:tier] - ) - end - - assign_shared_attr(:tiers) { |metric| [*metric.tier] } - - assign_shared_attr(:distribution) do |metric| - metric.tier.include?('free') ? %w[ce ee] : %w[ee] - end - end - - def create_metric_files - @metrics.map.with_index do |metric, idx| - new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter - - cli.say format_prompt(format_subheader('SAVING FILE', metric.description, idx, @metrics.length)) - cli.say "\n" - - prompt_to_save_file(metric.file_path, metric.formatted_output) - end - end - - def prompt_for_next_steps(outcomes = []) - new_page! - - outcome = outcomes.any? ? outcomes.compact.join("\n") : ' No files saved.' - metric = @metrics.first - - cli.say <<~TEXT - #{divider} - #{format_info('Done with metric definitions!')} - - #{outcome} - #{divider} - - Have you instrumented the application code to trigger the event yet? View usage examples to easily copy/paste implementation! - - Want to verify the metrics? Check out the group::#{metric[:product_group]} Metrics Exploration Dashboard in Tableau - Note: The Metrics Exploration Dashboard data would be available ~1 week after deploy for Gitlab.com, ~1 week after next release for self-managed - Link: #{format_info(metric_exploration_group_path(metric[:product_group], find_stage(metric.product_group)))} - - Typical flow: Define event > Define metric > Instrument app code > Merge/Deploy MR > Verify data in Tableau/Snowflake - - TEXT - - actions = selected_events.map(&:action).join(', ') - next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| - menu.enum "." - - menu.choice "New Event -- define a new event", :new_event - menu.choice "New Metric -- define another metric for #{actions}", :new_metric_with_events - menu.choice "New Metric -- define another metric", :new_metric - choice = "View Usage -- look at code examples for event #{selected_events.first.action}" - menu.default choice - menu.choice choice, :view_usage - menu.choice 'Exit', :exit - end - - case next_step - when :new_event - InternalEventsCli::EventDefiner.new(cli).run - when :new_metric_with_events - MetricDefiner.new(cli, @selected_event_paths).run - when :new_metric - MetricDefiner.new(cli).run - when :view_usage - UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run - when :exit - cli.say Text::FEEDBACK_NOTICE - end - end - - # ----- Prompt-specific Helpers ------------- - - # Helper for #prompt_for_metrics - def selected_events_filter_options - filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? } - - selected_events.map do |event| - filters = event.additional_properties&.keys - filter_phrase = if filters - " (filterable by #{filters&.join(', ')})" - elsif filterable_events_selected - ' -- not filterable' - end - - " - #{event.action}#{format_help(filter_phrase)}\n" - end - end - - # Helper for #prompt_for_event_filters - def print_event_filter_header(event, idx, total) - cli.say "\n" - cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total)) - - return unless event.additional_properties&.any? - - event_filter_options = event.additional_properties.map do |property, attrs| - " #{property}: #{attrs['description']}\n" - end - - cli.say event_filter_options.join - end - - # Helper for #prompt_for_event_filters - def deselect_nonfilterable_event?(event) - cli.say "\n" - - return false if event.additional_properties&.any? - return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts) - - selected_events.delete(event) - bulk_assign(actions: selected_events.map(&:action).sort) - - true - end - - # Helper for #prompt_for_event_filters - def prompt_for_property_filter(action, property, default) - formatted_prop = format_info(property) - prompt = "Count where #{formatted_prop} equals any of (comma-sep):" - - inputs = prompt_for_text(prompt, default, **input_opts) do |q| - if property == 'value' - q.convert ->(input) { input.split(',').map(&:to_i).uniq } - q.validate %r{^(\d|\s|,)*$} - q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric" - else - q.convert ->(input) { input.split(',').map(&:strip).uniq } - end - end - - return unless inputs&.any? - - @selected_filters[action] ||= {} - @selected_filters[action][property] = inputs.join(',') - - inputs.map { |input| { property => input } }.uniq - end - - # Helper for #prompt_for_event_filters - # - # Gets all the permutations of the provided property values. - # @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }] - # @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }] - def find_filter_permutations(action, filters) - # Define a filter for all events, regardless of the available props so NewMetric#events is correct - return [[action, {}]] unless filters&.any? - - # Uses proc syntax to avoid spliting & type-checking `filters` - :product.to_proc.call(*filters).map do |filter| - [action, filter.reduce(&:merge)] - end - end - - # Helper for #prompt_for_descriptions - def selected_event_descriptions - selected_events.map do |event| - filters = @selected_filters[event.action] - - if filters&.any? - filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ') - filter_phrase = format_help("(#{filter_phrase})") - end - - " #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n" - end - end - - # Helper for #prompt_for_descriptions - def prompt_for_description(metric, default) - description_start = format_info("#{metric.description_prefix}...") - - cli.say <<~TEXT - - #{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text} - - TEXT - - prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q| - q.required true - q.modify :trim - q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP - end - end - - # Helper for #prompt_for_descriptions - def prompt_for_metric_name(metric, default) - name_reason = name_requirement_reason(metric) - default_name = metric.key.value - display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m") - empty_name = metric.key.value('') - - return unless name_reason - - cli.say <<~TEXT - - #{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text} - - ID: #{format_info(display_name)} - Filename: #{format_info(display_name)}#{format_info('.yml')} - - TEXT - - max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length - help_tokens = { name: default_name, count: max_length } - - prompt_for_text(' Replace with: ', default, multiline: true) do |q| - q.required true - q.messages[:required?] = name_reason[:help] % help_tokens - q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens - q.validate ->(input) do - input.length <= max_length && - input.match?(NAME_REGEX) && - !conflicting_key_path?(metric.key.value(input)) - end - end - end - - # Helper for #prompt_for_descriptions - def name_requirement_reason(metric) - if metric.filters.assigned? - NAME_REQUIREMENT_REASONS[:filters] - elsif metric.file_name.length > MAX_FILENAME_LENGTH - NAME_REQUIREMENT_REASONS[:length] - elsif conflicting_key_path?(metric.key_path) - NAME_REQUIREMENT_REASONS[:conflict] - end - end - - # Helper for #prompt_for_descriptions - def conflicting_key_path?(key_path) - cli.global.metrics.any? do |existing_metric| - existing_metric.key_path == key_path - end - end - - # Helper for #prompt_for_copying_event_properties - def collect_values_for_shared_event_properties - fields = Hash.new { |h, k| h[k] = [] } - - selected_events.each do |event| - fields[:introduced_by_url] << event.introduced_by_url - fields[:product_group] << event.product_group - fields[:stage] << find_stage(event.product_group) - fields[:section] << find_section(event.product_group) - fields[:distribution] << event.distributions&.sort - fields[:tier] << event.tiers&.sort - fields[:tiers] << event.tiers&.sort - end - - # Keep event values if every selected event is the same - fields.each_with_object({}) do |(attr, values), defaults| - next unless values.compact.uniq.length == 1 - - defaults[attr] ||= values.first - end - end - - # ----- Shared Helpers ---------------------- - - def assign_shared_attrs(...) - metric = @metrics.first - attrs = metric.to_h.slice(...) - attrs = yield(metric) unless attrs.values.all? - - bulk_assign(attrs) - end - - def assign_shared_attr(key) - assign_shared_attrs(key) do |metric| - { key => yield(metric) } - end - end - - def bulk_assign(attrs) - @metrics.each { |metric| metric.bulk_assign(attrs) } - end - end -end diff --git a/scripts/internal_events/cli/usage_viewer.rb b/scripts/internal_events/cli/usage_viewer.rb deleted file mode 100755 index d91fa6f3c4ebe4d938df77fb2db990cab9d4705f..0000000000000000000000000000000000000000 --- a/scripts/internal_events/cli/usage_viewer.rb +++ /dev/null @@ -1,434 +0,0 @@ -# frozen_string_literal: true - -require_relative './helpers' - -module InternalEventsCli - class UsageViewer - include Helpers - - PROPERTY_EXAMPLES = { - 'label' => "'string'", - 'property' => "'string'", - 'value' => '72', - 'custom_key' => 'custom_value' - }.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 = '1. ruby/rails') - choices = [ - { name: '1. ruby/rails', value: :rails }, - { name: '2. rspec', value: :rspec }, - { name: '3. javascript (vue)', value: :vue }, - { name: '4. javascript (plain)', value: :js }, - { name: '5. vue template', value: :vue_template }, - { name: '6. haml', value: :haml }, - { name: '7. Manual testing in GDK', value: :gdk }, - { name: '8. Data verification in Tableau', value: :tableau }, - { name: '9. View examples for a different event', value: :other_event }, - { name: '10. Exit', value: :exit } - ] - - usage_location = cli.select( - 'Select a use-case to view examples for:', - choices, - **select_opts, - **filter_opts, - per_page: 10 - ) do |menu| - menu.default default - end - - case usage_location - when :rails - rails_examples - prompt_for_usage_location('1. ruby/rails') - when :rspec - rspec_examples - prompt_for_usage_location('2. rspec') - when :haml - haml_examples - prompt_for_usage_location('6. haml') - when :js - js_examples - prompt_for_usage_location('4. javascript (plain)') - when :vue - vue_examples - prompt_for_usage_location('3. javascript (vue)') - when :vue_template - vue_template_examples - prompt_for_usage_location('5. vue template') - when :gdk - gdk_examples - prompt_for_usage_location('7. Manual testing in GDK') - when :tableau - service_ping_dashboard_examples - prompt_for_usage_location('8. Data verification in Tableau') - when :other_event - self.class.new(cli).run - when :exit - cli.say(Text::FEEDBACK_NOTICE) - end - end - - def rails_examples - identifier_args = identifiers.map do |identifier| - " #{identifier}: #{identifier}" - end - - property_args = format_additional_properties do |property, value, description| - " #{property}: #{value}, # #{description}" - end - - if property_args.any? - # remove trailing comma after last arg but keep any other commas - property_args.last.sub!(',', '') - property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }" - end - - args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n") - args = "\n #{args}\n" if args.lines.count > 1 - - cli.say format_warning <<~TEXT - #{divider} - #{format_help('# RAILS')} - - include Gitlab::InternalEventsTracking - - track_internal_event(#{args}) - - #{divider} - TEXT - end - - def rspec_examples - identifier_args = identifiers.map do |identifier| - " let(:#{identifier}) { create(:#{identifier}) }\n" - end.join('') - - property_args = format_additional_properties do |property, value| - " #{property}: #{value}" - end - - if property_args.any? - property_arg = format_prefix ' ', <<~TEXT - let(:additional_properties) do - { - #{property_args.join(",\n")} - } - end - TEXT - end - - args = [*identifier_args, *property_arg].join('') - - cli.say format_warning <<~TEXT - #{divider} - #{format_help('# RSPEC')} - - it_behaves_like 'internal event tracking' do - let(:event) { '#{action}' } - #{args}end - - #{divider} - TEXT - end - - def haml_examples - property_args = format_additional_properties do |property, value, _| - "event_#{property}: #{value}" - end - - args = ["event_tracking: '#{action}'", *property_args].join(', ') - - cli.say <<~TEXT - #{divider} - #{format_help('# HAML -- ON-CLICK')} - - .inline-block{ #{format_warning("data: { #{args} }")} } - = _('Important Text') - - #{divider} - #{format_help('# HAML -- COMPONENT ON-CLICK')} - - = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} }) - - #{divider} - #{format_help('# HAML -- COMPONENT ON-LOAD')} - - = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} }) - - #{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 - on_click_args = template_formatted_args('data-event-tracking', indent: 2) - on_load_args = template_formatted_args('data-event-tracking-load', indent: 2) - - cli.say <<~TEXT - #{divider} - #{format_help('// VUE TEMPLATE -- ON-CLICK')} - - <script> - import { GlButton } from '@gitlab/ui'; - - export default { - components: { GlButton } - }; - </script> - - <template> - <gl-button#{on_click_args} - 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#{on_load_args} - 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 - args = js_formatted_args(indent: 2) - - cli.say <<~TEXT - #{divider} - #{format_help('// FRONTEND -- RAW JAVASCRIPT')} - - #{format_warning("import { InternalEvents } from '~/tracking';")} - - export const performAction = () => { - #{format_warning("InternalEvents.trackEvent#{args}")} - - 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 - args = js_formatted_args(indent: 6) - - 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#{args}")} - }, - }, - }; - </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 - - private - - def action - event['action'] - end - - def identifiers - Array(event['identifiers']).tap do |ids| - # We always auto assign namespace if project is provided - ids.delete('namespace') if ids.include?('project') - end - end - - def additional_properties - Array(event['additional_properties']) - end - - def format_additional_properties - additional_properties.map do |property, details| - example_value = PROPERTY_EXAMPLES[property] - description = details['description'] || 'TODO' - - yield(property, example_value, description) - end - end - - def js_formatted_args(indent:) - return "('#{action}');" if additional_properties.none? - - property_args = format_additional_properties do |property, value, description| - " #{property}: #{value}, // #{description}" - end - - [ - '(', - " '#{action}',", - ' {', - *property_args, - ' },', - ');' - ].join("\n#{' ' * indent}") - end - - def service_ping_metrics_info - product_group = related_metrics.map(&:product_group).uniq - - <<~TEXT - #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")} - - #{divider} - #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")} - - #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")} - TEXT - end - - def service_ping_no_metric_info - <<~TEXT - #{format_help("# Warning: There are no metrics for #{event.action} yet.")} - #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))} - TEXT - end - - def template_formatted_args(data_attr, indent:) - return " #{data_attr}=\"#{action}\">" if additional_properties.none? - - spacer = ' ' * indent - property_args = format_additional_properties do |property, value, _| - " data-event-#{property}=#{value.tr("'", '"')}" - end - - args = [ - '', # start args on next line - " #{data_attr}=\"#{action}\"", - *property_args - ].join("\n#{spacer}") - - "#{format_warning(args)}\n#{spacer}>" - end - - def related_metrics - cli.global.metrics.select { |metric| metric.actions&.include?(event.action) } - end - - def service_ping_dashboard_examples - cli.say <<~TEXT - #{divider} - #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')} - - #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info} - #{divider} - Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')} - - Not what you're looking for? Check this doc: - - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')} - - TEXT - end - - def gdk_examples - key_paths = related_metrics.map(&:key_path) - - cli.say <<~TEXT - #{divider} - #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')} - - 1. From `gitlab/` directory, run the monitor script: - - #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")} - - 2. View metric updates within the terminal - - 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/snowplow_micro.md - - #{divider} - #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')} - - #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")} - - #{format_help('# Get current value of a metric')} - #{ - if key_paths.any? - key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n") - else - format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace <key_path> below.\n") + - format_warning('ServicePingHelpers.get_current_usage_metric_value(<key_path>)') - end - } - - #{format_help('# View entire service ping payload')} - #{format_warning('ServicePingHelpers.get_current_service_ping_payload')} - #{divider} - Need to test something else? Check these docs: - - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html - - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html - - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html - - TEXT - end - end -end