Skip to content
Snippets Groups Projects
Commit e14c6681 authored by euko's avatar euko :palm_tree:
Browse files

Merge branch '409871-introduce-simple-forecasting-graphql-api-part-1' into 'master'

parents a328b890 3af46ee5
No related branches found
No related tags found
3 merge requests!122597doc/gitaly: Remove references to removed metrics,!120936Draft: Debugging commit to trigger pipeline (DO NOT MERGE),!120448Add service for deployment frequency forecasting
Pipeline #867504514 passed
Showing
with 384 additions and 0 deletions
# frozen_string_literal: true
module Analytics
module Forecasting
class DeploymentFrequencyForecast < Forecast
FIT_TIMESPAN = 1.year
def source_time_series
@source_time_series ||= begin
from = FIT_TIMESPAN.ago.to_date
to = end_date
metrics = Dora::DailyMetrics.for_project_production(context)
.in_range_of(from, to)
.order(:date)
.pluck(:date, :deployment_frequency).to_h
fill_missing_values!(metrics, from: from, to: to)
end
end
private
def model_forecast(*)
super.map(&:round)
end
end
end
end
# frozen_string_literal: true
module Analytics
module Forecasting
class Forecast
include ActiveModel::Model
MINIMAL_SCORE_THRESHOLD = 0.4
attr_accessor :context, :type, :horizon
def self.for(type)
DeploymentFrequencyForecast if type == 'deployment_frequency'
end
def initialize(*args)
super
@end_date = Date.today
end
def status
good_fit? ? 'ready' : 'unavailable'
end
def values
return [] unless good_fit?
@values ||= model_forecast.map.with_index do |value, i|
[end_date + i + 1, value]
end.to_h
end
def source_time_series
raise NoMethodError, 'must be implemented in a subclass'
end
private
attr_reader :end_date
def fill_missing_values!(metrics, from:, to:)
current_date = from
while current_date <= to
metrics[current_date] ||= 0
current_date += 1
end
metrics.sort.to_h
end
def good_fit?
model && model.r2_score >= MINIMAL_SCORE_THRESHOLD
end
def model_forecast
return [] unless model
model.predict(horizon)
end
def model
@model ||= Analytics::Forecasting::HoltWintersOptimizer.model_for(source_time_series.values)
end
end
end
end
......@@ -24,6 +24,12 @@ class DailyMetrics < ApplicationRecord
where(date: after..before)
end
scope :for_project_production, -> (project) do
environment = project.environments.production.limit(1)
for_environments(environment)
end
class << self
def aggregate_for!(metrics, interval)
select_query_part = metrics.map do |metric|
......
# frozen_string_literal: true
module Analytics
module Forecasting
class BuildForecastService
include BaseServiceUtility
attr_reader :type, :context, :horizon
HORIZON_LIMIT = 90
SUPPORTED_TYPES = %w[deployment_frequency].freeze
def initialize(type:, context:, horizon:)
@type = type
@context = context
@horizon = horizon
end
def execute
error = validate
return error if error
success(forecast: Forecast.for(type).new(type: type, context: context, horizon: horizon))
end
private
def validate
unless SUPPORTED_TYPES.include?(type)
return error(
format(_("Unsupported forecast type. Supported types: %{types}"), types: SUPPORTED_TYPES),
:bad_request)
end
validate_deployment_frequency
end
def validate_deployment_frequency
if horizon > HORIZON_LIMIT
return error(
format(_("Forecast horizon must be %{max_horizon} days at the most."), max_horizon: HORIZON_LIMIT),
:bad_request)
end
return if context.is_a?(Project)
error(_("Invalid context. Project is expected."), :bad_request)
end
end
end
end
......@@ -9,6 +9,10 @@ class HoltWintersOptimizer
SEASON_LENGTH = 7 # First metrics will be weekly so we hardcode it for now.
STARTING_POINT = { alpha: 0.5, beta: 0.5, gamma: 0.5 }.freeze
def self.model_for(*args, **params)
new(*args, **params).model
end
def initialize(time_series, model_class: HoltWinters)
@time_series = time_series
@model_class = model_class
......
......@@ -51,4 +51,14 @@ def r2_score
expect(model.gamma).to be_within(0.05).of(best_params[:gamma])
end
end
describe '.model_for' do
it 'returns best fit model' do
model = described_class.model_for(time_series, model_class: model_mock)
expect(model.alpha).to be_within(0.05).of(best_params[:alpha])
expect(model.beta).to be_within(0.05).of(best_params[:beta])
expect(model.gamma).to be_within(0.05).of(best_params[:gamma])
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Analytics::Forecasting::DeploymentFrequencyForecast, feature_category: :devops_reports do
let(:model_score) { 0.4 }
let(:model_mock) do
instance_double('Analytics::Forecasting::HoltWinters', r2_score: model_score)
end
let(:forecast_type) { 'deployment_frequency' }
let(:horizon) { 30 }
let_it_be(:project) { create(:project) }
let_it_be(:production_env) { create(:environment, :production, project: project) }
subject { described_class.new(context: project, horizon: horizon, type: forecast_type) }
around do |example|
freeze_time { example.run }
end
before do
allow(Analytics::Forecasting::HoltWintersOptimizer).to receive(:model_for).and_return(model_mock)
end
describe '#source_time_series' do
let(:daily_metrics) do
[nil, 5, 6, 3, 4, nil, nil, 4, 5, 6, 4, 3]
end
let(:expected_metrics) do
(1.year.ago.to_date..Date.today).map.with_index { |date, i| [date, daily_metrics[i] || 0] }.to_h
end
before do
# Create dora metric records.
daily_metrics.each.with_index do |value, i|
next unless value
create(:dora_daily_metrics,
environment: production_env,
deployment_frequency: value,
date: 1.year.ago.to_date + i)
end
end
it 'returns deployment frequency metrics for last year with gaps filled' do
expect(subject.source_time_series).to eq(expected_metrics)
end
end
describe '#values' do
let(:model_forecast) { [1.1, 2, 3.5, 3.9] }
let(:expected_forecast) do
[1, 2, 4, 4].map.with_index { |v, i| [Date.today + i + 1, v] }.to_h
end
before do
allow(model_mock).to receive(:predict).with(horizon).and_return(model_forecast)
end
it 'returns rounded values of whatever model forecasts' do
expect(subject.values).to eq expected_forecast
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Analytics::Forecasting::Forecast, feature_category: :devops_reports do
let(:model_score) { 0.4 }
let(:model_mock) do
instance_double('Analytics::Forecasting::HoltWinters', r2_score: model_score)
end
let(:forecast_type) { 'deployment_frequency' }
let(:horizon) { 30 }
let_it_be(:project) { Project.new }
before do
allow(Analytics::Forecasting::HoltWintersOptimizer).to receive(:model_for).and_return(model_mock)
end
describe '.for' do
it 'returns corresponding classes by type' do
expect(described_class.for('deployment_frequency')).to eq(Analytics::Forecasting::DeploymentFrequencyForecast)
expect(described_class.for('something_else')).to eq(nil)
end
end
subject { described_class.for(forecast_type).new(type: forecast_type, horizon: horizon, context: project) }
describe '#status' do
context 'when model score >= 0.4' do
it 'returns "ready"' do
expect(subject.status).to eq 'ready'
end
end
context 'when model score < 0.4' do
let(:model_score) { 0.39 }
it 'returns "unavailable"' do
expect(subject.status).to eq 'unavailable'
end
end
end
describe '#source_time_series' do
it 'raises NoMethodError' do
expect do
described_class.new.source_time_series
end.to raise_error NoMethodError, 'must be implemented in a subclass'
end
end
describe '#values' do
let(:model_forecast) { (1..horizon).to_a }
before do
allow(model_mock).to receive(:predict).with(horizon).and_return(model_forecast)
end
it 'returns forecast hash with dates and model forecast values' do
freeze_time do
expect(subject.values).to be_kind_of Hash
expect(subject.values.values).to eq(model_forecast)
expect(subject.values.keys).to eq(((Date.today + 1)..(Date.today + horizon)).to_a)
end
end
end
end
......@@ -57,6 +57,28 @@
end
end
describe '.for_project_production' do
subject { described_class.for_project_production(project) }
let_it_be(:project) { create(:project) }
let_it_be(:production_metrics) do
create(:dora_daily_metrics, environment: create(:environment, :production, project: project))
end
let_it_be(:staging_metrics) do
create(:dora_daily_metrics, environment: create(:environment, :staging, project: project))
end
let_it_be(:different_production_metrics) do
create(:dora_daily_metrics, environment: create(:environment, :production))
end
it 'returns metrics for project production environment' do
is_expected.to match_array([production_metrics])
end
end
describe '.refresh!' do
subject { described_class.refresh!(environment, date.to_date) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Analytics::Forecasting::BuildForecastService, feature_category: :devops_reports do
let_it_be(:project) { create :project }
let_it_be(:production) { create :environment, project: project }
let(:type) { 'deployment_frequency' }
let(:horizon) { 30 }
let(:forecast_context) { project }
subject(:service) { described_class.new(type: type, context: forecast_context, horizon: horizon) }
describe '#execute' do
subject { service.execute }
shared_examples_for 'failure response' do |message|
it 'returns error' do
expect(subject[:status]).to eq(:error)
expect(subject[:message]).to eq(message)
expect(subject[:http_status]).to eq(:bad_request)
end
end
context 'when forecast type is not supported' do
let(:type) { 'something-invalid' }
it_behaves_like 'failure response', "Unsupported forecast type. Supported types: [\"deployment_frequency\"]"
end
context 'when horizon is too big' do
let(:horizon) { 91 }
it_behaves_like 'failure response', "Forecast horizon must be 90 days at the most."
end
context 'when context is not a project' do
let(:forecast_context) { User.new }
it_behaves_like 'failure response', "Invalid context. Project is expected."
end
it 'returns deployment frequency forecast for given horizon' do
forecast_mock = instance_double('Analytics::Forecasting::DeploymentFrequencyForecast')
expect(::Analytics::Forecasting::DeploymentFrequencyForecast).to receive(:new).and_return(forecast_mock)
expect(subject[:status]).to eq(:success)
expect(subject[:forecast]).to eq(forecast_mock)
end
end
end
......@@ -18974,6 +18974,9 @@ msgstr ""
msgid "Forbidden"
msgstr ""
 
msgid "Forecast horizon must be %{max_horizon} days at the most."
msgstr ""
msgid "Forgot your password?"
msgstr ""
 
......@@ -24210,6 +24213,9 @@ msgstr ""
msgid "Invalid URL: %{url}"
msgstr ""
 
msgid "Invalid context. Project is expected."
msgstr ""
msgid "Invalid date"
msgstr ""
 
......@@ -47888,6 +47894,9 @@ msgstr ""
msgid "Unsubscribes from this %{quick_action_target}."
msgstr ""
 
msgid "Unsupported forecast type. Supported types: %{types}"
msgstr ""
msgid "Unsupported sort value."
msgstr ""
 
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment