Commit e5aa7613 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'dora-metrics-rest-api' into 'master'

Project-level DORA metrics API

See merge request gitlab-org/gitlab!55823
parents 975b3d14 66a0ea9d
...@@ -10,6 +10,22 @@ module Dora ...@@ -10,6 +10,22 @@ module Dora
self.table_name = 'dora_daily_metrics' self.table_name = 'dora_daily_metrics'
INTERVAL_ALL = 'all'
INTERVAL_MONTHLY = 'monthly'
INTERVAL_DAILY = 'daily'
METRIC_DEPLOYMENT_FREQUENCY = 'deployment_frequency'
METRIC_LEAD_TIME_FOR_CHANGES = 'lead_time_for_changes'
AVAILABLE_METRICS = [METRIC_DEPLOYMENT_FREQUENCY, METRIC_LEAD_TIME_FOR_CHANGES].freeze
AVAILABLE_INTERVALS = [INTERVAL_ALL, INTERVAL_MONTHLY, INTERVAL_DAILY].freeze
scope :for_environments, -> (environments) do
where(environment: environments)
end
scope :in_range_of, -> (after, before) do
where(date: after..before)
end
class << self class << self
def refresh!(environment, date) def refresh!(environment, date)
raise ArgumentError unless environment.is_a?(::Environment) && date.is_a?(Date) raise ArgumentError unless environment.is_a?(::Environment) && date.is_a?(Date)
...@@ -38,8 +54,41 @@ module Dora ...@@ -38,8 +54,41 @@ module Dora
SQL SQL
end end
def aggregate_for!(metric, interval)
data_query = data_query_for!(metric)
case interval
when INTERVAL_ALL
select(data_query).take.data
when INTERVAL_MONTHLY
select("DATE_TRUNC('month', date)::date AS month, #{data_query}")
.group("DATE_TRUNC('month', date)")
.order('month ASC')
.map { |row| { row.month.to_s => row.data } }
when INTERVAL_DAILY
select("date, #{data_query}")
.group('date')
.order('date ASC')
.map { |row| { row.date.to_s => row.data } }
else
raise ArgumentError, 'Unknown interval'
end
end
private private
def data_query_for!(metric)
case metric
when METRIC_DEPLOYMENT_FREQUENCY
'SUM(deployment_frequency) AS data'
when METRIC_LEAD_TIME_FOR_CHANGES
# Median
'(PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY lead_time_for_changes_in_seconds)) AS data'
else
raise ArgumentError, 'Unknown metric'
end
end
# Compose a query to calculate "Deployment Frequency" of the date # Compose a query to calculate "Deployment Frequency" of the date
def deployment_frequency(environment, date) def deployment_frequency(environment, date)
deployments = Deployment.arel_table deployments = Deployment.arel_table
......
# frozen_string_literal: true
module Dora
class AggregateMetricsService < ::BaseContainerService
MAX_RANGE = 3.months / 1.day
DEFAULT_ENVIRONMENT_TIER = 'production'
DEFAULT_INTERVAL = Dora::DailyMetrics::INTERVAL_DAILY
def execute
if error = validate
return error
end
data = ::Dora::DailyMetrics
.for_environments(environments)
.in_range_of(start_date, end_date)
.aggregate_for!(metric, interval)
success(data: data)
end
private
def validate
unless (end_date - start_date) <= MAX_RANGE
return error(_("Date range must be shorter than %{max_range} days.") % { max_range: MAX_RANGE },
:bad_request)
end
unless start_date < end_date
return error(_('The start date must be ealier than the end date.'), :bad_request)
end
unless project?
return error(_('Container must be a project.'), :bad_request)
end
unless ::Dora::DailyMetrics::AVAILABLE_INTERVALS.include?(interval)
return error(_("The interval must be one of %{intervals}.") % { intervals: ::Dora::DailyMetrics::AVAILABLE_INTERVALS.join(',') },
:bad_request)
end
unless ::Dora::DailyMetrics::AVAILABLE_METRICS.include?(metric)
return error(_("The metric must be one of %{metrics}.") % { metrics: ::Dora::DailyMetrics::AVAILABLE_METRICS.join(',') },
:bad_request)
end
unless Environment.tiers[environment_tier]
return error(_("The environment tier must be one of %{environment_tiers}.") % { environment_tiers: Environment.tiers.keys.join(',') },
:bad_request)
end
unless can?(current_user, :read_dora4_analytics, container)
return error(_('You do not have permission to access dora metrics.'), :unauthorized)
end
nil
end
def environments
Environment.for_project(container).for_tier(environment_tier)
end
def project?
container.is_a?(Project)
end
def start_date
params[:start_date] || 3.months.ago.to_date
end
def end_date
params[:end_date] || Time.current.to_date
end
def environment_tier
params[:environment_tier] || DEFAULT_ENVIRONMENT_TIER
end
def interval
params[:interval] || DEFAULT_INTERVAL
end
def metric
params[:metric]
end
end
end
# frozen_string_literal: true
module API
module Dora
class Metrics < ::API::Base
feature_category :continuous_delivery
params do
requires :id, type: String, desc: 'The ID of the project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/dora/metrics' do
desc 'Fetch the project-level DORA metrics'
params do
requires :metric, type: String, desc: 'The metric type.'
optional :start_date, type: Date, desc: 'Date range to start from.'
optional :end_date, type: Date, desc: 'Date range to end at.'
optional :interval, type: String, desc: "The bucketing interval."
optional :environment_tier, type: String, desc: "The tier of the environment."
end
get do
fetch!(user_project)
end
end
end
helpers do
def fetch!(container)
not_found! unless ::Feature.enabled?(:dora_daily_metrics, container, default_enabled: :yaml)
result = ::Dora::AggregateMetricsService
.new(container: container, current_user: current_user, params: declared_params(include_missing: false))
.execute
if result[:status] == :success
present result[:data]
else
render_api_error!(result[:message], result[:http_status])
end
end
end
end
end
end
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
mount ::API::ProjectApprovalRules mount ::API::ProjectApprovalRules
mount ::API::ExternalApprovalRules mount ::API::ExternalApprovalRules
mount ::API::ProjectApprovalSettings mount ::API::ProjectApprovalSettings
mount ::API::Dora::Metrics
mount ::API::EpicIssues mount ::API::EpicIssues
mount ::API::EpicLinks mount ::API::EpicLinks
mount ::API::Epics mount ::API::Epics
......
...@@ -7,6 +7,56 @@ RSpec.describe Dora::DailyMetrics, type: :model do ...@@ -7,6 +7,56 @@ RSpec.describe Dora::DailyMetrics, type: :model do
it { is_expected.to belong_to(:environment) } it { is_expected.to belong_to(:environment) }
end end
describe '.in_range_of' do
subject { described_class.in_range_of(from, to) }
let_it_be(:daily_metrics_1) { create(:dora_daily_metrics, date: 1.day.ago.to_date) }
let_it_be(:daily_metrics_2) { create(:dora_daily_metrics, date: 3.days.ago.to_date) }
context 'when between 2 days ago and 1 day ago' do
let(:from) { 2.days.ago.to_date }
let(:to) { 1.day.ago.to_date }
it 'returns the correct metrics' do
is_expected.to eq([daily_metrics_1])
end
end
context 'when between 3 days ago and 2 days ago' do
let(:from) { 3.days.ago.to_date }
let(:to) { 2.days.ago.to_date }
it 'returns the correct metrics' do
is_expected.to eq([daily_metrics_2])
end
end
end
describe '.for_environments' do
subject { described_class.for_environments(environments) }
let_it_be(:environment_a) { create(:environment) }
let_it_be(:environment_b) { create(:environment) }
let_it_be(:daily_metrics_a) { create(:dora_daily_metrics, environment: environment_a) }
let_it_be(:daily_metrics_b) { create(:dora_daily_metrics, environment: environment_b) }
context 'when targeting environment A only' do
let(:environments) { environment_a }
it 'returns the entry of environment A' do
is_expected.to eq([daily_metrics_a])
end
end
context 'when targeting environment B only' do
let(:environments) { environment_b }
it 'returns the entry of environment B' do
is_expected.to eq([daily_metrics_b])
end
end
end
describe '.refresh!' do describe '.refresh!' do
subject { described_class.refresh!(environment, date) } subject { described_class.refresh!(environment, date) }
...@@ -107,4 +157,115 @@ RSpec.describe Dora::DailyMetrics, type: :model do ...@@ -107,4 +157,115 @@ RSpec.describe Dora::DailyMetrics, type: :model do
end end
end end
end end
describe '.aggregate_for!' do
subject { described_class.aggregate_for!(metric, interval) }
around do |example|
freeze_time do
example.run
end
end
context 'when metric is deployment frequency' do
before_all do
create(:dora_daily_metrics, deployment_frequency: 3, date: '2021-01-01')
create(:dora_daily_metrics, deployment_frequency: 3, date: '2021-01-01')
create(:dora_daily_metrics, deployment_frequency: 2, date: '2021-01-02')
create(:dora_daily_metrics, deployment_frequency: 2, date: '2021-01-02')
create(:dora_daily_metrics, deployment_frequency: 1, date: '2021-01-03')
create(:dora_daily_metrics, deployment_frequency: 1, date: '2021-01-03')
create(:dora_daily_metrics, deployment_frequency: nil, date: '2021-01-04')
end
let(:metric) { described_class::METRIC_DEPLOYMENT_FREQUENCY }
context 'when interval is all' do
let(:interval) { described_class::INTERVAL_ALL }
it 'aggregates the rows' do
is_expected.to eq(12)
end
end
context 'when interval is monthly' do
let(:interval) { described_class::INTERVAL_MONTHLY }
it 'aggregates the rows' do
is_expected.to eq([{ '2021-01-01' => 12 }])
end
end
context 'when interval is daily' do
let(:interval) { described_class::INTERVAL_DAILY }
it 'aggregates the rows' do
is_expected.to eq([{ '2021-01-01' => 6 },
{ '2021-01-02' => 4 },
{ '2021-01-03' => 2 },
{ '2021-01-04' => nil }])
end
end
context 'when interval is unknown' do
let(:interval) { 'unknown' }
it { expect { subject }.to raise_error(ArgumentError, 'Unknown interval') }
end
end
context 'when metric is lead time for changes' do
before_all do
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: 100, date: '2021-01-01')
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: 90, date: '2021-01-01')
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: 80, date: '2021-01-02')
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: 70, date: '2021-01-02')
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: 60, date: '2021-01-03')
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: 50, date: '2021-01-03')
create(:dora_daily_metrics, lead_time_for_changes_in_seconds: nil, date: '2021-01-04')
end
let(:metric) { described_class::METRIC_LEAD_TIME_FOR_CHANGES }
context 'when interval is all' do
let(:interval) { described_class::INTERVAL_ALL }
it 'calculates the median' do
is_expected.to eq(75)
end
end
context 'when interval is monthly' do
let(:interval) { described_class::INTERVAL_MONTHLY }
it 'calculates the median' do
is_expected.to eq([{ '2021-01-01' => 75 }])
end
end
context 'when interval is daily' do
let(:interval) { described_class::INTERVAL_DAILY }
it 'calculates the median' do
is_expected.to eq([{ '2021-01-01' => 95 },
{ '2021-01-02' => 75 },
{ '2021-01-03' => 55 },
{ '2021-01-04' => nil }])
end
end
context 'when interval is unknown' do
let(:interval) { 'unknown' }
it { expect { subject }.to raise_error(ArgumentError, 'Unknown interval') }
end
end
context 'when metric is unknown' do
let(:metric) { 'unknown' }
let(:interval) { described_class::INTERVAL_ALL }
it { expect { subject }.to raise_error(ArgumentError, 'Unknown metric') }
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Dora::Metrics do
describe 'GET /projects/:id/dora/metrics' do
subject { get api(url, user), params: params }
let_it_be(:project) { create(:project) }
let_it_be(:production) { create(:environment, :production, project: project) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:url) { "/projects/#{project.id}/dora/metrics" }
let(:params) { { metric: :deployment_frequency } }
let(:user) { maintainer }
around do |example|
freeze_time do
example.run
end
end
before_all do
project.add_maintainer(maintainer)
project.add_guest(guest)
create(:dora_daily_metrics, deployment_frequency: 1, environment: production, date: '2021-01-01')
create(:dora_daily_metrics, deployment_frequency: 2, environment: production, date: '2021-01-02')
end
before do
stub_licensed_features(dora4_analytics: true)
end
it 'returns data' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq([{ '2021-01-01' => 1 },
{ '2021-01-02' => 2 }])
end
context 'when user is guest' do
let(:user) { guest }
it 'returns authorization error' do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
expect(json_response['message']).to eq('You do not have permission to access dora metrics.')
end
end
context 'when dora_daily_metrics feature flag is disabled' do
before do
stub_feature_flags(dora_daily_metrics: false)
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Dora::AggregateMetricsService do
let(:service) { described_class.new(container: container, current_user: user, params: params) }
describe '#execute' do
subject { service.execute }
around do |example|
freeze_time do
example.run
end
end
shared_examples_for 'request failure' do
it 'returns error' do
expect(subject[:status]).to eq(:error)
expect(subject[:message]).to eq(message)
expect(subject[:http_status]).to eq(http_status)
end
end
context 'when container is project' do
let_it_be(:project) { create(:project) }
let_it_be(:production) { create(:environment, :production, project: project) }
let_it_be(:staging) { create(:environment, :staging, project: project) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:container) { project }
let(:user) { maintainer }
let(:params) { { metric: 'deployment_frequency' }.merge(extra_params) }
let(:extra_params) { {} }
before_all do
project.add_maintainer(maintainer)
project.add_guest(guest)
create(:dora_daily_metrics, deployment_frequency: 2, environment: production)
create(:dora_daily_metrics, deployment_frequency: 1, environment: staging)
end
before do
stub_licensed_features(dora4_analytics: true)
end
it 'returns the aggregated data' do
expect(subject[:status]).to eq(:success)
expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 2 }])
end
context 'when interval is monthly' do
let(:extra_params) { { interval: Dora::DailyMetrics::INTERVAL_MONTHLY } }
it 'returns the aggregated data' do
expect(subject[:status]).to eq(:success)
expect(subject[:data]).to eq([{ Time.current.beginning_of_month.to_date.to_s => 2 }])
end
end
context 'when interval is all' do
let(:extra_params) { { interval: Dora::DailyMetrics::INTERVAL_ALL } }
it 'returns the aggregated data' do
expect(subject[:status]).to eq(:success)
expect(subject[:data]).to eq(2)
end
end
context 'when environment tier is changed' do
let(:extra_params) { { environment_tier: 'staging' } }
it 'returns the aggregated data' do
expect(subject[:status]).to eq(:success)
expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 1 }])
end
end
context 'when data range is too wide' do
let(:extra_params) { { start_date: 1.year.ago.to_date } }
it_behaves_like 'request failure' do
let(:message) { "Date range must be shorter than #{described_class::MAX_RANGE} days." }
let(:http_status) { :bad_request }
end
end
context 'when start date is later than end date' do
let(:extra_params) { { end_date: 1.year.ago.to_date } }
it_behaves_like 'request failure' do
let(:message) { 'The start date must be ealier than the end date.' }
let(:http_status) { :bad_request }
end
end
context 'when interval is invalid' do
let(:extra_params) { { interval: 'unknown' } }
it_behaves_like 'request failure' do
let(:message) { "The interval must be one of #{::Dora::DailyMetrics::AVAILABLE_INTERVALS.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when metric is invalid' do
let(:extra_params) { { metric: 'unknown' } }
it_behaves_like 'request failure' do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when params is empty' do
let(:params) { {} }
it_behaves_like 'request failure' do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when environment tier is invalid' do
let(:extra_params) { { environment_tier: 'unknown' } }
it_behaves_like 'request failure' do
let(:message) { "The environment tier must be one of #{Environment.tiers.keys.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when guest user' do
let(:user) { guest }
it_behaves_like 'request failure' do
let(:message) { 'You do not have permission to access dora metrics.' }
let(:http_status) { :unauthorized }
end
end
end
context 'when container is group' do
let_it_be(:group) { create(:group) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:container) { group }
let(:user) { maintainer }
let(:params) { { metric: 'deployment_frequency' } }
it_behaves_like 'request failure' do
let(:message) { 'Container must be a project.' }
let(:http_status) { :bad_request }
end
end
end
end
...@@ -7993,6 +7993,9 @@ msgstr "" ...@@ -7993,6 +7993,9 @@ msgstr ""
msgid "Container does not exist" msgid "Container does not exist"
msgstr "" msgstr ""
msgid "Container must be a project."
msgstr ""
msgid "Container registry images" msgid "Container registry images"
msgstr "" msgstr ""
...@@ -9750,6 +9753,9 @@ msgstr "" ...@@ -9750,6 +9753,9 @@ msgstr ""
msgid "Date range is greater than %{quarter_days} days" msgid "Date range is greater than %{quarter_days} days"
msgstr "" msgstr ""
msgid "Date range must be shorter than %{max_range} days."
msgstr ""
msgid "Day of month" msgid "Day of month"
msgstr "" msgstr ""
...@@ -29851,6 +29857,9 @@ msgstr "" ...@@ -29851,6 +29857,9 @@ msgstr ""
msgid "The download link will expire in 24 hours." msgid "The download link will expire in 24 hours."
msgstr "" msgstr ""
msgid "The environment tier must be one of %{environment_tiers}."
msgstr ""
msgid "The errors we encountered were:" msgid "The errors we encountered were:"
msgstr "" msgstr ""
...@@ -29925,6 +29934,9 @@ msgstr "" ...@@ -29925,6 +29934,9 @@ msgstr ""
msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination." msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
msgstr "" msgstr ""
msgid "The interval must be one of %{intervals}."
msgstr ""
msgid "The invitation could not be accepted." msgid "The invitation could not be accepted."
msgstr "" msgstr ""
...@@ -29985,6 +29997,9 @@ msgstr "" ...@@ -29985,6 +29997,9 @@ msgstr ""
msgid "The merge request can now be merged." msgid "The merge request can now be merged."
msgstr "" msgstr ""
msgid "The metric must be one of %{metrics}."
msgstr ""
msgid "The name \"%{name}\" is already taken in this directory." msgid "The name \"%{name}\" is already taken in this directory."
msgstr "" msgstr ""
...@@ -30108,6 +30123,9 @@ msgstr "" ...@@ -30108,6 +30123,9 @@ msgstr ""
msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
msgstr "" msgstr ""
msgid "The start date must be ealier than the end date."
msgstr ""
msgid "The status of the table below only applies to the default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. Once you've enabled a scan for the default branch, any subsequent feature branch you create will include the scan." msgid "The status of the table below only applies to the default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. Once you've enabled a scan for the default branch, any subsequent feature branch you create will include the scan."
msgstr "" msgstr ""
...@@ -34461,6 +34479,9 @@ msgstr "" ...@@ -34461,6 +34479,9 @@ msgstr ""
msgid "You do not have permission to access deployment frequencies" msgid "You do not have permission to access deployment frequencies"
msgstr "" msgstr ""
msgid "You do not have permission to access dora metrics."
msgstr ""
msgid "You do not have permission to leave this %{namespaceType}." msgid "You do not have permission to leave this %{namespaceType}."
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment