diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index cf79be3792c930e3ca1c565de8c56a0999ebc43c..5097f2dddaa92f678987472127d5c6b6ea79dbd4 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -911,7 +911,8 @@ Returns [`[CiRunnerUsageByProject!]`](#cirunnerusagebyproject).
 | Name | Type | Description |
 | ---- | ---- | ----------- |
 | <a id="queryrunnerusagebyprojectfromdate"></a>`fromDate` | [`Date`](#date) | Start of the requested date frame. Defaults to the start of the previous calendar month. |
-| <a id="queryrunnerusagebyprojectprojectslimit"></a>`projectsLimit` | [`Int`](#int) | Maximum number of projects to return.Other projects will be aggregated to a `project: null` entry. Defaults to 5 if unspecified. Maximum of 500. |
+| <a id="queryrunnerusagebyprojectfullpath"></a>`fullPath` | [`String`](#string) | Filter jobs based on the full path of the group or project they belong to. For example, `gitlab-org` or `gitlab-org/gitlab`. Available only to admins, group maintainers (when a group is specified), or project maintainers (when a project is specified). Limited to runners from 5000 child projects. |
+| <a id="queryrunnerusagebyprojectprojectslimit"></a>`projectsLimit` | [`Int`](#int) | Maximum number of projects to return. Other projects will be aggregated to a `project: null` entry. Defaults to 5 if unspecified. Maximum of 500. |
 | <a id="queryrunnerusagebyprojectrunnertype"></a>`runnerType` | [`CiRunnerType`](#cirunnertype) | Filter jobs by the type of runner that executed them. |
 | <a id="queryrunnerusagebyprojecttodate"></a>`toDate` | [`Date`](#date) | End of the requested date frame. Defaults to the end of the previous calendar month. |
 
diff --git a/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb b/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb
index c1ff1fac19c8318d6603e133e42c8004fc5ab3f6..dff03c590d89a87753cceb59b5dcfd15786470be 100644
--- a/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb
+++ b/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb
@@ -3,6 +3,7 @@
 module Resolvers
   module Ci
     class RunnerUsageByProjectResolver < BaseResolver
+      include Gitlab::Utils::StrongMemoize
       include Gitlab::Graphql::Authorize::AuthorizeResource
 
       MAX_PROJECTS_LIMIT = 500
@@ -12,13 +13,23 @@ class RunnerUsageByProjectResolver < BaseResolver
 
       type [Types::Ci::RunnerUsageByProjectType], null: true
       description <<~MD
-        Runner usage in minutes by project. Available only to admins.
+        Runner usage in minutes by project.
+        Available only to admins, group maintainers (when a group is specified),
+        or project maintainers (when a project is specified).
       MD
 
       argument :runner_type, ::Types::Ci::RunnerTypeEnum,
         required: false,
         description: 'Filter jobs by the type of runner that executed them.'
 
+      argument :full_path, GraphQL::Types::String,
+        required: false,
+        description: 'Filter jobs based on the full path of the group or project they belong to. ' \
+          'For example, `gitlab-org` or `gitlab-org/gitlab`. ' \
+          'Available only to admins, group maintainers (when a group is specified), ' \
+          'or project maintainers (when a project is specified). ' \
+          "Limited to runners from #{::Ci::Runners::GetUsageByProjectService::MAX_PROJECTS_IN_GROUP} child projects."
+
       argument :from_date, Types::DateType,
         required: false,
         description: 'Start of the requested date frame. Defaults to the start of the previous calendar month.'
@@ -29,12 +40,13 @@ class RunnerUsageByProjectResolver < BaseResolver
 
       argument :projects_limit, GraphQL::Types::Int,
         required: false,
-        description: 'Maximum number of projects to return.' \
-                     'Other projects will be aggregated to a `project: null` entry. ' \
-                     "Defaults to #{DEFAULT_PROJECTS_LIMIT} if unspecified. Maximum of #{MAX_PROJECTS_LIMIT}."
+        description:
+          'Maximum number of projects to return. ' \
+          'Other projects will be aggregated to a `project: null` entry. ' \
+          "Defaults to #{DEFAULT_PROJECTS_LIMIT} if unspecified. Maximum of #{MAX_PROJECTS_LIMIT}."
 
-      def resolve(from_date: nil, to_date: nil, runner_type: nil, projects_limit: nil)
-        authorize! :global
+      def resolve(from_date: nil, to_date: nil, runner_type: nil, full_path: nil, projects_limit: nil)
+        find_and_authorize_scope!(full_path)
 
         from_date ||= 1.month.ago.beginning_of_month.to_date
         to_date ||= 1.month.ago.end_of_month.to_date
@@ -47,6 +59,7 @@ def resolve(from_date: nil, to_date: nil, runner_type: nil, projects_limit: nil)
         result = ::Ci::Runners::GetUsageByProjectService.new(
           current_user,
           runner_type: runner_type,
+          scope: @group_scope || @project_scope,
           from_date: from_date,
           to_date: to_date,
           max_item_count: [MAX_PROJECTS_LIMIT, projects_limit || DEFAULT_PROJECTS_LIMIT].min
@@ -59,6 +72,18 @@ def resolve(from_date: nil, to_date: nil, runner_type: nil, projects_limit: nil)
 
       private
 
+      def find_and_authorize_scope!(full_path)
+        return authorize! :global if full_path.nil?
+
+        strong_memoize_with(:find_and_authorize_scope, full_path) do
+          @group_scope = Group.find_by_full_path(full_path)
+          @project_scope = Project.find_by_full_path(full_path) if @group_scope.nil?
+
+          raise_resource_not_available_error! if @group_scope.nil? && @project_scope.nil?
+          authorize!(@group_scope || @project_scope)
+        end
+      end
+
       def prepare_result(payload)
         payload.map do |project_usage|
           {
diff --git a/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb b/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb
index 83c4867be27004a33a7ee71d3adc45f9259a112c..0c1e783dd0cbc543bd463e5e21572a7cd645a69f 100644
--- a/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb
+++ b/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb
@@ -30,9 +30,10 @@ class RunnerUsageResolver < BaseResolver
       argument :runners_limit, GraphQL::Types::Int,
         required: false,
         default_value: DEFAULT_RUNNERS_LIMIT,
-        description: 'Maximum number of runners to return. ' \
-                     'Other runners will be aggregated to a `runner: null` entry. ' \
-                     "Defaults to #{DEFAULT_RUNNERS_LIMIT} if unspecified. Maximum of #{MAX_RUNNERS_LIMIT}."
+        description:
+          'Maximum number of runners to return. ' \
+          'Other runners will be aggregated to a `runner: null` entry. ' \
+          "Defaults to #{DEFAULT_RUNNERS_LIMIT} if unspecified. Maximum of #{MAX_RUNNERS_LIMIT}."
 
       def resolve(from_date: nil, to_date: nil, runner_type: nil, runners_limit: nil)
         authorize! :global
diff --git a/ee/app/graphql/types/ci/runner_usage_by_project_type.rb b/ee/app/graphql/types/ci/runner_usage_by_project_type.rb
index f4a6fabca37bf48ea0fecfa7608b807a9d8350c5..b8629a3047a7752f7af3390bfc9104c0e260e85c 100644
--- a/ee/app/graphql/types/ci/runner_usage_by_project_type.rb
+++ b/ee/app/graphql/types/ci/runner_usage_by_project_type.rb
@@ -2,12 +2,11 @@
 
 module Types
   module Ci
+    # rubocop: disable Graphql/AuthorizeTypes -- the read_runner_usage permission is already checked by the resolver
     class RunnerUsageByProjectType < BaseObject
       graphql_name 'CiRunnerUsageByProject'
       description 'Runner usage in minutes by project.'
 
-      authorize :read_runner_usage
-
       field :project, ::Types::ProjectType,
         null: true, description: 'Project that the usage refers to. Null means "Other projects".'
 
@@ -27,5 +26,6 @@ def project
         end
       end
     end
+    # rubocop: enable Graphql/AuthorizeTypes
   end
 end
diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb
index f68aea3688e5c75eef6879cd9692945eaf529037..f7d13dabaceb7168421056ebfd6e8563d1b9b3f7 100644
--- a/ee/app/policies/ee/group_policy.rb
+++ b/ee/app/policies/ee/group_policy.rb
@@ -242,6 +242,10 @@ module GroupPolicy
         @subject.licensed_feature_available?(:runner_performance_insights_for_namespace)
       end
 
+      condition(:clickhouse_main_database_available, scope: :global) do
+        ::Gitlab::ClickHouse.configured?
+      end
+
       rule { user_banned_from_namespace }.prevent_all
 
       rule { public_group | logged_in_viewable }.policy do
@@ -270,6 +274,8 @@ module GroupPolicy
         enable :admin_wiki
         enable :modify_product_analytics_settings
         enable :read_jobs_statistics
+        enable :read_runner_usage
+        enable :admin_push_rules
       end
 
       rule { (admin | maintainer) & group_analytics_dashboards_available & ~has_parent }.policy do
@@ -716,9 +722,12 @@ module GroupPolicy
       end
 
       rule { ~runner_performance_insights_available }.policy do
+        prevent :read_runner_usage
         prevent :read_jobs_statistics
       end
 
+      rule { ~clickhouse_main_database_available }.prevent :read_runner_usage
+
       condition(:pre_receive_secret_detection_available) do
         ::Gitlab::CurrentSettings.gitlab_dedicated_instance? || ::Feature.enabled?(:pre_receive_secret_detection_push_check, @subject)
       end
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index fd263748cec2a34718a5ad24d232ee20998e6331..f258c9c42ade854a3abf6207154f87ad42bae8a7 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -525,8 +525,13 @@ module ProjectPolicy
         enable :modify_product_analytics_settings
         enable :admin_push_rules
         enable :manage_deploy_tokens
+        enable :read_runner_usage
       end
 
+      rule { ~runner_performance_insights_available }.prevent :read_runner_usage
+
+      rule { ~clickhouse_main_database_available }.prevent :read_runner_usage
+
       rule { license_scanning_enabled & can?(:maintainer_access) }.enable :admin_software_license_policy
 
       rule { oncall_schedules_available & can?(:maintainer_access) }.enable :admin_incident_management_oncall_schedule
@@ -916,6 +921,16 @@ module ProjectPolicy
         enable :read_web_hook
         enable :admin_web_hook
       end
+
+      with_scope :subject
+      condition(:runner_performance_insights_available) do
+        @subject.group&.licensed_feature_available?(:runner_performance_insights_for_namespace)
+      end
+
+      with_scope :global
+      condition(:clickhouse_main_database_available) do
+        ::Gitlab::ClickHouse.configured?
+      end
     end
 
     override :lookup_access_level!
diff --git a/ee/app/services/ci/runners/get_usage_service_base.rb b/ee/app/services/ci/runners/get_usage_service_base.rb
index 29182e52e7152f46d0e9ee237964e7806760e158..fc603628d174abc56d3d8c7ebe0ef8580418f358 100644
--- a/ee/app/services/ci/runners/get_usage_service_base.rb
+++ b/ee/app/services/ci/runners/get_usage_service_base.rb
@@ -5,8 +5,29 @@ module Runners
     class GetUsageServiceBase
       include Gitlab::Utils::StrongMemoize
 
-      def initialize(current_user, runner_type:, from_date:, to_date:, max_item_count:, additional_group_by_columns: [])
+      MAX_PROJECTS_IN_GROUP = 5_000
+
+      # Instantiates a new service
+      #
+      # @param [User, token String] current_user The current user for whom the report is generated
+      # @param [DateTime] from_date The start date for the report data (inclusive)
+      # @param [DateTime] to_date The end date for the report data (exclusive)
+      # @param [Integer] max_item_count The maximum number of items to include in the report
+      # @param [Project, Group, nil] scope The top-level object that owns the jobs.
+      #   - Project: filter jobs from the specified project. current_user needs to be at least a maintainer
+      #   - Group: filter jobs from projects under the specified group. current_user needs to be at least a maintainer
+      #   - nil: use all jobs. current_user must be an admin
+      # @param [String] runner_type The type of CI runner to include data for
+      #     Valid options are defined in `Ci::Runner.runner_types`
+      # @param [Array<String>] additional_group_by_columns An array of additional columns to group the report data by.
+      #
+      # @return [GetUsageServiceBase]
+      def initialize(
+        current_user, from_date:, to_date:, max_item_count:,
+        scope: nil, runner_type: nil, additional_group_by_columns: []
+      )
         @current_user = current_user
+        @scope = scope
         @runner_type = Ci::Runner.runner_types[runner_type]
         @from_date = from_date
         @to_date = to_date
@@ -20,7 +41,7 @@ def execute
             reason: :db_not_configured)
         end
 
-        unless Ability.allowed?(@current_user, :read_runner_usage)
+        unless Ability.allowed?(@current_user, :read_runner_usage, scope || :global)
           return ServiceResponse.error(message: 'Insufficient permissions',
             reason: :insufficient_permissions)
         end
@@ -31,7 +52,7 @@ def execute
 
       private
 
-      attr_reader :runner_type, :from_date, :to_date, :max_item_count, :additional_group_by_columns
+      attr_reader :scope, :runner_type, :from_date, :to_date, :max_item_count, :additional_group_by_columns
 
       def clickhouse_query
         grouping_columns = ["#{bucket_column}_bucket", *additional_group_by_columns].join(', ')
@@ -66,6 +87,18 @@ def bucket_column
         raise NotImplementedError
       end
 
+      def project_ids
+        case scope
+        when ::Project
+          [scope.id]
+        when ::Group
+          # rubocop: disable CodeReuse/ActiveRecord -- the number of returned IDs is limited and the logic is specific
+          scope.all_projects.limit(MAX_PROJECTS_IN_GROUP).ids
+          # rubocop: enable CodeReuse/ActiveRecord
+        end
+      end
+      strong_memoize_attr :project_ids
+
       def select_list
         [
           *additional_group_by_columns,
@@ -87,6 +120,7 @@ def order_list
       def where_conditions
         <<~SQL
           #{'runner_type = {runner_type: UInt8} AND' if runner_type}
+          #{'project_id IN {project_ids: Array(UInt64)} AND' if project_ids}
           finished_at_bucket >= {from_date: DateTime('UTC', 6)} AND
           finished_at_bucket < {to_date: DateTime('UTC', 6)}
         SQL
@@ -96,6 +130,7 @@ def where_conditions
       def placeholders
         {
           runner_type: runner_type,
+          project_ids: project_ids&.to_json,
           from_date: format_date(from_date),
           to_date: format_date(to_date + 1), # Include jobs until the end of the day
           max_item_count: max_item_count
diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb
index 13607845cb98da224d67781783592becc3b1a30e..d9f5d993beecca05bab05c1a3a85b1f245a5314d 100644
--- a/ee/spec/policies/group_policy_spec.rb
+++ b/ee/spec/policies/group_policy_spec.rb
@@ -3794,6 +3794,35 @@ def create_member_role(member, abilities = member_role_abilities)
     end
   end
 
+  describe 'read_runner_usage' do
+    where(:licensed, :current_user, :enable_admin_mode, :clickhouse_configured, :expected) do
+      true  | ref(:admin)      | true  | true  | true
+      false | ref(:maintainer) | false | true  | false
+      true  | ref(:maintainer) | false | false | false
+      true  | ref(:maintainer) | false | true  | true
+      true  | ref(:auditor)    | false | true  | false
+      true  | ref(:developer)  | false | true  | false
+    end
+
+    with_them do
+      before do
+        stub_licensed_features(runner_performance_insights_for_namespace: licensed)
+
+        enable_admin_mode!(admin) if enable_admin_mode
+
+        allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(clickhouse_configured)
+      end
+
+      it 'matches expectation' do
+        if expected
+          is_expected.to be_allowed(:read_runner_usage)
+        else
+          is_expected.to be_disallowed(:read_runner_usage)
+        end
+      end
+    end
+  end
+
   describe 'web_hooks' do
     let(:current_user) { maintainer }
 
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index 82791962f287b9aceb4b9a4d89eb264444f79ab6..180af5e5673a327378ca8ebba44732e93e5e93d1 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -7,6 +7,8 @@
   include AdminModeHelper
   include_context 'ProjectPolicy context'
 
+  using RSpec::Parameterized::TableSyntax
+
   let(:project) { public_project }
 
   let_it_be(:auditor) { create(:user, :auditor) }
@@ -131,8 +133,6 @@
           # permissions unless the feature is disabled.
           project_features.each do |feature, permissions|
             context "with project feature #{feature}" do
-              using RSpec::Parameterized::TableSyntax
-
               where(:project_visibility, :access_level, :allowed) do
                 :public   | ProjectFeature::ENABLED  | true
                 :public   | ProjectFeature::PRIVATE  | true
@@ -193,8 +193,6 @@
     end
 
     context 'in a group project' do
-      using RSpec::Parameterized::TableSyntax
-
       let(:project) { public_project_in_group }
       let(:current_user) { maintainer }
 
@@ -413,8 +411,6 @@
     end
 
     context 'when SAML SSO is enabled for resource' do
-      using RSpec::Parameterized::TableSyntax
-
       let(:saml_provider) { create(:saml_provider, enabled: true, enforced_sso: false) }
       let(:identity) { create(:group_saml_identity, saml_provider: saml_provider) }
       let(:root_group) { saml_provider.group }
@@ -1314,8 +1310,6 @@
     let(:policy) { :publish_status_page }
 
     context 'when feature is available' do
-      using RSpec::Parameterized::TableSyntax
-
       where(:role, :admin_mode, :allowed) do
         :anonymous  | nil   | false
         :guest      | nil   | false
@@ -1607,8 +1601,6 @@
   describe ':read_code_review_analytics' do
     let(:project) { private_project }
 
-    using RSpec::Parameterized::TableSyntax
-
     where(:role, :admin_mode, :allowed) do
       :guest      | nil   | false
       :reporter   | nil   | true
@@ -1644,8 +1636,6 @@
   shared_examples 'merge request approval settings' do |admin_override_allowed = false|
     let(:project) { private_project }
 
-    using RSpec::Parameterized::TableSyntax
-
     context 'with merge request approvers rules available in license' do
       where(:role, :setting, :admin_mode, :allowed) do
         :guest      | true  | nil    | false
@@ -1706,8 +1696,6 @@
   describe ':admin_merge_request_approval_settings' do
     let(:project) { private_project }
 
-    using RSpec::Parameterized::TableSyntax
-
     where(:role, :licensed, :allowed) do
       :guest      | true  | false
       :reporter   | true  | false
@@ -1758,8 +1746,6 @@
   end
 
   describe 'Quality Management test case' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:policy) { :create_test_case }
 
     where(:role, :admin_mode, :allowed) do
@@ -1793,8 +1779,6 @@
   end
 
   shared_examples_for 'prevents CI cancellation ability' do
-    using RSpec::Parameterized::TableSyntax
-
     context 'when feature is enabled' do
       where(:restricted_role, :actual_role, :allowed) do
         :developer  | :guest      | false
@@ -1840,8 +1824,6 @@
   end
 
   describe ':compliance_framework_available' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:policy) { :admin_compliance_framework }
 
     where(:role, :feature_enabled, :admin_mode, :allowed) do
@@ -1873,8 +1855,6 @@
   end
 
   describe 'Incident Management on-call schedules' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:current_user) { public_send(role) }
     let(:admin_mode) { false }
 
@@ -1943,8 +1923,6 @@
   end
 
   describe 'Escalation Policies' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:current_user) { public_send(role) }
     let(:admin_mode) { false }
 
@@ -2309,8 +2287,6 @@
   end
 
   describe ':build_read_project' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:policy) { :build_read_project }
 
     where(:role, :project_visibility, :allowed) do
@@ -2346,8 +2322,6 @@
   end
 
   describe 'pending member permissions' do
-    using RSpec::Parameterized::TableSyntax
-
     let_it_be(:current_user) { create(:user) }
     let_it_be(:group) { create(:group, :public) }
 
@@ -2435,8 +2409,6 @@ def expect_private_project_permissions_as_if_non_member
     end
 
     describe ':read_approvers' do
-      using RSpec::Parameterized::TableSyntax
-
       let(:policy) { :read_approvers }
 
       where(:role, :allowed) do
@@ -2601,8 +2573,6 @@ def expect_private_project_permissions_as_if_non_member
   end
 
   describe 'create_objective' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:okr_policies) { [:create_objective, :create_key_result] }
 
     where(:role, :allowed) do
@@ -2646,8 +2616,6 @@ def expect_private_project_permissions_as_if_non_member
   end
 
   describe 'read_member_role' do
-    using RSpec::Parameterized::TableSyntax
-
     let_it_be_with_reload(:project) { private_project_in_group }
     let_it_be_with_reload(:current_user) { create(:user) }
 
@@ -3017,8 +2985,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'permissions for suggested reviewers bot', :saas do
-    using RSpec::Parameterized::TableSyntax
-
     let(:permissions) { [:admin_project_member, :create_resource_access_tokens] }
     let(:namespace) { build_stubbed(:namespace) }
     let(:project) { build_stubbed(:project, namespace: namespace) }
@@ -3077,6 +3043,37 @@ def create_member_role(member, abilities = member_role_abilities)
     end
   end
 
+  describe 'read_runner_usage' do
+    where(:licensed, :current_user, :project, :enable_admin_mode, :clickhouse_configured, :expected) do
+      true  | ref(:admin)      | ref(:public_project_in_group) | true  | true  | true
+      false | ref(:maintainer) | ref(:public_project_in_group) | false | true  | false
+      true  | ref(:maintainer) | ref(:public_project_in_group) | false | false | false
+      true  | ref(:maintainer) | ref(:public_project_in_group) | false | true  | true
+      true  | ref(:auditor)    | ref(:public_project_in_group) | false | true  | false
+      true  | ref(:developer)  | ref(:public_project_in_group) | false | true  | false
+      true  | ref(:admin)      | ref(:public_project)          | true  | true  | false
+      true  | ref(:maintainer) | ref(:public_project)          | false | true  | false
+    end
+
+    with_them do
+      before do
+        stub_licensed_features(runner_performance_insights_for_namespace: licensed)
+
+        enable_admin_mode!(admin) if enable_admin_mode
+
+        allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(clickhouse_configured)
+      end
+
+      it 'matches expectation' do
+        if expected
+          is_expected.to be_allowed(:read_runner_usage)
+        else
+          is_expected.to be_disallowed(:read_runner_usage)
+        end
+      end
+    end
+  end
+
   describe 'workspace creation' do
     context 'with no user' do
       let(:current_user) { nil }
@@ -3553,8 +3550,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'generate_cube_query policy' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:current_user) { owner }
 
     where(:ai_global_switch, :flag_enabled, :licensed, :allowed) do
@@ -3586,8 +3581,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'read_ai_agents' do
-    using RSpec::Parameterized::TableSyntax
-
     where(:feature_flag_enabled, :licensed_feature, :current_user, :allowed) do
       true  | true  | ref(:owner)      | true
       true  | true  | ref(:reporter)   | true
@@ -3621,8 +3614,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'write_ai_agents' do
-    using RSpec::Parameterized::TableSyntax
-
     where(:feature_flag_enabled, :licensed_feature, :current_user, :allowed) do
       true  | true  | ref(:owner)      | true
       true  | true  | ref(:reporter)   | true
@@ -3723,8 +3714,6 @@ def create_member_role(member, abilities = member_role_abilities)
     end
 
     context 'for self-managed', :with_cloud_connector do
-      using RSpec::Parameterized::TableSyntax
-
       let_it_be_with_reload(:group) { create(:group) }
       let(:policy) { :access_duo_chat }
 
@@ -3754,8 +3743,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   context 'access_duo_features' do
-    using RSpec::Parameterized::TableSyntax
-
     let(:project) { private_project }
 
     where(:current_user, :duo_features_enabled, :cs_matcher) do
@@ -3777,7 +3764,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'on_demand_scans_enabled policy' do
-    using RSpec::Parameterized::TableSyntax
     let(:current_user) { owner }
     let(:permissions) { [:read_on_demand_dast_scan, :create_on_demand_dast_scan, :edit_on_demand_dast_scan] }
 
@@ -3868,8 +3854,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'read_google_cloud_artifact_registry' do
-    using RSpec::Parameterized::TableSyntax
-
     where(:saas_feature_enabled, :current_user, :match_expected_result) do
       true  | ref(:owner)      | be_allowed(:read_google_cloud_artifact_registry)
       true  | ref(:reporter)   | be_allowed(:read_google_cloud_artifact_registry)
@@ -3891,8 +3875,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'admin_google_cloud_artifact_registry' do
-    using RSpec::Parameterized::TableSyntax
-
     where(:saas_feature_enabled, :current_user, :match_expected_result) do
       true  | ref(:owner)      | be_allowed(:admin_google_cloud_artifact_registry)
       true  | ref(:maintainer) | be_allowed(:admin_google_cloud_artifact_registry)
@@ -3954,8 +3936,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'enable_pre_receive_secret_detection' do
-    using RSpec::Parameterized::TableSyntax
-
     where(:dedicated_instance, :pre_receive_secret_detection_actor, :current_user, :match_expected_result) do
       false | project | ref(:owner)      | be_allowed(:enable_pre_receive_secret_detection)
       false | project | ref(:maintainer) | be_allowed(:enable_pre_receive_secret_detection)
@@ -3982,8 +3962,6 @@ def create_member_role(member, abilities = member_role_abilities)
   end
 
   describe 'enable_container_scanning_for_registry' do
-    using RSpec::Parameterized::TableSyntax
-
     where(:container_scanning_for_registry, :current_user, :match_expected_result) do
       true  | ref(:owner)      | be_allowed(:enable_container_scanning_for_registry)
       true  | ref(:maintainer) | be_allowed(:enable_container_scanning_for_registry)
diff --git a/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb b/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb
index 8326dbe060fc994d182e0f46c8e9624d0e2b65f9..50eb453920c393a32665bff52fe1d75f9f12dbca 100644
--- a/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb
+++ b/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb
@@ -5,21 +5,28 @@
 RSpec.describe 'Query.ciRunnerUsageByProject', :click_house, feature_category: :fleet_visibility do
   include GraphqlHelpers
 
-  let_it_be(:projects) { create_list(:project, 7) }
+  let_it_be(:group1) { create(:group) }
+  let_it_be(:projects) { create_list(:project, 7, group: group1) }
   let_it_be(:project) { projects.first }
-  let_it_be(:instance_runner) { create(:ci_runner, :instance, :with_runner_manager) }
-  let_it_be(:project_runner) { create(:ci_runner, :project, :with_runner_manager) }
+  let_it_be(:instance_runner) { create(:ci_runner, :instance) }
+  let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) }
 
   let_it_be(:admin) { create(:user, :admin) }
+  let_it_be(:group_maintainer) { create(:user, maintainer_of: group1) }
+  let_it_be(:group_developer) { create(:user, developer_of: group1) }
   let_it_be(:starting_date) { Date.new(2023) }
 
+  let(:full_path) { nil }
   let(:runner_type) { nil }
   let(:from_date) { starting_date }
   let(:to_date) { starting_date + 1.day }
   let(:projects_limit) { nil }
 
   let(:params) do
-    { runner_type: runner_type, from_date: from_date, to_date: to_date, projects_limit: projects_limit }.compact
+    {
+      full_path: full_path, runner_type: runner_type, from_date: from_date, to_date: to_date,
+      projects_limit: projects_limit
+    }.compact
   end
 
   let(:query_path) do
@@ -78,12 +85,6 @@
     include_examples "returns unauthorized or unavailable error"
   end
 
-  context "when runner_performance_insights feature is disabled" do
-    let(:licensed_feature_available) { false }
-
-    include_examples "returns unauthorized or unavailable error"
-  end
-
   context "when user is nil" do
     let(:current_user) { nil }
 
@@ -91,7 +92,7 @@
   end
 
   context "when user is not admin" do
-    let(:current_user) { create(:user) }
+    let(:current_user) { group_developer }
 
     include_examples "returns unauthorized or unavailable error"
   end
@@ -119,140 +120,290 @@
     let(:other_projects) { projects - top_projects }
 
     it "returns #{n} projects consuming most of the runner minutes and one line for the 'rest'" do
-      builds = top_projects.each_with_index.flat_map do |project, index|
-        Array.new(index + 1) do
-          stubbed_build(starting_date, 20.minutes, project: project)
+      builds = top_projects.flat_map.with_index(1) do |project, index|
+        Array.new(index) do
+          stubbed_build(starting_date, 20.minutes, project: project, runner: default_runner)
         end
       end
 
       builds += other_projects.flat_map do |project|
         Array.new(3) do
-          stubbed_build(starting_date, 2.minutes, project: project)
+          stubbed_build(starting_date, 2.minutes, project: project, runner: default_runner)
         end
       end
 
       insert_ci_builds_to_click_house(builds)
 
-      expected_result = top_projects.each_with_index.flat_map do |project, index|
+      expected_result = top_projects.flat_map.with_index(1) do |project, index|
         {
-          "project" => a_graphql_entity_for(project, :name, :full_path),
-          "ciMinutesUsed" => (20 * (index + 1)).to_s,
-          "ciBuildCount" => (index + 1).to_s
+          'project' => a_graphql_entity_for(project, :name, :full_path),
+          'ciMinutesUsed' => (20 * index).to_s,
+          'ciBuildCount' => index.to_s
         }
       end.reverse + [{
-        "project" => nil,
-        "ciMinutesUsed" => (other_projects.count * 3 * 2).to_s,
-        "ciBuildCount" => (other_projects.count * 3).to_s
+        'project' => nil,
+        'ciMinutesUsed' => (other_projects.count * 3 * 2).to_s,
+        'ciBuildCount' => (other_projects.count * 3).to_s
       }]
 
       expect(runner_usage_by_project).to match(expected_result)
     end
   end
 
-  include_examples 'returns top N projects', 5
-
-  context 'when projects_limit = 2' do
-    let(:projects_limit) { 2 }
+  shared_examples 'a working ciRunnerUsageByProject query' do
+    context "when runner_performance_insights feature is disabled" do
+      let(:licensed_feature_available) { false }
 
-    include_examples 'returns top N projects', 2
-  end
+      include_examples "returns unauthorized or unavailable error"
+    end
 
-  context 'when projects_limit > MAX_PROJECTS_LIMIT' do
-    let(:projects_limit) { 5 }
+    it 'only counts builds from from_date to to_date' do
+      builds = [from_date - 1.minute, from_date, to_date + 1.day - 1.minute, to_date + 1.day]
+        .map.with_index(1) do |finished_at, index|
+          stubbed_build(finished_at, index.minutes, runner: default_runner)
+        end
+      insert_ci_builds_to_click_house(builds)
 
-    before do
-      stub_const('Resolvers::Ci::RunnerUsageByProjectResolver::MAX_PROJECTS_LIMIT', 3)
+      expect(runner_usage_by_project).to contain_exactly({
+        'project' => a_graphql_entity_for(project, :name, :full_path),
+        'ciMinutesUsed' => '5',
+        'ciBuildCount' => '2'
+      })
     end
 
-    include_examples 'returns top N projects', 3
-  end
+    context 'when from_date and to_date are not specified' do
+      let(:from_date) { nil }
+      let(:to_date) { nil }
 
-  it 'only counts builds from from_date to to_date' do
-    builds = [from_date - 1.minute,
-      from_date,
-      to_date + 1.day - 1.minute,
-      to_date + 1.day].each_with_index.map do |finished_at, index|
-      stubbed_build(finished_at, (index + 1).minutes)
+      around do |example|
+        travel_to(Date.new(2024, 2, 1)) do
+          example.run
+        end
+      end
+
+      it 'defaults time frame to the last calendar month' do
+        from_date_default = Date.new(2024, 1, 1)
+        to_date_default = Date.new(2024, 1, 31)
+
+        builds = [
+          from_date_default - 1.minute,
+          from_date_default,
+          to_date_default + 1.day - 1.minute,
+          to_date_default + 1.day
+        ].map.with_index(1) do |finished_at, index|
+          stubbed_build(finished_at, index.minutes, runner: default_runner)
+        end
+        insert_ci_builds_to_click_house(builds)
+
+        execute_query
+        expect_graphql_errors_to_be_empty
+
+        expect(runner_usage_by_project).to contain_exactly({
+          'project' => a_graphql_entity_for(project, :name, :full_path),
+          'ciMinutesUsed' => '5',
+          'ciBuildCount' => '2'
+        })
+      end
     end
-    insert_ci_builds_to_click_house(builds)
 
-    expect(runner_usage_by_project).to contain_exactly({
-      'project' => a_graphql_entity_for(project, :name, :full_path),
-      'ciMinutesUsed' => '5',
-      'ciBuildCount' => '2'
-    })
-  end
+    context 'when runner_type is specified' do
+      let(:runner_type) { :PROJECT_TYPE }
+
+      it 'filters data by runner type' do
+        builds = [
+          stubbed_build(starting_date, 21.minutes, runner: default_runner),
+          stubbed_build(starting_date, 33.minutes, runner: project_runner)
+        ]
 
-  context 'when from_date and to_date are not specified' do
-    let(:from_date) { nil }
-    let(:to_date) { nil }
+        insert_ci_builds_to_click_house(builds)
 
-    around do |example|
-      travel_to(Date.new(2024, 2, 1)) do
-        example.run
+        expect(runner_usage_by_project).to contain_exactly({
+          'project' => a_graphql_entity_for(project, :name, :full_path),
+          'ciMinutesUsed' => '33',
+          'ciBuildCount' => '1'
+        })
       end
     end
 
-    it 'defaults time frame to the last calendar month' do
-      from_date_default = Date.new(2024, 1, 1)
-      to_date_default = Date.new(2024, 1, 31)
+    context 'when requesting more than 1 year' do
+      let(:to_date) { from_date + 13.months }
+
+      it 'returns error' do
+        execute_query
 
-      builds = [from_date_default - 1.minute,
-        from_date_default,
-        to_date_default + 1.day - 1.minute,
-        to_date_default + 1.day].each_with_index.map do |finished_at, index|
-        stubbed_build(finished_at, (index + 1).minutes)
+        expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year")
       end
-      insert_ci_builds_to_click_house(builds)
+    end
 
-      expect(runner_usage_by_project).to contain_exactly({
-        'project' => a_graphql_entity_for(project, :name, :full_path),
-        'ciMinutesUsed' => '5',
-        'ciBuildCount' => '2'
-      })
+    context 'when to_date is before from_date' do
+      let(:to_date) { from_date - 1.day }
+
+      it 'returns error' do
+        execute_query
+
+        expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year")
+      end
     end
   end
 
-  context 'when runner_type is specified' do
-    let(:runner_type) { :PROJECT_TYPE }
+  context 'when fullPath is not specified' do
+    let(:full_path) { nil }
+    let(:default_runner) { instance_runner }
 
-    it 'filters data by runner type' do
-      builds = [
-        stubbed_build(starting_date, 21.minutes),
-        stubbed_build(starting_date, 33.minutes, runner: project_runner)
-      ]
+    it_behaves_like 'a working ciRunnerUsageByProject query'
 
-      insert_ci_builds_to_click_house(builds)
+    include_examples 'returns top N projects', 5
 
-      expect(runner_usage_by_project).to contain_exactly({
-        'project' => a_graphql_entity_for(project, :name, :full_path),
-        'ciMinutesUsed' => '33',
-        'ciBuildCount' => '1'
-      })
+    context 'when projects_limit = 2' do
+      let(:projects_limit) { 2 }
+
+      include_examples 'returns top N projects', 2
     end
-  end
 
-  context 'when requesting more than 1 year' do
-    let(:to_date) { from_date + 13.months }
+    context 'when projects_limit > MAX_PROJECTS_LIMIT' do
+      let(:projects_limit) { 5 }
 
-    it 'returns error' do
-      execute_query
+      before do
+        stub_const('Resolvers::Ci::RunnerUsageByProjectResolver::MAX_PROJECTS_LIMIT', 3)
+      end
 
-      expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year")
+      include_examples 'returns top N projects', 3
     end
   end
 
-  context 'when to_date is before from_date' do
-    let(:to_date) { from_date - 1.day }
+  context 'when fullPath is specified' do
+    let(:full_path) { group1.full_path }
+    let(:current_user) { group_maintainer }
 
-    it 'returns error' do
-      execute_query
+    before do
+      stub_licensed_features(runner_performance_insights_for_namespace: licensed_feature_available)
+    end
+
+    context 'and fullPath refers to a group' do
+      let_it_be(:group1_runner) { create(:ci_runner, :group, groups: [group1]) }
+
+      let(:full_path) { group1.full_path }
+      let(:default_runner) { group1_runner }
+
+      it_behaves_like 'a working ciRunnerUsageByProject query'
+
+      include_examples 'returns top N projects', 5
+
+      context 'when projects_limit = 2' do
+        let(:projects_limit) { 2 }
+
+        include_examples 'returns top N projects', 2
+      end
+
+      context 'when projects_limit > MAX_PROJECTS_LIMIT' do
+        let(:projects_limit) { 5 }
+
+        before do
+          stub_const('Resolvers::Ci::RunnerUsageByProjectResolver::MAX_PROJECTS_LIMIT', 3)
+        end
+
+        include_examples 'returns top N projects', 3
+      end
+
+      context 'when multiple groups exist' do
+        let_it_be(:group2) { create(:group, maintainers: group_maintainer) }
+        let_it_be(:project2) { create(:project, group: group2) }
+
+        before do
+          group2_runner = create(:ci_runner, :group, groups: [group2])
+          builds = [
+            stubbed_build(1.hour.after(starting_date), 21.minutes, runner: group1_runner, project: project),
+            stubbed_build(10.hours.after(starting_date), 33.minutes, runner: group2_runner, project: project2)
+          ]
+
+          insert_ci_builds_to_click_house(builds)
+        end
+
+        context 'when full_path refers to group1' do
+          let(:full_path) { group1.full_path }
+
+          it "returns only group's projects" do
+            expect(runner_usage_by_project).to contain_exactly({
+              'project' => a_graphql_entity_for(project, :name, :full_path),
+              'ciMinutesUsed' => '21',
+              'ciBuildCount' => '1'
+            })
+          end
+        end
+
+        context 'when full_path refers to group2' do
+          let(:full_path) { group2.full_path }
 
-      expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year")
+          it "returns only group2's projects" do
+            expect(runner_usage_by_project).to contain_exactly({
+              'project' => a_graphql_entity_for(project2, :name, :full_path),
+              'ciMinutesUsed' => '33',
+              'ciBuildCount' => '1'
+            })
+          end
+        end
+      end
+
+      context 'when specified group is not accessible' do
+        let(:current_user) { group_developer }
+
+        include_examples 'returns unauthorized or unavailable error'
+      end
+    end
+
+    context 'and fullPath refers to a project' do
+      let(:full_path) { project.full_path }
+      let(:default_runner) { instance_runner }
+
+      it_behaves_like 'a working ciRunnerUsageByProject query'
+
+      context 'when multiple projects exist' do
+        let_it_be(:project2) { projects.second }
+        let_it_be(:project2_runner) { create(:ci_runner, :project, projects: [project2]) }
+
+        before do
+          builds = [
+            stubbed_build(1.hour.after(starting_date), 21.minutes, runner: project_runner, project: project),
+            stubbed_build(10.hours.after(starting_date), 33.minutes, runner: project2_runner, project: project2)
+          ]
+
+          insert_ci_builds_to_click_house(builds)
+        end
+
+        context 'when full_path refers to project' do
+          let(:full_path) { project.full_path }
+
+          it "returns only stats referring to project" do
+            expect(runner_usage_by_project).to contain_exactly({
+              'project' => a_graphql_entity_for(project, :name, :full_path),
+              'ciMinutesUsed' => '21',
+              'ciBuildCount' => '1'
+            })
+          end
+        end
+
+        context 'when full_path refers to project2' do
+          let(:full_path) { project2.full_path }
+
+          it "returns only stats referring to project2" do
+            expect(runner_usage_by_project).to contain_exactly({
+              'project' => a_graphql_entity_for(project2, :name, :full_path),
+              'ciMinutesUsed' => '33',
+              'ciBuildCount' => '1'
+            })
+          end
+        end
+      end
+
+      context 'when specified project is not accessible' do
+        let(:current_user) { group_developer }
+
+        include_examples 'returns unauthorized or unavailable error'
+      end
     end
   end
 
-  def stubbed_build(finished_at, duration, project: projects.first, runner: instance_runner)
+  def stubbed_build(finished_at, duration, runner:, project: projects.first)
     created_at = finished_at - duration
 
     build_stubbed(:ci_build,
@@ -262,7 +413,6 @@ def stubbed_build(finished_at, duration, project: projects.first, runner: instan
       queued_at: created_at,
       started_at: created_at,
       finished_at: finished_at,
-      runner: runner,
-      runner_manager: runner.runner_managers.first)
+      runner: runner)
   end
 end
diff --git a/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb b/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb
index ba3fe6e15662713ccd0aef1e629b05b5b4ec28bf..9c8c429f64c4ea63155d5facd9ed37ffe2e17045 100644
--- a/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb
+++ b/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb
@@ -2,38 +2,38 @@
 
 require 'spec_helper'
 
-RSpec.describe Ci::Runners::GetUsageByProjectService, :click_house, :enable_admin_mode,
-  feature_category: :fleet_visibility do
-  let_it_be(:user) { create(:admin) }
-  let_it_be(:instance_runner) { create(:ci_runner, :instance, :with_runner_manager) }
+RSpec.describe Ci::Runners::GetUsageByProjectService, :click_house, feature_category: :fleet_visibility do
+  let_it_be(:instance_runner) { create(:ci_runner, :instance) }
   let_it_be(:group) { create(:group) }
   let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) }
