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