Commit 430037a8 authored by Pavel Shutsin's avatar Pavel Shutsin

Expose SAST & DAST devops adoption metrics

Will be used at devops adoption page

Changelog: added
EE: true
parent c1ff62cf
......@@ -181,6 +181,8 @@ module Ci
scope :with_destroy_preloads, -> { includes(project: [:route, :statistics]) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
scope :for_project, ->(project) { where(project_id: project) }
scope :created_in_time_range, ->(from: nil, to: nil) { where(created_at: from..to) }
delegate :filename, :exists?, :open, to: :file
......
# frozen_string_literal: true
class AddDevopsAdoptionSastDast < ActiveRecord::Migration[6.1]
def change
add_column :analytics_devops_adoption_snapshots, :sast_enabled_count, :integer
add_column :analytics_devops_adoption_snapshots, :dast_enabled_count, :integer
end
end
# frozen_string_literal: true
class AddDevopsAdoptionSastDastIndexes < ActiveRecord::Migration[6.1]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
INDEX_SAST = 'index_ci_job_artifacts_sast_for_devops_adoption'
INDEX_DAST = 'index_ci_job_artifacts_dast_for_devops_adoption'
def up
add_concurrent_index :ci_job_artifacts, [:project_id, :created_at], where: "file_type = 5", name: INDEX_SAST
add_concurrent_index :ci_job_artifacts, [:project_id, :created_at], where: "file_type = 8", name: INDEX_DAST
end
def down
remove_concurrent_index_by_name :ci_job_artifacts, INDEX_SAST
remove_concurrent_index_by_name :ci_job_artifacts, INDEX_DAST
end
end
a535348229ff5e9e3c5b530ded9407df9f4308fc4d9967106bf246d7267c2a48
\ No newline at end of file
30c6316f3931075bd8b167e06af5d80b7ece65f428d1fa7602ab27b526bc8410
\ No newline at end of file
......@@ -9114,6 +9114,8 @@ CREATE TABLE analytics_devops_adoption_snapshots (
total_projects_count integer,
code_owners_used_count integer,
namespace_id integer,
sast_enabled_count integer,
dast_enabled_count integer,
CONSTRAINT check_3f472de131 CHECK ((namespace_id IS NOT NULL))
);
......@@ -22831,6 +22833,8 @@ CREATE UNIQUE INDEX index_ci_group_variables_on_group_id_and_key_and_environment
CREATE UNIQUE INDEX index_ci_instance_variables_on_key ON ci_instance_variables USING btree (key);
CREATE INDEX index_ci_job_artifacts_dast_for_devops_adoption ON ci_job_artifacts USING btree (project_id, created_at) WHERE (file_type = 8);
CREATE INDEX index_ci_job_artifacts_for_terraform_reports ON ci_job_artifacts USING btree (project_id, id) WHERE (file_type = 18);
CREATE INDEX index_ci_job_artifacts_id_for_terraform_reports ON ci_job_artifacts USING btree (id) WHERE (file_type = 18);
......@@ -22845,6 +22849,8 @@ CREATE INDEX index_ci_job_artifacts_on_project_id ON ci_job_artifacts USING btre
CREATE INDEX index_ci_job_artifacts_on_project_id_for_security_reports ON ci_job_artifacts USING btree (project_id) WHERE (file_type = ANY (ARRAY[5, 6, 7, 8]));
CREATE INDEX index_ci_job_artifacts_sast_for_devops_adoption ON ci_job_artifacts USING btree (project_id, created_at) WHERE (file_type = 5);
CREATE INDEX index_ci_job_token_project_scope_links_on_added_by_id ON ci_job_token_project_scope_links USING btree (added_by_id);
CREATE INDEX index_ci_job_token_project_scope_links_on_target_project_id ON ci_job_token_project_scope_links USING btree (target_project_id);
......@@ -8348,6 +8348,7 @@ Snapshot.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="devopsadoptionsnapshotcodeownersusedcount"></a>`codeOwnersUsedCount` | [`Int`](#int) | Total number of projects with existing CODEOWNERS file. |
| <a id="devopsadoptionsnapshotdastenabledcount"></a>`dastEnabledCount` | [`Int`](#int) | Total number of projects with enabled DAST. |
| <a id="devopsadoptionsnapshotdeploysucceeded"></a>`deploySucceeded` | [`Boolean!`](#boolean) | At least one deployment succeeded. |
| <a id="devopsadoptionsnapshotendtime"></a>`endTime` | [`Time!`](#time) | The end time for the snapshot where the data points were collected. |
| <a id="devopsadoptionsnapshotissueopened"></a>`issueOpened` | [`Boolean!`](#boolean) | At least one issue was opened. |
......@@ -8356,6 +8357,7 @@ Snapshot.
| <a id="devopsadoptionsnapshotpipelinesucceeded"></a>`pipelineSucceeded` | [`Boolean!`](#boolean) | At least one pipeline succeeded. |
| <a id="devopsadoptionsnapshotrecordedat"></a>`recordedAt` | [`Time!`](#time) | The time the snapshot was recorded. |
| <a id="devopsadoptionsnapshotrunnerconfigured"></a>`runnerConfigured` | [`Boolean!`](#boolean) | At least one runner was used. |
| <a id="devopsadoptionsnapshotsastenabledcount"></a>`sastEnabledCount` | [`Int`](#int) | Total number of projects with enabled SAST. |
| <a id="devopsadoptionsnapshotsecurityscansucceeded"></a>`securityScanSucceeded` | [`Boolean!`](#boolean) | At least one security scan succeeded. |
| <a id="devopsadoptionsnapshotstarttime"></a>`startTime` | [`Time!`](#time) | The start time for the snapshot where the data points were collected. |
| <a id="devopsadoptionsnapshottotalprojectscount"></a>`totalProjectsCount` | [`Int`](#int) | Total number of projects. |
......
......@@ -26,9 +26,7 @@ module Types
def latest_snapshot
BatchLoader::GraphQL.for(object.namespace_id).batch(key: :devops_adoption_latest_snapshots) do |ids, loader, _args|
snapshots = ::Analytics::DevopsAdoption::Snapshot
.latest_snapshot_for_namespace_ids(ids)
.index_by(&:namespace_id)
snapshots = ::Analytics::DevopsAdoption::Snapshot.latest_for_namespace_ids(ids).index_by(&:namespace_id)
ids.each do |id|
loader.call(id, snapshots[id])
......
......@@ -24,6 +24,10 @@ module Types
description: 'At least one security scan succeeded.'
field :code_owners_used_count, GraphQL::INT_TYPE, null: true,
description: 'Total number of projects with existing CODEOWNERS file.'
field :sast_enabled_count, GraphQL::INT_TYPE, null: true,
description: 'Total number of projects with enabled SAST.'
field :dast_enabled_count, GraphQL::INT_TYPE, null: true,
description: 'Total number of projects with enabled DAST.'
field :total_projects_count, GraphQL::INT_TYPE, null: true,
description: 'Total number of projects.'
field :recorded_at, Types::TimeType, null: false,
......
......@@ -3,6 +3,25 @@
class Analytics::DevopsAdoption::Snapshot < ApplicationRecord
include IgnorableColumns
BOOLEAN_METRICS = [
:issue_opened,
:merge_request_opened,
:merge_request_approved,
:runner_configured,
:pipeline_succeeded,
:deploy_succeeded,
:security_scan_succeeded
].freeze
NUMERIC_METRICS = [
:code_owners_used_count,
:sast_enabled_count,
:dast_enabled_count,
:total_projects_count
].freeze
ADOPTION_METRICS = BOOLEAN_METRICS + NUMERIC_METRICS
belongs_to :namespace
has_many :enabled_namespaces, foreign_key: :namespace_id, primary_key: :namespace_id
......@@ -10,27 +29,13 @@ class Analytics::DevopsAdoption::Snapshot < ApplicationRecord
validates :namespace, presence: true
validates :recorded_at, presence: true
validates :end_time, presence: true
validates :issue_opened, inclusion: { in: [true, false] }
validates :merge_request_opened, inclusion: { in: [true, false] }
validates :merge_request_approved, inclusion: { in: [true, false] }
validates :runner_configured, inclusion: { in: [true, false] }
validates :pipeline_succeeded, inclusion: { in: [true, false] }
validates :deploy_succeeded, inclusion: { in: [true, false] }
validates :security_scan_succeeded, inclusion: { in: [true, false] }
validates :total_projects_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
validates :code_owners_used_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
validates(*BOOLEAN_METRICS, inclusion: { in: [true, false] })
validates(*NUMERIC_METRICS, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true)
ignore_column :segment_id, remove_with: '14.2', remove_after: '2021-07-22'
scope :latest_snapshot_for_namespace_ids, -> (ids) do
inner_select = model
.default_scoped
.finalized
.distinct
.select("FIRST_VALUE(id) OVER (PARTITION BY namespace_id ORDER BY end_time DESC) as id")
.where(namespace_id: ids)
joins("INNER JOIN (#{inner_select.to_sql}) latest_snapshots ON latest_snapshots.id = analytics_devops_adoption_snapshots.id")
scope :latest_for_namespace_ids, -> (ids) do
finalized.for_month(1.month.before(Time.zone.now)).for_namespaces(ids)
end
scope :for_month, -> (month_date) { where(end_time: month_date.end_of_month) }
......
......@@ -9,7 +9,7 @@ module Analytics
:namespace_id,
:end_time,
:recorded_at
] + SnapshotCalculator::ADOPTION_METRICS).freeze
] + Snapshot::ADOPTION_METRICS).freeze
def initialize(snapshot:, params: {})
@snapshot = snapshot
......
---
name: analytics_devops_adoption_sastdast
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63868
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333550
milestone: '14.0'
type: development
group: group::optimize
default_enabled: true
......@@ -36,6 +36,8 @@ Gitlab::Seeder.quiet do
deploy_succeeded: booleans.sample,
security_scan_succeeded: booleans.sample,
code_owners_used_count: rand(10),
sast_enabled_count: rand(10),
dast_enabled_count: rand(10),
total_projects_count: rand(10..19),
recorded_at: [end_time + 1.day, Time.zone.now].min,
end_time: end_time
......
......@@ -5,23 +5,6 @@ module Analytics
class SnapshotCalculator
attr_reader :enabled_namespace, :range_end, :range_start, :snapshot
BOOLEAN_METRICS = [
:issue_opened,
:merge_request_opened,
:merge_request_approved,
:runner_configured,
:pipeline_succeeded,
:deploy_succeeded,
:security_scan_succeeded
].freeze
NUMERIC_METRICS = [
:code_owners_used_count,
:total_projects_count
].freeze
ADOPTION_METRICS = BOOLEAN_METRICS + NUMERIC_METRICS
def initialize(enabled_namespace:, range_end:, snapshot: nil)
@enabled_namespace = enabled_namespace
@range_end = range_end
......@@ -32,11 +15,11 @@ module Analytics
def calculate
params = { recorded_at: Time.zone.now, end_time: range_end, namespace: enabled_namespace.namespace }
BOOLEAN_METRICS.each do |metric|
Snapshot::BOOLEAN_METRICS.each do |metric|
params[metric] = snapshot&.public_send(metric) || send(metric) # rubocop:disable GitlabSecurity/PublicSend
end
NUMERIC_METRICS.each do |metric|
Snapshot::NUMERIC_METRICS.each do |metric|
params[metric] = send(metric) # rubocop:disable GitlabSecurity/PublicSend
end
......@@ -111,6 +94,28 @@ module Analytics
!Gitlab::CodeOwners::Loader.new(project, project.default_branch || 'HEAD').empty_code_owners?
end
end
# rubocop: disable CodeReuse/ActiveRecord
def sast_enabled_count
return unless Feature.enabled?(:analytics_devops_adoption_sastdast, enabled_namespace.namespace, default_enabled: :yaml)
Ci::JobArtifact.sast_reports
.for_project(snapshot_project_ids)
.created_in_time_range(from: range_start, to: range_end)
.select(:project_id).distinct.count
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def dast_enabled_count
return unless Feature.enabled?(:analytics_devops_adoption_sastdast, enabled_namespace.namespace, default_enabled: :yaml)
Ci::JobArtifact.dast_reports
.for_project(snapshot_project_ids)
.created_in_time_range(from: range_start, to: range_end)
.select(:project_id).distinct.count
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
......@@ -166,6 +166,29 @@ RSpec.describe Analytics::DevopsAdoption::SnapshotCalculator do
end
end
shared_examples 'calculates artifact type count' do |type|
it 'returns number of projects with at least 1 sast CI artifact created in given period' do
create(:ee_ci_job_artifact, type, project: project, created_at: 1.year.before(range_end))
create(:ee_ci_job_artifact, type, project: project, created_at: 1.day.before(range_end))
create(:ee_ci_job_artifact, type, project: subproject, created_at: 1.week.before(range_end))
create(:ee_ci_job_artifact, type, created_at: 1.week.before(range_end))
expect(subject).to eq 2
end
end
describe 'sast_enabled_count' do
subject { data[:sast_enabled_count] }
include_examples 'calculates artifact type count', :sast
end
describe 'dast_enabled_count' do
subject { data[:dast_enabled_count] }
include_examples 'calculates artifact type count', :dast
end
context 'when snapshot already exists' do
subject(:data) { described_class.new(enabled_namespace: enabled_namespace, range_end: range_end, snapshot: snapshot).calculate }
......
......@@ -9,19 +9,21 @@ RSpec.describe Analytics::DevopsAdoption::Snapshot, type: :model do
it { is_expected.to validate_presence_of(:recorded_at) }
it { is_expected.to validate_presence_of(:end_time) }
describe '.latest_snapshot_for_namespace_ids' do
it 'returns the latest finalized snapshot for the given namespace ids based on snapshot end_time' do
group1 = create(:group)
group1_latest_snapshot = create(:devops_adoption_snapshot, namespace: group1, end_time: 1.week.ago, recorded_at: 1.day.ago)
create(:devops_adoption_snapshot, namespace: group1, end_time: 2.weeks.ago, recorded_at: 1.day.ago)
describe '.latest_for_namespace_ids' do
it 'returns for previous month finalized snapshot for the given namespace ids based on snapshot end_time' do
freeze_time do
group1 = create(:group)
group1_latest_snapshot = create(:devops_adoption_snapshot, namespace: group1, end_time: 1.month.ago.end_of_month, recorded_at: 1.day.ago)
create(:devops_adoption_snapshot, namespace: group1, end_time: 2.months.ago.end_of_month, recorded_at: 1.day.ago)
group2 = create(:group)
group2_latest_snapshot = create(:devops_adoption_snapshot, namespace: group2, end_time: 1.year.ago, recorded_at: 1.day.ago)
create(:devops_adoption_snapshot, namespace: group2, end_time: 2.years.ago, recorded_at: 1.day.ago)
group2 = create(:group)
create(:devops_adoption_snapshot, namespace: group2, end_time: 1.year.ago.end_of_month, recorded_at: 1.day.ago)
create(:devops_adoption_snapshot, namespace: group2, end_time: 2.years.ago.end_of_month, recorded_at: 1.day.ago)
latest_snapshots = described_class.latest_snapshot_for_namespace_ids([group1.id, group2.id])
latest_snapshots = described_class.latest_for_namespace_ids([group1.id, group2.id])
expect(latest_snapshots).to match_array([group1_latest_snapshot, group2_latest_snapshot])
expect(latest_snapshots).to match_array([group1_latest_snapshot])
end
end
end
......
......@@ -12,11 +12,25 @@ RSpec.describe 'devopsAdoptionEnabledNamespaces' do
create(:devops_adoption_enabled_namespace, namespace: group, display_namespace: group)
end
let_it_be(:expected_metrics) do
result = {}
Analytics::DevopsAdoption::Snapshot::BOOLEAN_METRICS.each.with_index do |m, i|
result[m] = i.odd?
end
Analytics::DevopsAdoption::Snapshot::NUMERIC_METRICS.each do |m|
result[m] = rand(10)
end
result[:total_projects_count] += 10
result
end
let_it_be(:snapshot) do
create(:devops_adoption_snapshot, namespace: group, issue_opened: true, merge_request_opened: false)
create(:devops_adoption_snapshot, namespace: group, **expected_metrics, end_time: DateTime.parse('2021-01-31').end_of_month)
end
let(:query) do
metrics = Analytics::DevopsAdoption::Snapshot::ADOPTION_METRICS.map { |m| m.to_s.camelize(:lower) }
graphql_query_for(:devopsAdoptionEnabledNamespaces, { display_namespace_id: group.to_gid.to_s }, %(
nodes {
id
......@@ -28,13 +42,11 @@ RSpec.describe 'devopsAdoptionEnabledNamespaces' do
}
snapshots {
nodes {
issueOpened
mergeRequestOpened
#{metrics.join(' ')}
}
}
latestSnapshot {
issueOpened
mergeRequestOpened
#{metrics.join(' ')}
}
}
))
......@@ -43,23 +55,21 @@ RSpec.describe 'devopsAdoptionEnabledNamespaces' do
before do
stub_licensed_features(instance_level_devops_adoption: true, group_level_devops_adoption: true)
post_graphql(query, current_user: current_user)
travel_to(DateTime.parse('2021-02-02')) do
post_graphql(query, current_user: current_user)
end
end
it 'returns measurement objects' do
expected_snapshot = expected_metrics.transform_keys { |key| key.to_s.camelize(:lower) }
expect(graphql_data['devopsAdoptionEnabledNamespaces']['nodes']).to eq([
{
'id' => enabled_namespace.to_gid.to_s,
'namespace' => { 'name' => group.name },
'displayNamespace' => { 'name' => group.name },
'snapshots' => { 'nodes' => [{
'mergeRequestOpened' => false,
'issueOpened' => true
}] },
'latestSnapshot' => {
'mergeRequestOpened' => false,
'issueOpened' => true
}
'snapshots' => { 'nodes' => [expected_snapshot] },
'latestSnapshot' => expected_snapshot
}
])
end
......
......@@ -8,10 +8,10 @@ RSpec.describe Analytics::DevopsAdoption::Snapshots::CreateService do
let(:snapshot) { service_response.payload[:snapshot] }
let(:params) do
params = {}
Analytics::DevopsAdoption::SnapshotCalculator::BOOLEAN_METRICS.each.with_index do |attribute, i|
Analytics::DevopsAdoption::Snapshot::BOOLEAN_METRICS.each.with_index do |attribute, i|
params[attribute] = i.odd?
end
Analytics::DevopsAdoption::SnapshotCalculator::NUMERIC_METRICS.each.with_index do |attribute, i|
Analytics::DevopsAdoption::Snapshot::NUMERIC_METRICS.each.with_index do |attribute, i|
params[attribute] = i
end
......
......@@ -9,10 +9,10 @@ RSpec.describe Analytics::DevopsAdoption::Snapshots::UpdateService do
let(:params) do
params = {}
Analytics::DevopsAdoption::SnapshotCalculator::BOOLEAN_METRICS.each.with_index do |attribute, i|
Analytics::DevopsAdoption::Snapshot::BOOLEAN_METRICS.each.with_index do |attribute, i|
params[attribute] = i.odd?
end
Analytics::DevopsAdoption::SnapshotCalculator::NUMERIC_METRICS.each.with_index do |attribute, i|
Analytics::DevopsAdoption::Snapshot::NUMERIC_METRICS.each.with_index do |attribute, i|
params[attribute] = i
end
params[:recorded_at] = Time.zone.now
......
......@@ -268,6 +268,29 @@ RSpec.describe Ci::JobArtifact do
end
end
describe '.for_project' do
it 'returns artifacts only for given project(s)', :aggregate_failures do
artifact1 = create(:ci_job_artifact)
artifact2 = create(:ci_job_artifact)
create(:ci_job_artifact)
expect(described_class.for_project(artifact1.project)).to match_array([artifact1])
expect(described_class.for_project([artifact1.project, artifact2.project])).to match_array([artifact1, artifact2])
end
end
describe 'created_in_time_range' do
it 'returns artifacts created in given time range', :aggregate_failures do
artifact1 = create(:ci_job_artifact, created_at: 1.day.ago)
artifact2 = create(:ci_job_artifact, created_at: 1.month.ago)
artifact3 = create(:ci_job_artifact, created_at: 1.year.ago)
expect(described_class.created_in_time_range(from: 1.week.ago)).to match_array([artifact1])
expect(described_class.created_in_time_range(to: 1.week.ago)).to match_array([artifact2, artifact3])
expect(described_class.created_in_time_range(from: 2.months.ago, to: 1.week.ago)).to match_array([artifact2])
end
end
describe 'callbacks' do
describe '#schedule_background_upload' do
subject { create(:ci_job_artifact, :archive) }
......
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