+  let_it_be(:projects) { create_list(:project, 21, group: group) }
+  let_it_be(:project1) { projects.first }
+  let_it_be(:starting_time) { DateTime.new(2023, 12, 31, 21, 0, 0) }
+  let_it_be(:developer) { create(:user, developer_of: group) }
 
   let_it_be(:builds) do
-    starting_time = DateTime.new(2023, 12, 31, 21, 0, 0)
-
-    builds = Array.new(20) do |i|
-      project = create(:project, group: group)
+    builds = projects.reject { |p| p == project1 }.map.with_index do |project, i|
       create_build(instance_runner, project, starting_time + (50.minutes * i),
         (14 + i).minutes, Ci::HasStatus::COMPLETED_STATUSES[i % Ci::HasStatus::COMPLETED_STATUSES.size])
     end
 
-    project = create(:project, group: group)
-    builds << create_build(group_runner, project, starting_time, 2.hours, :failed)
-    builds << create_build(instance_runner, project, starting_time, 10.minutes, :failed)
-    builds << create_build(instance_runner, project, starting_time, 7.minutes)
-    builds << create_build(group_runner, project, starting_time, 3.minutes, :canceled)
+    builds << create_build(group_runner, project1, starting_time, 2.hours, :failed)
+    builds << create_build(instance_runner, project1, starting_time, 10.minutes, :failed)
+    builds << create_build(instance_runner, project1, starting_time, 7.minutes)
+    builds << create_build(group_runner, project1, starting_time, 3.minutes, :canceled)
     builds
   end
 
