# frozen_string_literal: true require 'rails/generators' module Gitlab module Analytics class InternalEventsGenerator < Rails::Generators::Base TIME_FRAME_DIRS = { 'all' => 'counts_all', '7d' => 'counts_7d', '28d' => 'counts_28d' }.freeze TIME_FRAMES_DEFAULT = TIME_FRAME_DIRS.keys.tap do |time_frame_defaults| time_frame_defaults.class_eval do def to_s join(", ") end end end.freeze ALLOWED_TIERS = %w[free premium ultimate].dup.tap do |tiers_default| tiers_default.class_eval do def to_s join(", ") end end end.freeze NEGATIVE_ANSWERS = %w[no n No NO N].freeze POSITIVE_ANSWERS = %w[yes y Yes YES Y].freeze TOP_LEVEL_DIR = 'config' TOP_LEVEL_DIR_EE = 'ee' DESCRIPTION_MIN_LENGTH = 50 DESCRIPTION_INQUIRY = %( Please describe in at least #{DESCRIPTION_MIN_LENGTH} characters what %{entity} %{entity_type} represents, consider mentioning: %{considerations}. Your answer will be processed by a full-text search tool and help others find and reuse this %{entity_type}. ).freeze source_root File.expand_path('../../../../generator_templates/gitlab_internal_events', __dir__) desc 'Generates metric definitions and event definition yml files' class_option :skip_namespace, hide: true class_option :skip_collision_check, hide: true class_option :time_frames, optional: true, default: TIME_FRAMES_DEFAULT, type: :array, banner: TIME_FRAMES_DEFAULT, desc: "Indicates the metrics time frames. Please select one or more from: #{TIME_FRAMES_DEFAULT}" class_option :tiers, optional: true, default: ALLOWED_TIERS, type: :array, banner: ALLOWED_TIERS, desc: "Indicates the metric's GitLab subscription tiers. Please select one or more from: #{ALLOWED_TIERS}" class_option :group, type: :string, optional: false, desc: 'Name of group that added this metric' class_option :stage, type: :string, optional: false, desc: 'Name of stage that added this metric' class_option :section, type: :string, optional: false, desc: 'Name of section that added this metric' class_option :mr, type: :string, optional: false, desc: 'Merge Request that adds this metric' class_option :event, type: :string, optional: false, desc: 'Name of the event that this metric counts' class_option :unique, type: :string, optional: true, desc: 'Name of the event property that this metric counts' def create_metric_file validate! unless event_exists? template "event_definition.yml", event_file_path(event), ask_description(event, "event", "what the event is supposed to track, where, and when") end time_frames.each do |time_frame| template "metric_definition.yml", metric_file_path(time_frame), key_path(time_frame), time_frame, ask_description( key_path(time_frame), "metric", "events, and event attributes in the description" ) end end private def event_identifiers return unless include_default_event_properties? "\n- project\n- user\n- namespace" end def include_default_event_properties? question = <<~DESC By convention all events automatically include the following properties: * environment: string, * source: string (eg: ruby, javascript) * user_id: number * project_id: number * namespace_id: number * plan: string (eg: free, premium, ultimate) Would you like to add default properties to the event? Y(es)/N(o) DESC answer = Gitlab::TaskHelpers.prompt(question, POSITIVE_ANSWERS + NEGATIVE_ANSWERS) POSITIVE_ANSWERS.include?(answer) end def event_file_path(event) path = File.join(TOP_LEVEL_DIR, 'events', "#{event}.yml") path = File.join(TOP_LEVEL_DIR_EE, path) unless free? path end def event options[:event] end def unique(time_frame) return if time_frame == 'all' "\n unique: #{options.fetch(:unique)}" end def ask_description(entity, type, considerations) say("") desc = ask(format(DESCRIPTION_INQUIRY, entity: entity, entity_type: type, considerations: considerations)) while desc.length < DESCRIPTION_MIN_LENGTH error_msg = <<~ERROR Provided description is too short: #{desc.length} of required #{DESCRIPTION_MIN_LENGTH} characters ERROR say(set_color(error_msg, :red)) desc = ask("Please provide description that is #{DESCRIPTION_MIN_LENGTH} characters long.\n") end desc end def distributions dist = "\n" dist += "- ce\n" if free? "#{dist}- ee" end def tiers "\n- #{options[:tiers].join("\n- ")}" end def milestone Gitlab::VERSION.match('(\d+\.\d+)').captures.first end def class_name(time_frame) time_frame == 'all' ? 'TotalCountMetric' : 'RedisHLLMetric' end def key_path(time_frame) if time_frame == 'all' "count_total_#{event}" else "count_distinct_#{options[:unique].sub('.', '_')}_from_#{event}_#{time_frame}" end end def metric_file_path(time_frame) path = File.join(TOP_LEVEL_DIR, 'metrics', TIME_FRAME_DIRS[time_frame], "#{key_path(time_frame)}.yml") path = File.join(TOP_LEVEL_DIR_EE, path) unless free? path end def validate! validate_tiers! %i[event mr section stage group].each do |option| raise "The option: --#{option} is missing" unless options.key? option end time_frames.each do |time_frame| validate_time_frame!(time_frame) raise "The option: --unique is missing" if time_frame != 'all' && !options.key?('unique') validate_key_path!(time_frame) end end def validate_time_frame!(time_frame) return if TIME_FRAME_DIRS.key?(time_frame) raise "Invalid time frame: #{time_frame}, allowed options are: #{TIME_FRAMES_DEFAULT}" end def validate_tiers! wrong_tiers = options[:tiers] - ALLOWED_TIERS unless wrong_tiers.empty? raise "Tiers option included not allowed values: #{wrong_tiers}. Only allowed values are: #{ALLOWED_TIERS}" end return unless options[:tiers].empty? raise "At least one tier must be present. Please set --tiers option" end def validate_key_path!(time_frame) return unless metric_definition_exists?(time_frame) raise "Metric definition with key path '#{key_path(time_frame)}' already exists" end def event_exists? return true if ::Gitlab::UsageDataCounters::HLLRedisCounter.known_event?(event) existing_events_from_definitions.include?(event) end def existing_events_from_definitions events_glob_path = File.join(TOP_LEVEL_DIR, 'events', "*.yml") ee_events_glob_path = File.join(TOP_LEVEL_DIR_EE, events_glob_path) [ee_events_glob_path, events_glob_path].flat_map do |glob_path| Dir.glob(glob_path).map do |path| YAML.safe_load(File.read(path))["action"] end end end def free? options[:tiers].include? "free" end def time_frames options[:time_frames] end def directory @directory ||= TIME_FRAME_DIRS.find { |d| d.match?(input_dir) } end def metric_definitions @definitions ||= Gitlab::Usage::MetricDefinition.definitions end def metric_definition_exists?(time_frame) metric_definitions[key_path(time_frame)].present? end end end end