diff options
Diffstat (limited to 'lib/gitlab/analytics')
9 files changed, 420 insertions, 19 deletions
diff --git a/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder.rb b/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder.rb new file mode 100644 index 00000000000..1e50c980a3a --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module Aggregated + # rubocop: disable CodeReuse/ActiveRecord + class BaseQueryBuilder + include StageQueryHelpers + + MODEL_CLASSES = { + MergeRequest.to_s => ::Analytics::CycleAnalytics::MergeRequestStageEvent, + Issue.to_s => ::Analytics::CycleAnalytics::IssueStageEvent + }.freeze + + # Allowed params: + # * from - stage end date filter start date + # * to - stage end date filter to date + # * author_username + # * milestone_title + # * label_name (array) + # * assignee_username (array) + # * project_ids (array) + def initialize(stage:, params: {}) + @stage = stage + @params = params + @root_ancestor = stage.parent.root_ancestor + @stage_event_model = MODEL_CLASSES.fetch(stage.subject_class.to_s) + end + + def build + query = base_query + query = filter_by_stage_parent(query) + query = filter_author(query) + query = filter_milestone_ids(query) + query = filter_label_names(query) + filter_assignees(query) + end + + def filter_author(query) + return query if params[:author_username].blank? + + user = User.by_username(params[:author_username]).first + + return query.none if user.blank? + + query.authored(user) + end + + def filter_milestone_ids(query) + return query if params[:milestone_title].blank? + + milestone = MilestonesFinder + .new(group_ids: root_ancestor.self_and_descendant_ids, project_ids: root_ancestor.all_projects.select(:id), title: params[:milestone_title]) + .execute + .first + + return query.none if milestone.blank? + + query.with_milestone_id(milestone.id) + end + + def filter_label_names(query) + return query if params[:label_name].blank? + + LabelFilter.new( + stage: stage, + params: params, + project: nil, + group: root_ancestor + ).filter(query) + end + + def filter_assignees(query) + return query if params[:assignee_username].blank? + + Issuables::AssigneeFilter + .new(params: { assignee_username: params[:assignee_username] }) + .filter(query) + end + + def filter_by_stage_parent(query) + query.by_project_id(stage.parent_id) + end + + def base_query + query = stage_event_model + .by_stage_event_hash_id(stage.stage_event_hash_id) + + from = params[:from] || 30.days.ago + if in_progress? + query = query + .end_event_is_not_happened_yet + .opened_state + .start_event_timestamp_after(from) + query = query.start_event_timestamp_before(params[:to]) if params[:to] + else + query = query.end_event_timestamp_after(from) + query = query.end_event_timestamp_before(params[:to]) if params[:to] + end + + query + end + + private + + attr_reader :stage, :params, :root_ancestor, :stage_event_model + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + end +end +Gitlab::Analytics::CycleAnalytics::Aggregated::BaseQueryBuilder.prepend_mod_with('Gitlab::Analytics::CycleAnalytics::Aggregated::BaseQueryBuilder') diff --git a/lib/gitlab/analytics/cycle_analytics/aggregated/data_collector.rb b/lib/gitlab/analytics/cycle_analytics/aggregated/data_collector.rb new file mode 100644 index 00000000000..c8b11ecb4a8 --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/aggregated/data_collector.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module Aggregated + # Arguments: + # stage - an instance of CycleAnalytics::ProjectStage or CycleAnalytics::GroupStage + # params: + # current_user: an instance of User + # from: DateTime + # to: DateTime + class DataCollector + include Gitlab::Utils::StrongMemoize + + MAX_COUNT = 10001 + + delegate :serialized_records, to: :records_fetcher + + def initialize(stage:, params: {}) + @stage = stage + @params = params + end + + def median + strong_memoize(:median) { Median.new(stage: stage, query: query, params: params) } + end + + def count + strong_memoize(:count) { limit_count } + end + + def records_fetcher + strong_memoize(:records_fetcher) do + RecordsFetcher.new(stage: stage, query: query, params: params) + end + end + + private + + attr_reader :stage, :params + + def query + BaseQueryBuilder.new(stage: stage, params: params).build + end + + def limit_count + query.limit(MAX_COUNT).count + end + end + end + end + end +end diff --git a/lib/gitlab/analytics/cycle_analytics/aggregated/label_filter.rb b/lib/gitlab/analytics/cycle_analytics/aggregated/label_filter.rb new file mode 100644 index 00000000000..6d87ae91a9c --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/aggregated/label_filter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module Aggregated + # This class makes it possible to add label filters to stage event tables + class LabelFilter < Issuables::LabelFilter + extend ::Gitlab::Utils::Override + + def initialize(stage:, project:, group:, **kwargs) + @stage = stage + + super(project: project, group: group, **kwargs) + end + + private + + attr_reader :stage + + override :label_link_query + def label_link_query(target_model, label_ids: nil) + join_column = target_model.arel_table[target_model.issuable_id_column] + + LabelLink.by_target_for_exists_query(stage.subject_class.name, join_column, label_ids) + end + end + end + end + end +end diff --git a/lib/gitlab/analytics/cycle_analytics/aggregated/median.rb b/lib/gitlab/analytics/cycle_analytics/aggregated/median.rb new file mode 100644 index 00000000000..181ee20948b --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/aggregated/median.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module Aggregated + class Median + include StageQueryHelpers + + def initialize(stage:, query:, params:) + @stage = stage + @query = query + @params = params + end + + # rubocop: disable CodeReuse/ActiveRecord + def seconds + @query = @query.select(median_duration_in_seconds.as('median')).reorder(nil) + result = @query.take || {} + + result['median'] || nil + end + # rubocop: enable CodeReuse/ActiveRecord + + def days + seconds ? seconds.fdiv(1.day) : nil + end + + private + + attr_reader :stage, :query, :params + end + end + end + end +end diff --git a/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher.rb b/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher.rb new file mode 100644 index 00000000000..7dce757cdc8 --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module Aggregated + class RecordsFetcher + include Gitlab::Utils::StrongMemoize + include StageQueryHelpers + + MAX_RECORDS = 20 + + MAPPINGS = { + Issue => { + serializer_class: AnalyticsIssueSerializer, + includes_for_query: { project: { namespace: [:route] }, author: [] }, + columns_for_select: %I[title iid id created_at author_id project_id] + }, + MergeRequest => { + serializer_class: AnalyticsMergeRequestSerializer, + includes_for_query: { target_project: [:namespace], author: [] }, + columns_for_select: %I[title iid id created_at author_id state_id target_project_id] + } + }.freeze + + def initialize(stage:, query:, params: {}) + @stage = stage + @query = query + @params = params + @sort = params[:sort] || :end_event + @direction = params[:direction] || :desc + @page = params[:page] || 1 + @per_page = MAX_RECORDS + @stage_event_model = query.model + end + + def serialized_records + strong_memoize(:serialized_records) do + records = ordered_and_limited_query.select(stage_event_model.arel_table[Arel.star], duration.as('total_time')) + + yield records if block_given? + issuables_and_records = load_issuables(records) + + preload_associations(issuables_and_records.map(&:first)) + + issuables_and_records.map do |issuable, record| + project = issuable.project + attributes = issuable.attributes.merge({ + project_path: project.path, + namespace_path: project.namespace.route.path, + author: issuable.author, + total_time: record.total_time + }) + serializer.represent(attributes) + end + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def ordered_and_limited_query + sorting_options = { + end_event: { + asc: -> { query.order(end_event_timestamp: :asc) }, + desc: -> { query.order(end_event_timestamp: :desc) } + }, + duration: { + asc: -> { query.order(duration.asc) }, + desc: -> { query.order(duration.desc) } + } + } + + sort_lambda = sorting_options.dig(sort, direction) || sorting_options.dig(:end_event, :desc) + + sort_lambda.call + .page(page) + .per(per_page) + .without_count + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + attr_reader :stage, :query, :sort, :direction, :params, :page, :per_page, :stage_event_model + + delegate :subject_class, to: :stage + + def load_issuables(stage_event_records) + stage_event_records_by_issuable_id = stage_event_records.index_by(&:issuable_id) + + issuable_model = stage_event_model.issuable_model + issuables_by_id = issuable_model.id_in(stage_event_records_by_issuable_id.keys).index_by(&:id) + + stage_event_records_by_issuable_id.map do |issuable_id, record| + [issuables_by_id[issuable_id], record] if issuables_by_id[issuable_id] + end.compact + end + + def serializer + MAPPINGS.fetch(subject_class).fetch(:serializer_class).new + end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_associations(records) + ActiveRecord::Associations::Preloader.new.preload( + records, + MAPPINGS.fetch(subject_class).fetch(:includes_for_query) + ) + + records + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + end + end +end diff --git a/lib/gitlab/analytics/cycle_analytics/aggregated/stage_query_helpers.rb b/lib/gitlab/analytics/cycle_analytics/aggregated/stage_query_helpers.rb new file mode 100644 index 00000000000..f23d1832df9 --- /dev/null +++ b/lib/gitlab/analytics/cycle_analytics/aggregated/stage_query_helpers.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + module CycleAnalytics + module Aggregated + module StageQueryHelpers + def percentile_cont + percentile_cont_ordering = Arel::Nodes::UnaryOperation.new(Arel::Nodes::SqlLiteral.new('ORDER BY'), duration) + Arel::Nodes::NamedFunction.new( + 'percentile_cont(0.5) WITHIN GROUP', + [percentile_cont_ordering] + ) + end + + def duration + if in_progress? + Arel::Nodes::Subtraction.new( + Arel::Nodes::NamedFunction.new('TO_TIMESTAMP', [Time.current.to_i]), + query.model.arel_table[:start_event_timestamp] + ) + else + Arel::Nodes::Subtraction.new( + query.model.arel_table[:end_event_timestamp], + query.model.arel_table[:start_event_timestamp] + ) + end + end + + def median_duration_in_seconds + Arel::Nodes::Extract.new(percentile_cont, :epoch) + end + + def in_progress? + params[:end_event_filter] == :in_progress + end + end + end + end + end +end diff --git a/lib/gitlab/analytics/cycle_analytics/data_collector.rb b/lib/gitlab/analytics/cycle_analytics/data_collector.rb index 56179533ffb..a20481dd39e 100644 --- a/lib/gitlab/analytics/cycle_analytics/data_collector.rb +++ b/lib/gitlab/analytics/cycle_analytics/data_collector.rb @@ -23,13 +23,21 @@ module Gitlab def records_fetcher strong_memoize(:records_fetcher) do - RecordsFetcher.new(stage: stage, query: query, params: params) + if use_aggregated_data_collector? + aggregated_data_collector.records_fetcher + else + RecordsFetcher.new(stage: stage, query: query, params: params) + end end end def median strong_memoize(:median) do - Median.new(stage: stage, query: query, params: params) + if use_aggregated_data_collector? + aggregated_data_collector.median + else + Median.new(stage: stage, query: query, params: params) + end end end @@ -41,7 +49,11 @@ module Gitlab def count strong_memoize(:count) do - limit_count + if use_aggregated_data_collector? + aggregated_data_collector.count + else + limit_count + end end end @@ -59,6 +71,14 @@ module Gitlab def limit_count query.limit(MAX_COUNT).count end + + def aggregated_data_collector + @aggregated_data_collector ||= Aggregated::DataCollector.new(stage: stage, params: params) + end + + def use_aggregated_data_collector? + params.fetch(:use_aggregated_data_collector, false) + end end end end diff --git a/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb b/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb index f94696e3186..140c4a300ca 100644 --- a/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb +++ b/lib/gitlab/analytics/cycle_analytics/records_fetcher.rb @@ -8,23 +8,11 @@ module Gitlab include StageQueryHelpers include Gitlab::CycleAnalytics::MetricsTables - MAX_RECORDS = 20 - - MAPPINGS = { - Issue => { - serializer_class: AnalyticsIssueSerializer, - includes_for_query: { project: { namespace: [:route] }, author: [] }, - columns_for_select: %I[title iid id created_at author_id project_id] - }, - MergeRequest => { - serializer_class: AnalyticsMergeRequestSerializer, - includes_for_query: { target_project: [:namespace], author: [] }, - columns_for_select: %I[title iid id created_at author_id state_id target_project_id] - } - }.freeze - delegate :subject_class, to: :stage + MAX_RECORDS = Gitlab::Analytics::CycleAnalytics::Aggregated::RecordsFetcher::MAX_RECORDS + MAPPINGS = Gitlab::Analytics::CycleAnalytics::Aggregated::RecordsFetcher::MAPPINGS + def initialize(stage:, query:, params: {}) @stage = stage @query = query diff --git a/lib/gitlab/analytics/cycle_analytics/request_params.rb b/lib/gitlab/analytics/cycle_analytics/request_params.rb index 94e20762368..bc9d94ef09c 100644 --- a/lib/gitlab/analytics/cycle_analytics/request_params.rb +++ b/lib/gitlab/analytics/cycle_analytics/request_params.rb @@ -79,7 +79,8 @@ module Gitlab sort: sort&.to_sym, direction: direction&.to_sym, page: page, - end_event_filter: end_event_filter.to_sym + end_event_filter: end_event_filter.to_sym, + use_aggregated_data_collector: Feature.enabled?(:use_vsa_aggregated_tables, group || project, default_enabled: :yaml) }.merge(attributes.symbolize_keys.slice(*FINDER_PARAM_NAMES)) end |