+  let(:scope) { nil }
   let(:runner_type) { nil }
   let(:from_date) { Date.new(2023, 12, 1) }
   let(:to_date) { Date.new(2023, 12, 31) }
   let(:max_item_count) { 50 }
-  let(:additional_group_by_columns) { [] }
+  let(:additional_group_by_columns) { nil }
   let(:service) do
-    described_class.new(user, runner_type: runner_type, from_date: from_date, to_date: to_date,
-      additional_group_by_columns: additional_group_by_columns, max_item_count: max_item_count)
+    described_class.new(user,
+      **{ scope: scope, runner_type: runner_type, from_date: from_date, to_date: to_date,
+          additional_group_by_columns: additional_group_by_columns, max_item_count: max_item_count }.compact)
   end
 
   let(:result) { service.execute }
@@ -46,9 +46,7 @@
     insert_ci_builds_to_click_house(builds)
   end
 
-  context 'when user has not enough permissions' do
-    let_it_be(:user) { create(:user) }
-
+  shared_examples 'a user without required permissions' do
     it 'returns error' do
       expect(result).to be_error
       expect(result.message).to eq('Insufficient permissions')
@@ -56,105 +54,192 @@
     end
   end
 
-  context 'when ClickHouse database is not configured' do
-    before do
-      allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(false)
-    end
-
-    it 'returns error' do
-      expect(result).to be_error
-      expect(result.message).to eq('ClickHouse database is not configured')
-      expect(result.reason).to eq(:db_not_configured)
-    end
-  end
-
-  it 'contains 24 builds in source ci_finished_builds table' do
-    expect(ClickHouse::Client.select('SELECT count() FROM ci_finished_builds FINAL', :main))
-      .to contain_exactly({ 'count()' => 24 })
+  it_behaves_like 'a user without required permissions' do
+    let(:user) { developer }
   end
 
