diff --git a/.yamllint b/.yamllint index 896db4c101ea058a0311cf3039dd43a8767f6087..c4df8cce12272f4e29ce7439339487399dbaefbf 100644 --- a/.yamllint +++ b/.yamllint @@ -44,6 +44,7 @@ ignore: | #### Folders #### node_modules/ tmp/ + generator_templates/gitlab_internal_events/ # In CI some YAML files are linted using different rules. # See `.gitlab/ci/yaml.gitlab-ci.yml`. diff --git a/generator_templates/gitlab_internal_events/metric_definition.yml b/generator_templates/gitlab_internal_events/metric_definition.yml new file mode 100644 index 0000000000000000000000000000000000000000..88823355acce0401a5a35a09720701f377f83f4b --- /dev/null +++ b/generator_templates/gitlab_internal_events/metric_definition.yml @@ -0,0 +1,18 @@ +--- +key_path: <%= args.second %> +name: <%= args.second %> +description: <%= args.last %> +product_section: <%= options.fetch(:section) %> +product_stage: <%= options.fetch(:stage) %> +product_group: <%= options.fetch(:group) %> +performance_indicator_type: [] +value_type: number +status: active +milestone: "<%= milestone %>" +introduced_by_url: <%= options.fetch(:mr) %> +time_frame: <%= args.third %> +data_source: redis_hll +data_category: optional +instrumentation_class: <%= class_name %> +<%= distribution %> +<%= tier %> diff --git a/lib/generators/gitlab/analytics/internal_events_generator.rb b/lib/generators/gitlab/analytics/internal_events_generator.rb new file mode 100644 index 0000000000000000000000000000000000000000..775dac969f28bc5a95c454a9ef6106c53d705d17 --- /dev/null +++ b/lib/generators/gitlab/analytics/internal_events_generator.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'rails/generators' + +module Gitlab + module Analytics + class InternalEventsGenerator < Rails::Generators::Base + TIME_FRAME_DIRS = { + '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 + + TOP_LEVEL_DIR = 'config' + TOP_LEVEL_DIR_EE = 'ee' + DESCRIPTION_MIN_LENGTH = 50 + KNOWN_EVENTS_PATH = 'lib/gitlab/usage_data_counters/known_events/common.yml' + KNOWN_EVENTS_PATH_EE = 'ee/lib/ee/gitlab/usage_data_counters/known_events/common.yml' + + source_root File.expand_path('../../../../generator_templates/gitlab_internal_events', __dir__) + + desc 'Generates metric definitions yml files and known events entries' + + 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_on, + type: :string, + optional: false, + desc: 'Name of the event property that this metric counts' + + def create_metric_file + validate! + + time_frames.each do |time_frame| + template "metric_definition.yml", + file_path(time_frame), + key_path(time_frame), + time_frame, + ask_description(time_frame) + end + + # ToDo: Delete during https://gitlab.com/groups/gitlab-org/-/epics/9542 cleanup + append_file known_events_file_name, known_event_entry + end + + private + + def known_event_entry + <<~YML + - name: #{options[:event]} + aggregation: weekly + YML + end + + def ask_description(time_frame) + question = <<~DESC + Please describe in at least #{DESCRIPTION_MIN_LENGTH} characters + what #{key_path(time_frame)} metric represents, + consider mentioning: events, and event attributes in the description. + your answer will be processed to power a full-text search tool and help others find and reuse this metric. + DESC + + say("") + description = ask(question) + + while description.length < DESCRIPTION_MIN_LENGTH + error_mgs = <<~ERROR + Provided description is to short: #{description.length} of required #{DESCRIPTION_MIN_LENGTH} characters + ERROR + + say(set_color(error_mgs), :red) + + description = ask("Please provide description that is #{DESCRIPTION_MIN_LENGTH} characters long.\n") + end + description + end + + def distribution + content = [ + free? ? "- ce" : nil, + "- ee" + ].compact.join("\n") + + "distribution:\n#{content}" + end + + def tier + "tier:\n- #{options[:tiers].join("\n- ")}" + end + + def milestone + Gitlab::VERSION.match('(\d+\.\d+)').captures.first + end + + def class_name + 'RedisHLLMetric' + end + + def key_path(time_frame) + "count_distinct_#{options[:unique_on]}_from_#{options[:event]}_#{time_frame}" + end + + def 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 known_events_file_name + (free? ? KNOWN_EVENTS_PATH : KNOWN_EVENTS_PATH_EE) + end + + def validate! + raise "Required file: #{known_events_file_name} does not exists." unless File.exist?(known_events_file_name) + + validate_tiers! + + %i[unique_on 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) + 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 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(skip_validation: true) + end + + def metric_definition_exists?(time_frame) + metric_definitions[key_path(time_frame)].present? + end + end + end +end diff --git a/spec/lib/generators/gitlab/analytics/internal_events_generator_spec.rb b/spec/lib/generators/gitlab/analytics/internal_events_generator_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..38992a29dcb718fdba4f832865b9d7ecaf053b5b --- /dev/null +++ b/spec/lib/generators/gitlab/analytics/internal_events_generator_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Analytics::InternalEventsGenerator, :silence_stdout, feature_category: :service_ping do + include UsageDataHelpers + + let(:temp_dir) { Dir.mktmpdir } + let(:tmpfile) { Tempfile.new('test-metadata') } + let(:ee_temp_dir) { Dir.mktmpdir } + let(:existing_key_paths) { {} } + let(:description) { "This metric counts unique users viewing analytics metrics dashboard section" } + let(:group) { "group::analytics instrumentation" } + let(:stage) { "analytics" } + let(:section) { "analytics" } + let(:mr) { "https://gitlab.com/some-group/some-project/-/merge_requests/123" } + let(:event) { "view_analytics_dashboard" } + let(:unique_on) { "user_id" } + let(:options) do + { + time_frames: time_frames, + free: true, + mr: mr, + group: group, + stage: stage, + section: section, + event: event, + unique_on: unique_on + }.stringify_keys + end + + let(:key_path_7d) { "count_distinct_#{unique_on}_from_#{event}_7d" } + let(:metric_definition_path_7d) { Dir.glob(File.join(temp_dir, "metrics/counts_7d/#{key_path_7d}.yml")).first } + let(:metric_definition_7d) do + { + "key_path" => key_path_7d, + "name" => key_path_7d, + "description" => description, + "product_section" => section, + "product_stage" => stage, + "product_group" => group, + "performance_indicator_type" => [], + "value_type" => "number", + "status" => "active", + "milestone" => "13.9", + "introduced_by_url" => mr, + "time_frame" => "7d", + "data_source" => "redis_hll", + "data_category" => "optional", + "instrumentation_class" => "RedisHLLMetric", + "distribution" => %w[ce ee], + "tier" => %w[free premium ultimate] + } + end + + before do + stub_const("#{described_class}::TOP_LEVEL_DIR", temp_dir) + stub_const("#{described_class}::TOP_LEVEL_DIR_EE", ee_temp_dir) + stub_const("#{described_class}::KNOWN_EVENTS_PATH", tmpfile.path) + stub_const("#{described_class}::KNOWN_EVENTS_PATH_EE", tmpfile.path) + + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:ask) + .with(/Please describe in at least 50 characters/) + .and_return(description) + end + + allow(Gitlab::Usage::MetricDefinition) + .to receive(:definitions).and_return(existing_key_paths) + end + + after do + FileUtils.rm_rf(temp_dir) + FileUtils.rm_rf(ee_temp_dir) + FileUtils.rm_rf(tmpfile.path) + end + + describe 'Creating metric definition file' do + before do + # Stub version so that `milestone` key remains constant between releases to prevent flakiness. + stub_const('Gitlab::VERSION', '13.9.0') + end + + context 'for single time frame' do + let(:time_frames) { %w[7d] } + + it 'creates a metric definition file using the template' do + described_class.new([], options).invoke_all + + expect(YAML.safe_load(File.read(metric_definition_path_7d))).to eq(metric_definition_7d) + end + + context 'for ultimate only feature' do + let(:metric_definition_path_7d) do + Dir.glob(File.join(ee_temp_dir, temp_dir, "metrics/counts_7d/#{key_path_7d}.yml")).first + end + + it 'creates a metric definition file using the template' do + described_class.new([], options.merge(tiers: %w[ultimate])).invoke_all + + expect(YAML.safe_load(File.read(metric_definition_path_7d))) + .to eq(metric_definition_7d.merge("tier" => ["ultimate"], "distribution" => ["ee"])) + end + end + + context 'with invalid time frame' do + let(:time_frames) { %w[14d] } + + it 'raises error' do + expect { described_class.new([], options).invoke_all }.to raise_error(RuntimeError) + end + end + + context 'with duplicated key path' do + let(:existing_key_paths) { { key_path_7d => true } } + + it 'raises error' do + expect { described_class.new([], options).invoke_all }.to raise_error(RuntimeError) + end + end + + context 'without at least one tier available' do + it 'raises error' do + expect { described_class.new([], options.merge(tiers: [])).invoke_all } + .to raise_error(RuntimeError) + end + end + + context 'with unknown tier' do + it 'raises error' do + expect { described_class.new([], options.merge(tiers: %w[superb])).invoke_all } + .to raise_error(RuntimeError) + end + end + + context 'without obligatory parameter' do + it 'raises error', :aggregate_failures do + %w[unique_on event mr section stage group].each do |option| + expect { described_class.new([], options.without(option)).invoke_all } + .to raise_error(RuntimeError) + end + end + end + + context 'with to short description' do + it 'asks again for description' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:ask) + .with(/Please describe in at least 50 characters/) + .and_return("I am to short") + + expect(instance).to receive(:ask) + .with(/Please provide description that is 50 characters long/) + .and_return(description) + end + + described_class.new([], options).invoke_all + end + end + end + + context 'for multiple time frames' do + let(:time_frames) { %w[7d 28d] } + let(:key_path_28d) { "count_distinct_#{unique_on}_from_#{event}_28d" } + let(:metric_definition_path_28d) { Dir.glob(File.join(temp_dir, "metrics/counts_28d/#{key_path_28d}.yml")).first } + let(:metric_definition_28d) do + metric_definition_7d.merge( + "key_path" => key_path_28d, + "name" => key_path_28d, + "time_frame" => "28d" + ) + end + + it 'creates a metric definition file using the template' do + described_class.new([], options).invoke_all + + expect(YAML.safe_load(File.read(metric_definition_path_7d))).to eq(metric_definition_7d) + expect(YAML.safe_load(File.read(metric_definition_path_28d))).to eq(metric_definition_28d) + end + end + + context 'with default time frames' do + let(:time_frames) { nil } + let(:key_path_28d) { "count_distinct_#{unique_on}_from_#{event}_28d" } + let(:metric_definition_path_28d) { Dir.glob(File.join(temp_dir, "metrics/counts_28d/#{key_path_28d}.yml")).first } + let(:metric_definition_28d) do + metric_definition_7d.merge( + "key_path" => key_path_28d, + "name" => key_path_28d, + "time_frame" => "28d" + ) + end + + it 'creates a metric definition file using the template' do + described_class.new([], options.without('time_frames')).invoke_all + + expect(YAML.safe_load(File.read(metric_definition_path_7d))).to eq(metric_definition_7d) + expect(YAML.safe_load(File.read(metric_definition_path_28d))).to eq(metric_definition_28d) + end + end + end + + describe 'Creating known event entry' do + let(:time_frames) { %w[7d 28d] } + let(:expected_known_events) { [{ "name" => event, "aggregation" => "weekly" }] } + + it 'creates a metric definition file using the template' do + described_class.new([], options).invoke_all + + expect(YAML.safe_load(File.read(tmpfile.path))).to match_array(expected_known_events) + end + + context 'for ultimate only feature' do + let(:ee_tmpfile) { Tempfile.new('test-metadata') } + + after do + FileUtils.rm_rf(ee_tmpfile) + end + + it 'creates a metric definition file using the template' do + stub_const("#{described_class}::KNOWN_EVENTS_PATH_EE", ee_tmpfile.path) + + described_class.new([], options.merge(tiers: %w[ultimate])).invoke_all + + expect(YAML.safe_load(File.read(tmpfile.path))).to be nil + expect(YAML.safe_load(File.read(ee_tmpfile.path))).to match_array(expected_known_events) + end + end + end +end