diff options
Diffstat (limited to 'spec/lib/gitlab/usage')
7 files changed, 527 insertions, 14 deletions
diff --git a/spec/lib/gitlab/usage/docs/renderer_spec.rb b/spec/lib/gitlab/usage/docs/renderer_spec.rb new file mode 100644 index 00000000000..0677aa2d9d7 --- /dev/null +++ b/spec/lib/gitlab/usage/docs/renderer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Docs::Renderer do + describe 'contents' do + let(:dictionary_path) { Gitlab::Usage::Docs::Renderer::DICTIONARY_PATH } + let(:items) { Gitlab::Usage::MetricDefinition.definitions } + + it 'generates dictionary for given items' do + generated_dictionary = described_class.new(items).contents + generated_dictionary_keys = RDoc::Markdown + .parse(generated_dictionary) + .table_of_contents + .select { |metric_doc| metric_doc.level == 2 && !metric_doc.text.start_with?('info:') } + .map(&:text) + .map { |text| text.sub('<code>', '').sub('</code>', '') } + + expect(generated_dictionary_keys).to match_array(items.keys) + end + end +end diff --git a/spec/lib/gitlab/usage/docs/value_formatter_spec.rb b/spec/lib/gitlab/usage/docs/value_formatter_spec.rb new file mode 100644 index 00000000000..7002c76a7cf --- /dev/null +++ b/spec/lib/gitlab/usage/docs/value_formatter_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Docs::ValueFormatter do + describe '.format' do + using RSpec::Parameterized::TableSyntax + where(:key, :value, :expected_value) do + :product_group | 'growth::product intelligence' | '`growth::product intelligence`' + :data_source | 'redis' | 'Redis' + :data_source | 'ruby' | 'Ruby' + :introduced_by_url | 'http://test.com' | '[Introduced by](http://test.com)' + :tier | %w(gold premium) | 'gold, premium' + :distribution | %w(ce ee) | 'ce, ee' + :key_path | 'key.path' | '**`key.path`**' + :milestone | '13.4' | '13.4' + :status | 'data_available' | 'data_available' + end + + with_them do + subject { described_class.format(key, value) } + + it { is_expected.to eq(expected_value) } + end + end +end diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb index e101f837324..8b592838f5d 100644 --- a/spec/lib/gitlab/usage/metric_definition_spec.rb +++ b/spec/lib/gitlab/usage/metric_definition_spec.rb @@ -5,18 +5,14 @@ require 'spec_helper' RSpec.describe Gitlab::Usage::MetricDefinition do let(:attributes) do { - name: 'uuid', description: 'GitLab instance unique identifier', value_type: 'string', product_category: 'collection', - stage: 'growth', + product_stage: 'growth', status: 'data_available', default_generation: 'generation_1', - full_path: { - generation_1: 'uuid', - generation_2: 'license.uuid' - }, - group: 'group::product analytics', + key_path: 'uuid', + product_group: 'group::product analytics', time_frame: 'none', data_source: 'database', distribution: %w(ee ce), @@ -44,13 +40,12 @@ RSpec.describe Gitlab::Usage::MetricDefinition do using RSpec::Parameterized::TableSyntax where(:attribute, :value) do - :name | nil :description | nil :value_type | nil :value_type | 'test' :status | nil - :default_generation | nil - :group | nil + :key_path | nil + :product_group | nil :time_frame | nil :time_frame | '29d' :data_source | 'other' @@ -70,6 +65,20 @@ RSpec.describe Gitlab::Usage::MetricDefinition do described_class.new(path, attributes).validate! end + + context 'with skip_validation' do + it 'raise exception if skip_validation: false' do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).at_least(:once).with(instance_of(Gitlab::Usage::Metric::InvalidMetricError)) + + described_class.new(path, attributes.merge( { skip_validation: false } )).validate! + end + + it 'does not raise exception if has skip_validation: true' do + expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) + + described_class.new(path, attributes.merge( { skip_validation: true } )).validate! + end + end end end diff --git a/spec/lib/gitlab/usage/metric_spec.rb b/spec/lib/gitlab/usage/metric_spec.rb index 40671d980d6..d4a789419a4 100644 --- a/spec/lib/gitlab/usage/metric_spec.rb +++ b/spec/lib/gitlab/usage/metric_spec.rb @@ -4,15 +4,15 @@ require 'spec_helper' RSpec.describe Gitlab::Usage::Metric do describe '#definition' do - it 'returns generation_1 metric definiton' do - expect(described_class.new(default_generation_path: 'uuid').definition).to be_an(Gitlab::Usage::MetricDefinition) + it 'returns key_path metric definiton' do + expect(described_class.new(key_path: 'uuid').definition).to be_an(Gitlab::Usage::MetricDefinition) end end describe '#unflatten_default_path' do using RSpec::Parameterized::TableSyntax - where(:default_generation_path, :value, :expected_hash) do + where(:key_path, :value, :expected_hash) do 'uuid' | nil | { uuid: nil } 'uuid' | '1111' | { uuid: '1111' } 'counts.issues' | nil | { counts: { issues: nil } } @@ -21,7 +21,7 @@ RSpec.describe Gitlab::Usage::Metric do end with_them do - subject { described_class.new(default_generation_path: default_generation_path, value: value).unflatten_default_path } + subject { described_class.new(key_path: key_path, value: value).unflatten_key_path } it { is_expected.to eq(expected_hash) } end diff --git a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb new file mode 100644 index 00000000000..5469ded18f9 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redis_shared_state do + let(:entity1) { 'dfb9d2d2-f56c-4c77-8aeb-6cddc4a1f857' } + let(:entity2) { '1dd9afb2-a3ee-4de1-8ae3-a405579c8584' } + let(:entity3) { '34rfjuuy-ce56-sa35-ds34-dfer567dfrf2' } + let(:entity4) { '8b9a2671-2abf-4bec-a682-22f6a8f7bf31' } + let(:end_date) { Date.current } + let(:sources) { Gitlab::Usage::Metrics::Aggregates::Sources } + + let_it_be(:recorded_at) { Time.current.to_i } + + context 'aggregated_metrics_data' do + shared_examples 'aggregated_metrics_data' do + context 'no aggregated metric is defined' do + it 'returns empty hash' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics).and_return([]) + end + + expect(aggregated_metrics_data).to eq({}) + end + end + + context 'there are aggregated metrics defined' do + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics) + end + end + + context 'with disabled database_sourced_aggregated_metrics feature flag' do + before do + stub_feature_flags(database_sourced_aggregated_metrics: false) + end + + let(:aggregated_metrics) do + [ + { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "OR" }, + { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "OR" } + ].map(&:with_indifferent_access) + end + + it 'skips database sourced metrics', :aggregate_failures do + results = { + 'gmau_1' => 5 + } + + params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } + + expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(5) + expect(sources::PostgresHll).not_to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])) + expect(aggregated_metrics_data).to eq(results) + end + end + + context 'with AND operator' do + let(:aggregated_metrics) do + [ + { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "AND" }, + { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "AND" } + ].map(&:with_indifferent_access) + end + + it 'returns the number of unique events recorded for every metric in aggregate', :aggregate_failures do + results = { + 'gmau_1' => 2, + 'gmau_2' => 1 + } + params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } + + # gmau_1 data is as follow + # |A| => 4 + expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(4) + # |B| => 6 + expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event5')).and_return(6) + # |A + B| => 8 + expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(8) + # Exclusion inclusion principle formula to calculate intersection of 2 sets + # |A & B| = (|A| + |B|) - |A + B| => (4 + 6) - 8 => 2 + + # gmau_2 data is as follow: + # |A| => 2 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event1')).and_return(2) + # |B| => 3 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event2')).and_return(3) + # |C| => 5 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(5) + + # |A + B| => 4 therefore |A & B| = (|A| + |B|) - |A + B| => 2 + 3 - 4 => 1 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2])).and_return(4) + # |A + C| => 6 therefore |A & C| = (|A| + |C|) - |A + C| => 2 + 5 - 6 => 1 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event3])).and_return(6) + # |B + C| => 7 therefore |B & C| = (|B| + |C|) - |B + C| => 3 + 5 - 7 => 1 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event2 event3])).and_return(7) + # |A + B + C| => 8 + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(8) + # Exclusion inclusion principle formula to calculate intersection of 3 sets + # |A & B & C| = (|A & B| + |A & C| + |B & C|) - (|A| + |B| + |C|) + |A + B + C| + # (1 + 1 + 1) - (2 + 3 + 5) + 8 => 1 + + expect(aggregated_metrics_data).to eq(results) + end + end + + context 'with OR operator' do + let(:aggregated_metrics) do + [ + { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "OR" }, + { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "OR" } + ].map(&:with_indifferent_access) + end + + it 'returns the number of unique events occurred for any metric in aggregate', :aggregate_failures do + results = { + 'gmau_1' => 5, + 'gmau_2' => 3 + } + params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } + + expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(5) + expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(3) + expect(aggregated_metrics_data).to eq(results) + end + end + + context 'hidden behind feature flag' do + let(:enabled_feature_flag) { 'test_ff_enabled' } + let(:disabled_feature_flag) { 'test_ff_disabled' } + let(:aggregated_metrics) do + [ + # represents stable aggregated metrics that has been fully released + { name: 'gmau_without_ff', source: 'redis', events: %w[event3_slot event5_slot], operator: "OR" }, + # represents new aggregated metric that is under performance testing on gitlab.com + { name: 'gmau_enabled', source: 'redis', events: %w[event4], operator: "OR", feature_flag: enabled_feature_flag }, + # represents aggregated metric that is under development and shouldn't be yet collected even on gitlab.com + { name: 'gmau_disabled', source: 'redis', events: %w[event4], operator: "OR", feature_flag: disabled_feature_flag } + ].map(&:with_indifferent_access) + end + + it 'does not calculate data for aggregates with ff turned off' do + skip_feature_flags_yaml_validation + skip_default_enabled_yaml_check + stub_feature_flags(enabled_feature_flag => true, disabled_feature_flag => false) + allow(sources::RedisHll).to receive(:calculate_metrics_union).and_return(6) + + expect(aggregated_metrics_data).to eq('gmau_without_ff' => 6, 'gmau_enabled' => 6) + end + end + end + + context 'error handling' do + context 'development and test environment' do + it 'raises error when unknown aggregation operator is used' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics) + .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "SUM" }]) + end + + expect { aggregated_metrics_data }.to raise_error Gitlab::Usage::Metrics::Aggregates::UnknownAggregationOperator + end + + it 'raises error when unknown aggregation source is used' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics) + .and_return([{ name: 'gmau_1', source: 'whoami', events: %w[event1_slot], operator: "AND" }]) + end + + expect { aggregated_metrics_data }.to raise_error Gitlab::Usage::Metrics::Aggregates::UnknownAggregationSource + end + + it 're raises Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do + error = Gitlab::UsageDataCounters::HLLRedisCounter::EventError + allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error) + + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics) + .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "OR" }]) + end + + expect { aggregated_metrics_data }.to raise_error error + end + end + + context 'production' do + before do + stub_rails_env('production') + end + + it 'rescues unknown aggregation operator error' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics) + .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "SUM" }]) + end + + expect(aggregated_metrics_data).to eq('gmau_1' => -1) + end + + it 'rescues unknown aggregation source error' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics) + .and_return([{ name: 'gmau_1', source: 'whoami', events: %w[event1_slot], operator: "AND" }]) + end + + expect(aggregated_metrics_data).to eq('gmau_1' => -1) + end + + it 'rescues Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do + error = Gitlab::UsageDataCounters::HLLRedisCounter::EventError + allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error) + + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics) + .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "OR" }]) + end + + expect(aggregated_metrics_data).to eq('gmau_1' => -1) + end + end + end + end + + it 'allows for YAML aliases in aggregated metrics configs' do + expect(YAML).to receive(:safe_load).with(kind_of(String), aliases: true) + + described_class.new(recorded_at) + end + + describe '.aggregated_metrics_weekly_data' do + subject(:aggregated_metrics_data) { described_class.new(recorded_at).weekly_data } + + let(:start_date) { 7.days.ago.to_date } + + it_behaves_like 'aggregated_metrics_data' + end + + describe '.aggregated_metrics_monthly_data' do + subject(:aggregated_metrics_data) { described_class.new(recorded_at).monthly_data } + + let(:start_date) { 4.weeks.ago.to_date } + + it_behaves_like 'aggregated_metrics_data' + + context 'metrics union calls' do + let(:aggregated_metrics) do + [ + { name: 'gmau_3', source: 'redis', events: %w[event1_slot event2_slot event3_slot event5_slot], operator: "AND" } + ].map(&:with_indifferent_access) + end + + it 'caches intermediate operations', :aggregate_failures do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics) + end + + params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } + + aggregated_metrics[0][:events].each do |event| + expect(sources::RedisHll).to receive(:calculate_metrics_union) + .with(params.merge(metric_names: event)) + .once + .and_return(0) + end + + 2.upto(4) do |subset_size| + aggregated_metrics[0][:events].combination(subset_size).each do |events| + expect(sources::RedisHll).to receive(:calculate_metrics_union) + .with(params.merge(metric_names: events)) + .once + .and_return(0) + end + end + + aggregated_metrics_data + end + end + end + end +end diff --git a/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb new file mode 100644 index 00000000000..7b8be8e8bc6 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_gitlab_redis_shared_state do + let_it_be(:start_date) { 7.days.ago } + let_it_be(:end_date) { Date.current } + let_it_be(:recorded_at) { Time.current } + let_it_be(:time_period) { { created_at: (start_date..end_date) } } + let(:metric_1) { 'metric_1' } + let(:metric_2) { 'metric_2' } + let(:metric_names) { [metric_1, metric_2] } + + describe '.calculate_events_union' do + subject(:calculate_metrics_union) do + described_class.calculate_metrics_union(metric_names: metric_names, start_date: start_date, end_date: end_date, recorded_at: recorded_at) + end + + before do + [ + { + metric_name: metric_1, + time_period: time_period, + recorded_at_timestamp: recorded_at, + data: ::Gitlab::Database::PostgresHll::Buckets.new(141 => 1, 56 => 1) + }, + { + metric_name: metric_2, + time_period: time_period, + recorded_at_timestamp: recorded_at, + data: ::Gitlab::Database::PostgresHll::Buckets.new(10 => 1, 56 => 1) + } + ].each do |params| + described_class.save_aggregated_metrics(**params) + end + end + + it 'returns the number of unique events in the union of all metrics' do + expect(calculate_metrics_union.round(2)).to eq(3.12) + end + + context 'when there is no aggregated data saved' do + let(:metric_names) { [metric_1, 'i do not have any records'] } + + it 'raises error when union data is missing' do + expect { calculate_metrics_union }.to raise_error Gitlab::Usage::Metrics::Aggregates::Sources::UnionNotAvailable + end + end + + context 'when there is only one metric defined as aggregated' do + let(:metric_names) { [metric_1] } + + it 'returns the number of unique events for that metric' do + expect(calculate_metrics_union.round(2)).to eq(2.08) + end + end + end + + describe '.save_aggregated_metrics' do + subject(:save_aggregated_metrics) do + described_class.save_aggregated_metrics(metric_name: metric_1, + time_period: time_period, + recorded_at_timestamp: recorded_at, + data: data) + end + + context 'with compatible data argument' do + let(:data) { ::Gitlab::Database::PostgresHll::Buckets.new(141 => 1, 56 => 1) } + + it 'persists serialized data in Redis' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis).to receive(:set).with("#{metric_1}_weekly-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) + end + + save_aggregated_metrics + end + + context 'with monthly key' do + let_it_be(:start_date) { 4.weeks.ago } + let_it_be(:time_period) { { created_at: (start_date..end_date) } } + + it 'persists serialized data in Redis' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis).to receive(:set).with("#{metric_1}_monthly-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) + end + + save_aggregated_metrics + end + end + + context 'with all_time key' do + let_it_be(:time_period) { nil } + + it 'persists serialized data in Redis' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis).to receive(:set).with("#{metric_1}_all_time-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) + end + + save_aggregated_metrics + end + end + + context 'error handling' do + before do + allow(Gitlab::Redis::SharedState).to receive(:with).and_raise(::Redis::CommandError) + end + + it 'rescues and reraise ::Redis::CommandError for development and test environments' do + expect { save_aggregated_metrics }.to raise_error ::Redis::CommandError + end + + context 'for environment different than development' do + before do + stub_rails_env('production') + end + + it 'rescues ::Redis::CommandError' do + expect { save_aggregated_metrics }.not_to raise_error + end + end + end + end + + context 'with incompatible data argument' do + let(:data) { 1 } + + context 'for environment different than development' do + before do + stub_rails_env('production') + end + + it 'does not persist data in Redis' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis).not_to receive(:set) + end + + save_aggregated_metrics + end + end + + it 'raises error for development environment' do + expect { save_aggregated_metrics }.to raise_error /Unsupported data type/ + end + end + end +end diff --git a/spec/lib/gitlab/usage/metrics/aggregates/sources/redis_hll_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/sources/redis_hll_spec.rb new file mode 100644 index 00000000000..af2de5ea343 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/aggregates/sources/redis_hll_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::RedisHll do + describe '.calculate_events_union' do + let(:event_names) { %w[event_a event_b] } + let(:start_date) { 7.days.ago } + let(:end_date) { Date.current } + + subject(:calculate_metrics_union) do + described_class.calculate_metrics_union(metric_names: event_names, start_date: start_date, end_date: end_date, recorded_at: nil) + end + + it 'calls Gitlab::UsageDataCounters::HLLRedisCounter.calculate_events_union' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union) + .with(event_names: event_names, start_date: start_date, end_date: end_date) + .and_return(5) + + calculate_metrics_union + end + + it 'prevents from using fallback value as valid union result' do + allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_return(-1) + + expect { calculate_metrics_union }.to raise_error Gitlab::Usage::Metrics::Aggregates::Sources::UnionNotAvailable + end + end +end |