-  it 'exports usage data' do
-    is_expected.to eq([
-      { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 },
-      { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 },
-      { 'project_id_bucket' => builds[2].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 16 },
-      { 'project_id_bucket' => builds[1].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 15 },
-      { 'project_id_bucket' => builds[0].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 }
-    ])
-  end
+  context 'when user is admin', :enable_admin_mode do
+    let_it_be(:user) { create(:admin) }
 
-  context 'when additional_group_by_columns specified' do
-    let(:additional_group_by_columns) { [:status, :runner_type] }
+    context 'when ClickHouse database is not configured' do
+      before do
+        allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(false)
+      end
 
-    it 'exports usage data grouped by status and runner_type' do
-      is_expected.to eq([
-        { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 2,
-          'count_builds' => 1, 'total_duration_in_mins' => 120 },
-        { 'project_id_bucket' => builds[3].project.id, 'status' => 'skipped', 'runner_type' => 1,
-          'count_builds' => 1, 'total_duration_in_mins' => 17 },
-        { 'project_id_bucket' => builds[2].project.id, 'status' => 'canceled', 'runner_type' => 1,
-          'count_builds' => 1, 'total_duration_in_mins' => 16 },
-        { 'project_id_bucket' => builds[1].project.id, 'status' => 'failed', 'runner_type' => 1, 'count_builds' => 1,
-          'total_duration_in_mins' => 15 },
-        { 'project_id_bucket' => builds[0].project.id, 'status' => 'success', 'runner_type' => 1,
-          'count_builds' => 1, 'total_duration_in_mins' => 14 },
-        { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 1,
-          'count_builds' => 1, 'total_duration_in_mins' => 10 },
-        { 'project_id_bucket' => builds.last.project.id, 'status' => 'success', 'runner_type' => 1,
-          'count_builds' => 1, 'total_duration_in_mins' => 7 },
-        { 'project_id_bucket' => builds.last.project.id, 'status' => 'canceled', 'runner_type' => 2,
-          'count_builds' => 1, 'total_duration_in_mins' => 3 }
-      ])
+      it 'returns error' do
+        expect(result).to be_error
+        expect(result.message).to eq('ClickHouse database is not configured')
+        expect(result.reason).to eq(:db_not_configured)
+      end
     end
