From d45e601964aef8557d289a13c3b40a9f5b1431ac Mon Sep 17 00:00:00 2001 From: Adam Hegyi <ahegyi@gitlab.com> Date: Mon, 6 May 2024 08:02:04 +0200 Subject: [PATCH] Expose VSA metrics via GraphQL This change exposes the VSA aggregation metrics via GraphQL. Changelog: added --- .../value_streams/stage_metrics_resolver.rb | 56 ++++++++ .../value_streams/stage_metrics_type.rb | 55 ++++++++ .../value_streams/stage_type.rb | 6 + doc/api/graphql/reference/index.md | 28 ++++ .../cycle_analytics/base_query_builder.rb | 2 - .../cycle_analytics/value_streams_spec.rb | 121 ++++++++++++++++++ .../cycle_analytics/base_query_builder.rb | 1 + locale/gitlab.pot | 12 ++ .../api/graphql/project/value_streams_spec.rb | 119 ++++++++++++++++- 9 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb create mode 100644 app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb diff --git a/app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb new file mode 100644 index 0000000000000..971cd64dbaf3e --- /dev/null +++ b/app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + module CycleAnalytics + module ValueStreams + class StageMetricsResolver < BaseResolver + type ::Types::Analytics::CycleAnalytics::ValueStreams::StageMetricsType, null: true + + argument :timeframe, Types::TimeframeInputType, + required: true, + description: 'Aggregation timeframe. Filters the issue or the merge request creation time for FOSS ' \ + 'projects, and the end event timestamp for licensed projects or groups.' + + argument :assignee_usernames, [GraphQL::Types::String], + required: false, + description: 'Usernames of users assigned to the issue or the merge request.' + + argument :author_username, GraphQL::Types::String, + required: false, + description: 'Username of the author of the issue or the merge request.' + + argument :milestone_title, GraphQL::Types::String, + required: false, + description: 'Milestone applied to the issue or the merge request.' + + argument :label_names, [GraphQL::Types::String], + required: false, + description: 'Labels applied to the issue or the merge request.' + + def resolve(**args) + formatted_args = args.to_hash + timeframe = args.delete(:timeframe) + formatted_args[:created_after] = timeframe[:start] + formatted_args[:created_before] = timeframe[:end] + + if formatted_args[:assignee_usernames].present? + formatted_args[:assignee_username] = + formatted_args.delete(:assignee_usernames) + end + + formatted_args[:label_name] = formatted_args.delete(:label_names) if formatted_args[:label_names].present? + + params = Gitlab::Analytics::CycleAnalytics::RequestParams.new( + namespace: object.namespace, + current_user: current_user, + **formatted_args.compact + ) + + Gitlab::Analytics::CycleAnalytics::DataCollector.new(stage: object, params: params.to_data_collector_params) + end + end + end + end + end +end diff --git a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb new file mode 100644 index 0000000000000..89ac96bd9035e --- /dev/null +++ b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Types + module Analytics + module CycleAnalytics + module ValueStreams + # rubocop: disable Graphql/AuthorizeTypes -- # Already authorized in parent value stream type. + class StageMetricsType < BaseObject + graphql_name 'ValueStreamStageMetrics' + + field :average, + ::Types::Analytics::CycleAnalytics::MetricType, + description: 'Average duration in seconds.' + + field :count, + ::Types::Analytics::CycleAnalytics::MetricType, + description: 'Limited item count. The backend counts maximum 1000 items, ' \ + 'for free projects, and maximum 10,000 items for licensed ' \ + 'projects or licensed groups.' + + field :median, + ::Types::Analytics::CycleAnalytics::MetricType, + description: 'Median duration in seconds.' + + def count + { + value: object.count, + identifier: 'value_stream_stage_count', + title: s_('CycleAnalytics|Item count') + } + end + + def average + { + value: object.average.seconds, + identifier: 'value_stream_stage_average', + title: s_('CycleAnalytics|Average duration'), + unit: s_('CycleAnalytics|seconds') + } + end + + def median + { + value: object.median.seconds, + identifier: 'value_stream_stage_median', + title: s_('CycleAnalytics|Median duration'), + unit: s_('CycleAnalytics|seconds') + } + end + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb index a30927c8566a1..94727d064514b 100644 --- a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb +++ b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb @@ -48,6 +48,12 @@ class StageType < BaseObject null: false, description: 'HTML description of the end event.' + field :metrics, + Types::Analytics::CycleAnalytics::ValueStreams::StageMetricsType, + null: false, + resolver: Resolvers::Analytics::CycleAnalytics::ValueStreams::StageMetricsResolver, + description: 'Aggregated metrics for the given stage' + def start_event_identifier events_enum[object.start_event_identifier] end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1bc4bcf821f2a..a3b136a8eddaa 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -30987,6 +30987,34 @@ Represents a recorded measurement (object count) for the requested group. | <a id="valuestreamstagestarteventidentifier"></a>`startEventIdentifier` | [`ValueStreamStageEvent!`](#valuestreamstageevent) | Start event identifier. | | <a id="valuestreamstagestarteventlabel"></a>`startEventLabel` | [`Label`](#label) | Label associated with start event. | +#### Fields with arguments + +##### `ValueStreamStage.metrics` + +Aggregated metrics for the given stage. + +Returns [`ValueStreamStageMetrics!`](#valuestreamstagemetrics). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="valuestreamstagemetricsassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the issue or the merge request. | +| <a id="valuestreamstagemetricsauthorusername"></a>`authorUsername` | [`String`](#string) | Username of the author of the issue or the merge request. | +| <a id="valuestreamstagemetricslabelnames"></a>`labelNames` | [`[String!]`](#string) | Labels applied to the issue or the merge request. | +| <a id="valuestreamstagemetricsmilestonetitle"></a>`milestoneTitle` | [`String`](#string) | Milestone applied to the issue or the merge request. | +| <a id="valuestreamstagemetricstimeframe"></a>`timeframe` | [`Timeframe!`](#timeframe) | Aggregation timeframe. Filters the issue or the merge request creation time for FOSS projects, and the end event timestamp for licensed projects or groups. | + +### `ValueStreamStageMetrics` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="valuestreamstagemetricsaverage"></a>`average` | [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric) | Average duration in seconds. | +| <a id="valuestreamstagemetricscount"></a>`count` | [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric) | Limited item count. The backend counts maximum 1000 items, for free projects, and maximum 10,000 items for licensed projects or licensed groups. | +| <a id="valuestreamstagemetricsmedian"></a>`median` | [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric) | Median duration in seconds. | + ### `VulnerabilitiesCountByDay` Represents the count of vulnerabilities by severity on a particular day. This data is retained for 365 days. diff --git a/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb b/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb index 1ebde179d66d2..56a6137720757 100644 --- a/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb +++ b/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb @@ -13,8 +13,6 @@ def build override :build_finder_params def build_finder_params(params) super.tap do |finder_params| - finder_params.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES)) - finder_params[:project_ids] = Array(params[:project_ids]) end end diff --git a/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb b/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb index c1c36babb2deb..265fc693b303b 100644 --- a/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb +++ b/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb @@ -182,6 +182,125 @@ def perform_request expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages)).to be_empty end end + + context 'when requesting aggregated metrics' do + let_it_be(:assignee) { create(:user) } + let_it_be(:current_time) { Time.current } + let_it_be(:milestone) { create(:milestone, group: resource.root_ancestor) } + let_it_be(:filter_label) { create(:group_label, group: resource.root_ancestor) } + + let_it_be(:merge_request1) do + create(:merge_request, :unique_branches, source_project: project, created_at: current_time, + assignees: [assignee]).tap do |mr| + mr.metrics.update!(merged_at: current_time + 2.hours) + end + end + + let_it_be(:merge_request2) do + create(:merge_request, :unique_branches, source_project: project, + labels: [filter_label], + milestone: milestone, + created_at: current_time).tap do |mr| + mr.metrics.update!(merged_at: current_time + 2.hours) + end + end + + let_it_be(:merge_request3) do + create(:merge_request, :unique_branches, source_project: project, milestone: milestone, + created_at: current_time).tap do |mr| + mr.metrics.update!(merged_at: current_time + 2.hours) + end + end + + let(:query) do + <<~QUERY + query($fullPath: ID!, $valueStreamId: ID, $stageId: ID, $from: Date!, $to: Date!, $assigneeUsernames: [String!], $milestoneTitle: String, $labelNames: [String!]) { + #{resource_type}(fullPath: $fullPath) { + valueStreams(id: $valueStreamId) { + nodes { + stages(id: $stageId) { + metrics(timeframe: { start: $from, end: $to }, assigneeUsernames: $assigneeUsernames, milestoneTitle: $milestoneTitle, labelNames: $labelNames) { + count { + value + } + } + } + } + } + } + } + QUERY + end + + before do + variables.merge!({ + from: (current_time - 10.days).to_date, + to: (current_time + 10.days).to_date + }) + + Analytics::CycleAnalytics::DataLoaderService.new(group: resource.root_ancestor, + model: MergeRequest).execute + end + + subject(:record_count) do + graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages, + 0)['metrics']['count']['value'] + end + + it 'returns the correct count' do + perform_request + + expect(record_count).to eq(3) + end + + context 'when filtering for assignee' do + before do + variables[:assigneeUsernames] = [assignee.username] + end + + it 'returns the correct count' do + perform_request + + expect(record_count).to eq(1) + end + + context 'when assigneeUsernames is null' do + before do + variables[:assigneeUsernames] = nil + end + + it 'returns the correct count' do + perform_request + + expect(record_count).to eq(3) + end + end + end + + context 'when filtering for label' do + before do + variables[:labelNames] = [filter_label.name] + end + + it 'returns the correct count' do + perform_request + + expect(record_count).to eq(1) + end + end + + context 'when filtering for milestone title' do + before do + variables[:milestoneTitle] = milestone.title + end + + it 'returns the correct count' do + perform_request + + expect(record_count).to eq(2) + end + end + end end end end @@ -192,6 +311,7 @@ def perform_request let(:resource_type) { 'project' } let_it_be(:resource) { create(:project, group: create(:group)) } + let_it_be(:project) { resource } let_it_be(:namespace) { resource.project_namespace } let_it_be(:start_label) { create(:label, project: resource, title: 'Start Label') } let_it_be(:end_label) { create(:label, project: resource, title: 'End Label') } @@ -216,6 +336,7 @@ def perform_request let(:resource_type) { 'group' } let_it_be(:resource) { create(:group) } + let_it_be(:project) { create(:project, group: resource) } let_it_be(:namespace) { resource } let_it_be(:start_label) { create(:group_label, group: resource, title: 'Start Label') } let_it_be(:end_label) { create(:group_label, group: resource, title: 'End Label') } diff --git a/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb b/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb index ca8b4a3a890f0..bcfe0b92b2837 100644 --- a/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb +++ b/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb @@ -53,6 +53,7 @@ def build_finder_params(params) add_parent_model_params!(finder_params) add_time_range_params!(finder_params, params[:from], params[:to]) + finder_params.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES)) end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index eee87ef782465..1f5ed5fbe7b56 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -15879,6 +15879,9 @@ msgstr[1] "" msgid "CycleAnalytics|'%{name}' is collecting the data. This can take a few minutes." msgstr "" +msgid "CycleAnalytics|Average duration" +msgstr "" + msgid "CycleAnalytics|Average time to completion" msgstr "" @@ -15903,9 +15906,15 @@ msgstr "" msgid "CycleAnalytics|If you have recently upgraded your GitLab license from a tier without this feature, it can take up to 30 minutes for data to collect and display." msgstr "" +msgid "CycleAnalytics|Item count" +msgstr "" + msgid "CycleAnalytics|Lead time for changes" msgstr "" +msgid "CycleAnalytics|Median duration" +msgstr "" + msgid "CycleAnalytics|New value stream…" msgstr "" @@ -15959,6 +15968,9 @@ msgstr "" msgid "CycleAnalytics|project dropdown filter" msgstr "" +msgid "CycleAnalytics|seconds" +msgstr "" + msgid "DAG visualization requires at least 3 dependent jobs." msgstr "" diff --git a/spec/requests/api/graphql/project/value_streams_spec.rb b/spec/requests/api/graphql/project/value_streams_spec.rb index 0805f386ed897..3da5838596e40 100644 --- a/spec/requests/api/graphql/project/value_streams_spec.rb +++ b/spec/requests/api/graphql/project/value_streams_spec.rb @@ -95,7 +95,7 @@ def stage_id(name) end before_all do - project.add_guest(user) + project.add_developer(user) end before do @@ -156,6 +156,123 @@ def stage_id(name) expect(graphql_data_at(:project, :value_streams, :nodes, 0, :stages, :nodes)).to be_empty end end + + context 'when requesting metrics' do + let_it_be(:current_time) { Time.current } + let_it_be(:author) { create(:user) } + + let_it_be(:merge_request1) do + create(:merge_request, :unique_branches, source_project: project, created_at: '2024-02-01').tap do |mr| + mr.metrics.update!(latest_build_started_at: current_time, + latest_build_finished_at: current_time + 2.hours) + end + end + + let_it_be(:merge_request2) do + create(:merge_request, :unique_branches, author: author, source_project: project, + created_at: '2024-02-01').tap do |mr| + mr.metrics.update!(latest_build_started_at: current_time, + latest_build_finished_at: current_time + 4.hours) + end + end + + let_it_be(:merge_request3) do + create(:merge_request, :unique_branches, source_project: project, created_at: '2024-02-01').tap do |mr| + mr.metrics.update!(latest_build_started_at: current_time, + latest_build_finished_at: current_time + 5.hours) + end + end + + let(:variables) do + { + fullPath: project.full_path, + stageId: stage_id, + from: '2024-01-01', + to: '2024-03-01' + } + end + + let(:query) do + <<~QUERY + query($fullPath: ID!, $stageId: ID, $from: Date!, $to: Date!, $authorUsername: String) { + project(fullPath: $fullPath) { + valueStreams { + nodes { + name + stages(id: $stageId) { + name + + metrics(timeframe: { start: $from, end: $to }, authorUsername: $authorUsername) { + count { + value + } + average { + value + } + median { + value + } + } + } + } + } + } + } + QUERY + end + + it 'returns aggregated metrics' do + metrics = graphql_data_at(:project, :value_streams, :nodes, 0, :stages, 0, :metrics) + + expect(metrics).to eq({ + 'count' => { + 'value' => 3 + }, + 'average' => { + 'value' => (2 + 4 + 5).hours.seconds.to_i / 3 + }, + 'median' => { + 'value' => 4.hours.seconds.to_i + } + }) + end + + context 'when user has no access' do + let(:user) { create(:user) } + + it 'does not load metrics' do + expect(graphql_data_at(:project, :valueStreams)).to be_nil + end + end + + context 'when filtering is applied' do + let(:variables) do + { + fullPath: project.full_path, + stageId: stage_id, + from: '2024-01-01', + to: '2024-03-01', + authorUsername: author.username + } + end + + it 'returns the correct metrics' do + metrics = graphql_data_at(:project, :value_streams, :nodes, 0, :stages, 0, :metrics) + + expect(metrics).to eq({ + 'count' => { + 'value' => 1 + }, + 'average' => { + 'value' => 4.hours.seconds.to_i + }, + 'median' => { + 'value' => 4.hours.seconds.to_i + } + }) + end + end + end end end end -- GitLab