-  end
 
-  context 'when the number of projects exceeds max_item_count' do
-    let(:max_item_count) { 2 }
+    it 'contains 24 builds in source ci_finished_builds table' do
+      expect(ClickHouse::Client.select('SELECT count() FROM ci_finished_builds FINAL', :main))
+        .to contain_exactly({ 'count()' => 24 })
+    end
 
-    it 'exports usage data for the 2 top projects plus aggregate for other projects' do
+    it 'exports usage data' do
       is_expected.to eq([
         { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 },
         { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 },
-        { 'project_id_bucket' => nil, 'count_builds' => 3, 'total_duration_in_mins' => 45 }
+        { 'project_id_bucket' => builds[2].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 16 },
+        { 'project_id_bucket' => builds[1].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 15 },
+        { 'project_id_bucket' => builds[0].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 }
       ])
     end
-  end
 
-  context 'with group_type runner_type argument specified' do
-    let(:runner_type) { :group_type }
+    context 'when additional_group_by_columns specified' do
+      let(:additional_group_by_columns) { %i[status runner_type] }
 
-    it 'exports usage data for runners of specified type' do
-      is_expected.to contain_exactly(
-        { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 2, 'total_duration_in_mins' => 123 }
-      )
+      it 'exports usage data grouped by status and runner_type' do
+        is_expected.to eq([
+          { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 2,
+            'count_builds' => 1, 'total_duration_in_mins' => 120 },
+          { 'project_id_bucket' => builds[3].project.id, 'status' => 'skipped', 'runner_type' => 1,
+            'count_builds' => 1, 'total_duration_in_mins' => 17 },
+          { 'project_id_bucket' => builds[2].project.id, 'status' => 'canceled', 'runner_type' => 1,
+            'count_builds' => 1, 'total_duration_in_mins' => 16 },
+          { 'project_id_bucket' => builds[1].project.id, 'status' => 'failed', 'runner_type' => 1, 'count_builds' => 1,
+            'total_duration_in_mins' => 15 },
+          { 'project_id_bucket' => builds[0].project.id, 'status' => 'success', 'runner_type' => 1,
+            'count_builds' => 1, 'total_duration_in_mins' => 14 },
+          { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 1,
+            'count_builds' => 1, 'total_duration_in_mins' => 10 },
+          { 'project_id_bucket' => builds.last.project.id, 'status' => 'success', 'runner_type' => 1,
+            'count_builds' => 1, 'total_duration_in_mins' => 7 },
+          { 'project_id_bucket' => builds.last.project.id, 'status' => 'canceled', 'runner_type' => 2,
+            'count_builds' => 1, 'total_duration_in_mins' => 3 }
+        ])
+      end
     end
-  end
 
-  context 'with project_type runner_type argument specified' do
-    let(:runner_type) { :project_type }
+    context 'when the number of projects exceeds max_item_count' do
+      let(:max_item_count) { 2 }
+
+      it 'exports usage data for the 2 top projects plus aggregate for other projects' do
+        is_expected.to eq([
+          { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 },
+          { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 },
+          { 'project_id_bucket' => nil, 'count_builds' => 3, 'total_duration_in_mins' => 45 }
+        ])
+      end
+    end
+
+    context 'with group_type runner_type argument specified' do
+      let(:runner_type) { :group_type }
+
+      it 'exports usage data for runners of specified type' do
+        is_expected.to contain_exactly(
+          { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 2, 'total_duration_in_mins' => 123 }
+        )
+      end
+    end
+
+    context 'with project_type runner_type argument specified' do
+      let(:runner_type) { :project_type }
+
+      it 'exports usage data for runners of specified type' do
+        is_expected.to eq([])
+      end
+    end
+
+    context 'when dates are set' do
+      let_it_be(:project) { create(:project) }
+
+      let(:from_date) { Date.new(2024, 1, 2) }
+      let(:to_date) { Date.new(2024, 1, 2) }
 
-    it 'exports usage data for runners of specified type' do
-      is_expected.to eq([])
+      let(:build_before) { create_build(instance_runner, project, Date.new(2024, 1, 1)) }
+      let(:build_in_range) { create_build(instance_runner, project, Date.new(2024, 1, 2), 111.minutes) }
+      let(:build_overflowing_the_range) { create_build(instance_runner, project, Date.new(2024, 1, 2, 23), 61.minutes) }
+      let(:build_after) { create_build(instance_runner, project, Date.new(2024, 1, 3)) }
+
+      let(:builds) { [build_before, build_in_range, build_overflowing_the_range, build_after] }
+
+      it 'only exports usage data for builds created in the date range' do
+        is_expected.to contain_exactly(
+          { 'project_id_bucket' => project.id, 'count_builds' => 2, 'total_duration_in_mins' => 172 }
+        )
+      end
     end
   end
 
-  context 'when dates are set' do
-    let(:from_date) { Date.new(2024, 1, 2) }
-    let(:to_date) { Date.new(2024, 1, 2) }
+  context 'when scope is specified' do
+    let_it_be(:group_maintainer) { create(:user, maintainer_of: group) }
+    let_it_be(:group2) { create(:group, maintainers: [group_maintainer]) }
+    let_it_be(:group2_project) { create(:project, group: group2) }
+
+    let(:user) { group_maintainer }
+    let(:project2) { projects.second }
+
+    before do
+      stub_licensed_features(runner_performance_insights_for_namespace: true)
+    end
+
+    context 'with scope set to group' do
+      let(:scope) { group }
+
+      it_behaves_like 'a user without required permissions' do
+        let(:user) { developer }
+      end
+
+      it 'exports usage data' do
+        expect(result.errors).to be_empty
 
-    let(:project) { create(:project) }
+        expect(data).to eq([
+          { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 },
+          { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 },
+          { 'project_id_bucket' => builds[2].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 16 },
+          { 'project_id_bucket' => builds[1].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 15 },
+          { 'project_id_bucket' => builds[0].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 }
+        ])
+      end
+    end
+
+    context 'with scope set to a different group' do
+      let(:scope) { group2 }
+
+      before do
+        build = create_build(group_runner, group2_project, starting_time, 2.hours, :failed)
+
+        insert_ci_builds_to_click_house([build])
+      end
+
+      it 'exports usage data' do
+        expect(result.errors).to be_empty
+
+        expect(data).to contain_exactly(
+          { 'project_id_bucket' => group2_project.id, 'count_builds' => 1, 'total_duration_in_mins' => 120 }
+        )
+      end
+    end
+
+    context 'with scope set to project1' do
+      let(:scope) { project1 }
+
+      it_behaves_like 'a user without required permissions' do
+        let(:user) { developer }
+      end
+
+      it 'exports usage data' do
+        expect(result.errors).to be_empty
+
+        expect(data).to contain_exactly(
+          { 'project_id_bucket' => scope.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 }
+        )
+      end
+    end
 
-    let(:build_before) { create_build(instance_runner, project, Date.new(2024, 1, 1)) }
-    let(:build_in_range) { create_build(instance_runner, project, Date.new(2024, 1, 2), 111.minutes) }
-    let(:build_overflowing_the_range) { create_build(instance_runner, project, Date.new(2024, 1, 2, 23), 61.minutes) }
-    let(:build_after) { create_build(instance_runner, project, Date.new(2024, 1, 3)) }
+    context 'with scope set to project2' do
+      let(:scope) { project2 }
 
-    let(:builds) { [build_before, build_in_range, build_overflowing_the_range, build_after] }
+      it 'exports usage data' do
+        expect(result.errors).to be_empty
 
-    it 'only exports usage data for builds created in the date range' do
-      is_expected.to contain_exactly(
-        { 'project_id_bucket' => project.id, 'count_builds' => 2, 'total_duration_in_mins' => 172 }
-      )
+        expect(data).to contain_exactly(
+          { 'project_id_bucket' => scope.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 }
+        )
+      end
     end
   end
 
@@ -168,7 +253,6 @@ def create_build(runner, project, created_at, duration = 14.minutes, status = :s
       started_at: started_at,
       finished_at: started_at + duration,
       project: project,
-      runner: runner,
-      runner_manager: runner.runner_managers.first)
+      runner: runner)
   end
 end