diff --git a/app/controllers/priv/analytics/gauges/downloads_controller.rb b/app/controllers/priv/analytics/gauges/downloads_controller.rb new file mode 100644 index 0000000000..8de1c4aa72 --- /dev/null +++ b/app/controllers/priv/analytics/gauges/downloads_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Priv::Analytics::Gauges + class DownloadsController < Priv::Analytics::BaseController + use_clickhouse + + CACHE_TTL = 10.minutes + CACHE_RACE_TTL = 1.minute + + typed_query { + param :product, type: :uuid, optional: true, as: :product_id + param :package, type: :uuid, optional: true, as: :package_id + param :release, type: :uuid, optional: true, as: :release_id + } + def show + authorize! with: Accounts::AnalyticsPolicy + + gauge = Analytics::Gauge.new( + :downloads, + **download_query, + ) + + unless gauge.valid? + render_bad_request *gauge.errors.as_jsonapi( + title: 'Bad request', + source: :parameter, + sources: { + parameters: { + product_id: 'product', + package_id: 'package', + release_id: 'release', + }, + }, + ) + + return + end + + data = Rails.cache.fetch gauge.cache_key, expires_in: CACHE_TTL, race_condition_ttl: CACHE_RACE_TTL do + gauge.as_json + end + + render json: { data: } + end + end +end diff --git a/app/controllers/priv/analytics/sparks/downloads_controller.rb b/app/controllers/priv/analytics/sparks/downloads_controller.rb new file mode 100644 index 0000000000..f398f6a51e --- /dev/null +++ b/app/controllers/priv/analytics/sparks/downloads_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Priv::Analytics::Sparks + class DownloadsController < Priv::Analytics::BaseController + use_clickhouse + + CACHE_TTL = 10.minutes + CACHE_RACE_TTL = 1.minute + + typed_query { + param :product, type: :uuid, optional: true, as: :product_id + param :package, type: :uuid, optional: true, as: :package_id + param :release, type: :uuid, optional: true, as: :release_id + param :date, type: :hash, optional: true, collapse: { format: :child_parent } do + param :start, type: :date, coerce: true + param :end, type: :date, coerce: true + end + } + def show + authorize! with: Accounts::AnalyticsPolicy + + series = Analytics::Series.new( + :downloads, + **download_query, + ) + + unless series.valid? + render_bad_request *series.errors.as_jsonapi( + title: 'Bad request', + source: :parameter, + sources: { + parameters: { + product_id: 'product', + package_id: 'package', + release_id: 'release', + start_date: 'date[start]', + end_date: 'date[end]', + }, + }, + ) + + return + end + + data = Rails.cache.fetch series.cache_key, expires_in: CACHE_TTL, race_condition_ttl: CACHE_RACE_TTL do + series.as_json + end + + render json: { data: } + end + end +end diff --git a/app/models/analytics/gauge.rb b/app/models/analytics/gauge.rb index ddb75dc6d9..868518c783 100644 --- a/app/models/analytics/gauge.rb +++ b/app/models/analytics/gauge.rb @@ -11,6 +11,7 @@ class Gauge COUNTERS = { alus: ActiveLicensedUsers, + downloads: Downloads, licenses: Licenses, machines: Machines, users: Users, diff --git a/app/models/analytics/gauge/downloads.rb b/app/models/analytics/gauge/downloads.rb new file mode 100644 index 0000000000..115bc955e4 --- /dev/null +++ b/app/models/analytics/gauge/downloads.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Analytics + class Gauge + class Downloads + def initialize(account:, environment:, product_id: nil, package_id: nil, release_id: nil) + @account = account + @environment = environment + @product_id = product_id + @package_id = package_id + @release_id = release_id + end + + def metrics = %w[downloads] + def count + event_type_ids = EventType.by_pattern('artifact.downloaded') + .collect(&:id) + return {} if + event_type_ids.empty? + + scope = EventLog::Clickhouse.for_account(account) + .for_environment(environment) + .where( + event_type_id: event_type_ids, + created_date: Date.current, + ) + .where( + 'metadata.product IS NOT NULL', + ) + + unless product_id.nil? + scope = scope.where( + Arel.sql('metadata.product.:String') => product_id, + ) + end + + unless package_id.nil? + scope = scope.where( + Arel.sql('metadata.package.:String') => package_id, + ) + end + + unless release_id.nil? + scope = scope.where( + Arel.sql('metadata.release.:String') => release_id, + ) + end + + count = scope.count + + { 'downloads' => count }.reject { _2.zero? } + end + + private + + attr_reader :account, :environment, :product_id, :package_id, :release_id + end + end +end diff --git a/app/models/analytics/leaderboard.rb b/app/models/analytics/leaderboard.rb index a4e669b3b1..4ece59842d 100644 --- a/app/models/analytics/leaderboard.rb +++ b/app/models/analytics/leaderboard.rb @@ -13,6 +13,8 @@ class Leaderboard COUNTERS = { ips: Ips, licenses: Licenses, + packages: Packages, + products: Products, urls: Urls, user_agents: UserAgents, } diff --git a/app/models/analytics/leaderboard/packages.rb b/app/models/analytics/leaderboard/packages.rb new file mode 100644 index 0000000000..4d07d69d6d --- /dev/null +++ b/app/models/analytics/leaderboard/packages.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Analytics + class Leaderboard + class Packages + def initialize(account:, environment:) + @account = account + @environment = environment + end + + def count(start_date:, end_date:, limit:) + ReleaseDownloadSpark.for_account(account) + .for_environment(environment) + .where(created_date: start_date..end_date) + .where.not(package_id: nil) + .group(:package_id) + .order(Arel.sql('sum_count DESC')) + .limit(limit) + .sum(:count) + end + + private + + attr_reader :account, :environment + end + end +end diff --git a/app/models/analytics/leaderboard/products.rb b/app/models/analytics/leaderboard/products.rb new file mode 100644 index 0000000000..f085bb9b6a --- /dev/null +++ b/app/models/analytics/leaderboard/products.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Analytics + class Leaderboard + class Products + def initialize(account:, environment:) + @account = account + @environment = environment + end + + def count(start_date:, end_date:, limit:) + ReleaseDownloadSpark.for_account(account) + .for_environment(environment) + .where(created_date: start_date..end_date) + .group(:product_id) + .order(Arel.sql('sum_count DESC')) + .limit(limit) + .sum(:count) + end + + private + + attr_reader :account, :environment + end + end +end diff --git a/app/models/analytics/series.rb b/app/models/analytics/series.rb index db033ee638..e9173c58f1 100644 --- a/app/models/analytics/series.rb +++ b/app/models/analytics/series.rb @@ -7,6 +7,7 @@ class Series Bucket = Data.define(:metric, :date, :count) COUNTERS = { + downloads: Sparks::Downloads, events: Events, requests: Requests, sparks: Sparks, diff --git a/app/models/analytics/series/sparks/downloads.rb b/app/models/analytics/series/sparks/downloads.rb new file mode 100644 index 0000000000..dda1946e0d --- /dev/null +++ b/app/models/analytics/series/sparks/downloads.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Analytics + class Series + class Sparks + class Downloads + def initialize(account:, environment:, product_id: nil, package_id: nil, release_id: nil, realtime: true, **) + @account = account + @environment = environment + @product_id = product_id + @package_id = package_id + @release_id = release_id + @realtime = realtime + end + + def metrics = %w[downloads] + def count(start_date:, end_date:) + scope = ReleaseDownloadSpark.for_account(account) + .for_environment(environment) + .where( + created_date: start_date..end_date, + ) + + unless product_id.nil? + scope = scope.where(product_id:) + end + + unless package_id.nil? + scope = scope.where(package_id:) + end + + unless release_id.nil? + scope = scope.where(release_id:) + end + + rows = scope.group(:created_date) + .pluck( + :created_date, + Arel.sql('sum(count)'), + ) + + counts = rows.each_with_object({}) do |(date, count), hash| + hash[['downloads', date]] = count + end + + # defer to gauge for a realtime count since sparks are nightly + if realtime? && end_date.today? + gauge = Analytics::Gauge.new(:downloads, account:, environment:, product_id:, package_id:, release_id:) + + gauge.measurements.each do |measurement| + counts[[measurement.metric, end_date]] = measurement.count + end + end + + counts + end + + private + + attr_reader :account, + :environment, + :product_id, + :package_id, + :release_id, + :realtime + + def realtime? = !!realtime + end + end + end +end diff --git a/app/models/release_download_spark.rb b/app/models/release_download_spark.rb new file mode 100644 index 0000000000..95e1d2aea7 --- /dev/null +++ b/app/models/release_download_spark.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ReleaseDownloadSpark < ClickhouseRecord + include Accountable, Environmental + + has_environment + has_account +end diff --git a/app/services/broadcast_event_service.rb b/app/services/broadcast_event_service.rb index ae87a23b97..baf628f9f2 100644 --- a/app/services/broadcast_event_service.rb +++ b/app/services/broadcast_event_service.rb @@ -51,7 +51,7 @@ def call { product: resource.product_id, package: resource.package_id, prev: meta[:current], next: meta[:next] } when /^artifact\.downloaded$/, /^release\.downloaded$/ - { product: resource.product_id, package: resource.package_id, version: resource.version } + { product: resource.product_id, package: resource.package_id, release: resource.release_id, version: resource.version } when /^license\.validation\./ { code: meta[:code] } when /\.updated$/ diff --git a/app/workers/record_release_download_spark_worker.rb b/app/workers/record_release_download_spark_worker.rb new file mode 100644 index 0000000000..4265c35489 --- /dev/null +++ b/app/workers/record_release_download_spark_worker.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class RecordReleaseDownloadSparkWorker < BaseWorker + sidekiq_options queue: :cron, + cronitor_enabled: true + + def perform(account_id) + event_type_ids = EventType.by_pattern('artifact.downloaded') + .collect(&:id) + return if + event_type_ids.empty? + + events_cte = EventLog::Clickhouse.where(account_id:, created_date: Date.yesterday, event_type_id: event_type_ids) + .where('metadata.product IS NOT NULL') + .select( + :account_id, + :environment_id, + :created_date, + :created_at, + 'metadata.product.:String AS product_id', + 'metadata.package.:String AS package_id', + 'metadata.release.:String AS release_id', + ) + + agg_cte = EventLog::Clickhouse.from('release_download_events') + .select( + :account_id, + :environment_id, + :created_date, + 'max(created_at) AS created_at', + :product_id, + :package_id, + :release_id, + 'count() AS count', + ) + .group( + :account_id, + :environment_id, + :created_date, + :product_id, + :package_id, + :release_id, + ) + + ReleaseDownloadSpark.connection.execute(<<~SQL.squish) + WITH + release_download_events AS (#{events_cte.to_sql}), + release_download_agg AS (#{agg_cte.to_sql}) + INSERT INTO release_download_sparks ( + account_id, + environment_id, + created_date, + created_at, + product_id, + package_id, + release_id, + count + ) + SELECT + account_id, + environment_id, + created_date, + created_at, + product_id, + package_id, + release_id, + count + FROM + release_download_agg + SQL + end +end diff --git a/app/workers/record_release_download_sparks_worker.rb b/app/workers/record_release_download_sparks_worker.rb new file mode 100644 index 0000000000..80b1a80e16 --- /dev/null +++ b/app/workers/record_release_download_sparks_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RecordReleaseDownloadSparksWorker < BaseWorker + sidekiq_options queue: :cron, + cronitor_enabled: true + + def perform + Account.unordered.paid.find_each do |account| + jitter = rand(0..30.minutes) # prevent a thundering herd effect + + RecordReleaseDownloadSparkWorker.perform_in(jitter, account.id) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 86bdd70ba4..57afa8a332 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -502,8 +502,10 @@ get 'events/:event', to: 'events#show', as: :event, constraints: { event: /.*/ } get 'leaderboards/:leaderboard', to: 'leaderboards#show', as: :leaderboard get 'heatmaps/:heatmap', to: 'heatmaps#show', as: :heatmap + get 'gauges/downloads', to: 'gauges/downloads#show', as: :download_gauge # specialized gauge get 'gauges/validations', to: 'gauges/validations#show', as: :validation_gauge # specialized gauge get 'gauges/:metric', to: 'gauges#show', as: :gauge + get 'sparks/downloads', to: 'sparks/downloads#show', as: :download_spark # specialized spark get 'sparks/validations', to: 'sparks/validations#show', as: :validation_spark # specialized spark get 'sparks/:metric', to: 'sparks#show', as: :spark get 'usage', to: 'usage#show', as: :usage diff --git a/config/schedule.yml b/config/schedule.yml index d911c34539..3e6e120ef0 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -91,3 +91,8 @@ record_license_validation_sparks: cron: "0 0 * * *" # Every day at midnight (6pm CST) class: "RecordLicenseValidationSparksWorker" status: <%= ENV.true?('CLICKHOUSE_DATABASE_ENABLED') ? 'enabled' : 'disabled' %> + +record_release_download_sparks: + cron: "0 0 * * *" # Every day at midnight (6pm CST) + class: "RecordReleaseDownloadSparksWorker" + status: <%= ENV.true?('CLICKHOUSE_DATABASE_ENABLED') ? 'enabled' : 'disabled' %> diff --git a/db/clickhouse/20260303070458_create_release_download_sparks.rb b/db/clickhouse/20260303070458_create_release_download_sparks.rb new file mode 100644 index 0000000000..add896d128 --- /dev/null +++ b/db/clickhouse/20260303070458_create_release_download_sparks.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateReleaseDownloadSparks < ActiveRecord::Migration[8.1] + verbose! + + def up + create_table :release_download_sparks, id: false, + options: "MergeTree PARTITION BY toYYYYMM(created_date) ORDER BY (account_id, created_date, product_id, release_id)", + force: :cascade do |t| + t.uuid :account_id, null: false + t.uuid :environment_id, null: true + t.uuid :product_id, null: false + t.uuid :package_id, null: true + t.uuid :release_id, null: false + t.column :count, "UInt64", null: false, default: 0 + t.date :created_date, null: false + t.datetime :created_at, precision: 3, null: false + + t.index :environment_id, name: "idx_environment", type: "bloom_filter", granularity: 4 + t.index :package_id, name: "idx_package", type: "bloom_filter", granularity: 4 + end + end + + def down + drop_table :release_download_sparks + end +end diff --git a/db/clickhouse_schema.rb b/db/clickhouse_schema.rb index 728e8a7399..d4b28d414c 100644 --- a/db/clickhouse_schema.rb +++ b/db/clickhouse_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_26_205815) do +ActiveRecord::Schema[8.1].define(version: 2026_03_03_070458) do # TABLE: active_licensed_user_sparks # SQL: CREATE TABLE active_licensed_user_sparks ( `account_id` UUID, `environment_id` Nullable(UUID), `count` UInt64 DEFAULT 0, `created_date` Date, `created_at` DateTime64(3), INDEX idx_environment environment_id TYPE bloom_filter GRANULARITY 4 ) ENGINE = MergeTree PARTITION BY toYYYYMM(created_date) ORDER BY (account_id, created_date) SETTINGS index_granularity = 8192 create_table "active_licensed_user_sparks", id: false, options: "MergeTree PARTITION BY toYYYYMM(created_date) ORDER BY (account_id, created_date) SETTINGS index_granularity = 8192", force: :cascade do |t| @@ -92,6 +92,22 @@ t.index "environment_id", name: "idx_environment", type: "bloom_filter", granularity: 4 end + # TABLE: release_download_sparks + # SQL: CREATE TABLE release_download_sparks ( `account_id` UUID, `environment_id` Nullable(UUID), `product_id` UUID, `package_id` Nullable(UUID), `release_id` UUID, `count` UInt64 DEFAULT 0, `created_date` Date, `created_at` DateTime64(3), INDEX idx_environment environment_id TYPE bloom_filter GRANULARITY 4, INDEX idx_package package_id TYPE bloom_filter GRANULARITY 4 ) ENGINE = MergeTree PARTITION BY toYYYYMM(created_date) ORDER BY (account_id, created_date, product_id, release_id) SETTINGS index_granularity = 8192 + create_table "release_download_sparks", id: false, options: "MergeTree PARTITION BY toYYYYMM(created_date) ORDER BY (account_id, created_date, product_id, release_id) SETTINGS index_granularity = 8192", force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "environment_id" + t.uuid "product_id", null: false + t.uuid "package_id" + t.uuid "release_id", null: false + t.integer "count", limit: 8, default: 0, null: false + t.date "created_date", null: false + t.datetime "created_at", precision: 3, null: false + + t.index "environment_id", name: "idx_environment", type: "bloom_filter", granularity: 4 + t.index "package_id", name: "idx_package", type: "bloom_filter", granularity: 4 + end + # TABLE: request_logs # SQL: CREATE TABLE request_logs ( `id` UUID, `account_id` UUID, `environment_id` Nullable(UUID), `created_at` DateTime64(3), `updated_at` DateTime64(3), `created_date` Date, `method` LowCardinality(Nullable(String)), `status` LowCardinality(Nullable(String)), `url` Nullable(String), `ip` Nullable(String) TTL created_at + toIntervalDay(30), `user_agent` Nullable(String) TTL created_at + toIntervalDay(30), `requestor_type` LowCardinality(Nullable(String)), `requestor_id` Nullable(UUID), `resource_type` LowCardinality(Nullable(String)), `resource_id` Nullable(UUID), `request_body` Nullable(String) CODEC(ZSTD(1)) TTL created_at + toIntervalDay(30), `request_headers` Nullable(JSON) TTL created_at + toIntervalDay(30), `response_body` Nullable(String) CODEC(ZSTD(1)) TTL created_at + toIntervalDay(30), `response_headers` Nullable(JSON) TTL created_at + toIntervalDay(30), `response_signature` Nullable(String) CODEC(ZSTD(1)) TTL created_at + toIntervalDay(30), `queue_time` Nullable(Float32), `run_time` Nullable(Float32), `is_deleted` UInt8 DEFAULT 0, `ver` DateTime64(3) DEFAULT now(), `ttl` UInt32 DEFAULT 2592000, INDEX idx_status status TYPE set(100) GRANULARITY 4, INDEX idx_method method TYPE set(20) GRANULARITY 4, INDEX idx_requestor (requestor_type, requestor_id) TYPE bloom_filter GRANULARITY 4, INDEX idx_resource (resource_type, resource_id) TYPE bloom_filter GRANULARITY 4, INDEX idx_ip ip TYPE bloom_filter GRANULARITY 4, INDEX idx_environment environment_id TYPE bloom_filter GRANULARITY 4, INDEX idx_id id TYPE bloom_filter GRANULARITY 4 ) ENGINE = ReplacingMergeTree(ver, is_deleted) PARTITION BY toYYYYMMDD(created_date) ORDER BY (account_id, created_date, id) TTL created_at + toIntervalSecond(ttl) SETTINGS index_granularity = 8192 create_table "request_logs", id: :uuid, options: "ReplacingMergeTree(ver, is_deleted) PARTITION BY toYYYYMMDD(created_date) ORDER BY (account_id, created_date, id) TTL created_at + toIntervalSecond(ttl) SETTINGS index_granularity = 8192", force: :cascade do |t| diff --git a/features/priv/analytics/gauges/downloads.feature b/features/priv/analytics/gauges/downloads.feature new file mode 100644 index 0000000000..a45f1eeb03 --- /dev/null +++ b/features/priv/analytics/gauges/downloads.feature @@ -0,0 +1,135 @@ +@ee @clickhouse +@api/priv +Feature: Download gauge analytics + Background: + Given the following "accounts" exist: + | name | slug | + | Test 1 | test1 | + | Test 2 | test2 | + And I send and accept JSON + + Scenario: Endpoint should be accessible when account is disabled + Given the account "test1" is canceled + Given I am an admin of account "test1" + And the current account is "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads" + Then the response status should be "200" + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: Admin retrieves downloads gauge for their account + Given I am an admin of account "test1" + And the current account is "test1" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | Product 2 | + And the current account has the following "release" rows: + | id | product_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | fa48996c-9c98-41c1-a2c3-21de98aefafe | 2.0.0 | stable | + And the current account has the following "release_artifact" rows: + | id | release_id | + | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | + | 19a9aefc-00b9-4905-b236-ff3cca788b3e | a499bb93-9902-4b52-8a04-76944ad7f660 | + And the current account has the following "event_log" rows: + | id | event | metadata | resource_type | resource_id | + | 52785862-2da1-47a2-a6d7-be93743a12c1 | artifact.downloaded | { "product": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "release": "bf9b523f-dd65-48a2-9512-fb66ba6c3714", "version": "1.0.0" } | ReleaseArtifact | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | + | 533cb423-17e2-4641-8140-5dddfb0cd98c | artifact.downloaded | { "product": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "release": "bf9b523f-dd65-48a2-9512-fb66ba6c3714", "version": "1.0.0" } | ReleaseArtifact | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | + | ff3569df-0a9e-400c-8d47-c93d042b67e7 | artifact.downloaded | { "product": "fa48996c-9c98-41c1-a2c3-21de98aefafe", "release": "a499bb93-9902-4b52-8a04-76944ad7f660", "version": "2.0.0" } | ReleaseArtifact | 19a9aefc-00b9-4905-b236-ff3cca788b3e | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "count": 3 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: Admin retrieves downloads gauge filtered by product + Given I am an admin of account "test1" + And the current account is "test1" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | Product 2 | + And the current account has the following "release" rows: + | id | product_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | fa48996c-9c98-41c1-a2c3-21de98aefafe | 2.0.0 | stable | + And the current account has the following "release_artifact" rows: + | id | release_id | + | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | + | 19a9aefc-00b9-4905-b236-ff3cca788b3e | a499bb93-9902-4b52-8a04-76944ad7f660 | + And the current account has the following "event_log" rows: + | id | event | metadata | resource_type | resource_id | + | 52785862-2da1-47a2-a6d7-be93743a12c1 | artifact.downloaded | { "product": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "release": "bf9b523f-dd65-48a2-9512-fb66ba6c3714", "version": "1.0.0" } | ReleaseArtifact | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | + | 533cb423-17e2-4641-8140-5dddfb0cd98c | artifact.downloaded | { "product": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "release": "bf9b523f-dd65-48a2-9512-fb66ba6c3714", "version": "1.0.0" } | ReleaseArtifact | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | + | ff3569df-0a9e-400c-8d47-c93d042b67e7 | artifact.downloaded | { "product": "fa48996c-9c98-41c1-a2c3-21de98aefafe", "release": "a499bb93-9902-4b52-8a04-76944ad7f660", "version": "2.0.0" } | ReleaseArtifact | 19a9aefc-00b9-4905-b236-ff3cca788b3e | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads?product=c9e2cd2e-2543-4d3f-8563-d0bf0b11e233" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: Admin retrieves downloads gauge with no data + Given I am an admin of account "test1" + And the current account is "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: Product attempts to retrieve gauge for their account + Given the current account is "test1" + And the current account has 1 "product" + And I am a product of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads" + Then the response status should be "403" + And the response body should be an array of 1 error + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: User attempts to retrieve gauge for their account + Given the current account is "test1" + And the current account has 1 "user" + And I am a user of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads" + Then the response status should be "403" + And the response body should be an array of 1 error + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: License attempts to retrieve gauge for their account + Given the current account is "test1" + And the current account has 1 "license" + And I am a license of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/gauges/downloads" + Then the response status should be "403" + And the response body should be an array of 1 error + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs diff --git a/features/priv/analytics/leaderboards.feature b/features/priv/analytics/leaderboards.feature index 512e81549c..5b84294207 100644 --- a/features/priv/analytics/leaderboards.feature +++ b/features/priv/analytics/leaderboards.feature @@ -147,6 +147,83 @@ Feature: Leaderboard analytics And sidekiq should have 0 "event-log" jobs And time is unfrozen + Scenario: Admin retrieves products leaderboard + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | Product 2 | + | 0aef7c4a-953e-4824-9e16-9be2361afcf4 | Product 3 | + And the current account has the following "release" rows: + | id | product_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | fa48996c-9c98-41c1-a2c3-21de98aefafe | 2.0.0 | stable | + | 7559899f-2761-4b9c-a43e-2d919efa9b04 | 0aef7c4a-953e-4824-9e16-9be2361afcf4 | 3.0.0 | stable | + And the current account has the following "release_download_spark" rows: + | product_id | release_id | count | created_date | created_at | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00.000Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 3 | 2100-08-24 | 2100-08-24T00:00:00.000Z | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | a499bb93-9902-4b52-8a04-76944ad7f660 | 4 | 2100-08-23 | 2100-08-23T00:00:00.000Z | + | 0aef7c4a-953e-4824-9e16-9be2361afcf4 | 7559899f-2761-4b9c-a43e-2d919efa9b04 | 2 | 2100-08-25 | 2100-08-25T00:00:00.000Z | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/leaderboards/products?date[start]=2100-08-20&date[end]=2100-08-27" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "discriminator": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "count": 8 }, + { "discriminator": "fa48996c-9c98-41c1-a2c3-21de98aefafe", "count": 4 }, + { "discriminator": "0aef7c4a-953e-4824-9e16-9be2361afcf4", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + + Scenario: Admin retrieves packages leaderboard + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + And the current account has the following "package" rows: + | id | product_id | name | key | + | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Package 1 | pkg1 | + | f6cac50e-7153-4b0d-897d-3f1a79a13304 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Package 2 | pkg2 | + | 8fec17e8-17f1-4869-aeb1-19e050cf4dea | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Package 3 | pkg3 | + And the current account has the following "release" rows: + | id | product_id | package_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | f6cac50e-7153-4b0d-897d-3f1a79a13304 | 2.0.0 | stable | + | 7559899f-2761-4b9c-a43e-2d919efa9b04 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 8fec17e8-17f1-4869-aeb1-19e050cf4dea | 3.0.0 | stable | + And the current account has the following "release_download_spark" rows: + | product_id | package_id | release_id | count | created_date | created_at | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00.000Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 3 | 2100-08-24 | 2100-08-24T00:00:00.000Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | f6cac50e-7153-4b0d-897d-3f1a79a13304 | a499bb93-9902-4b52-8a04-76944ad7f660 | 4 | 2100-08-23 | 2100-08-23T00:00:00.000Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 8fec17e8-17f1-4869-aeb1-19e050cf4dea | 7559899f-2761-4b9c-a43e-2d919efa9b04 | 2 | 2100-08-25 | 2100-08-25T00:00:00.000Z | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/leaderboards/packages?date[start]=2100-08-20&date[end]=2100-08-27" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "discriminator": "46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694", "count": 8 }, + { "discriminator": "f6cac50e-7153-4b0d-897d-3f1a79a13304", "count": 4 }, + { "discriminator": "8fec17e8-17f1-4869-aeb1-19e050cf4dea", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + Scenario: Admin retrieves leaderboard with limit Given I am an admin of account "test1" And the current account is "test1" diff --git a/features/priv/analytics/sparks/downloads.feature b/features/priv/analytics/sparks/downloads.feature new file mode 100644 index 0000000000..8e0063b9b7 --- /dev/null +++ b/features/priv/analytics/sparks/downloads.feature @@ -0,0 +1,293 @@ +@ee @clickhouse +@api/priv +Feature: Download spark analytics + Background: + Given the following "accounts" exist: + | name | slug | + | Test 1 | test1 | + | Test 2 | test2 | + And I send and accept JSON + + Scenario: Endpoint should be accessible when account is disabled + Given the account "test1" is canceled + Given I am an admin of account "test1" + And the current account is "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads" + Then the response status should be "200" + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: Admin retrieves download series for their account + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | Product 2 | + And the current account has the following "release" rows: + | id | product_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | fa48996c-9c98-41c1-a2c3-21de98aefafe | 2.0.0 | stable | + And the current account has the following "release_download_spark" rows: + | product_id | release_id | count | created_date | created_at | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00Z | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | a499bb93-9902-4b52-8a04-76944ad7f660 | 3 | 2100-08-23 | 2100-08-23T00:00:00Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 2 | 2100-08-24 | 2100-08-24T00:00:00Z | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?date[start]=2100-08-20&date[end]=2100-08-27" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "date": "2100-08-23", "count": 8 }, + { "metric": "downloads", "date": "2100-08-24", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + + Scenario: Admin retrieves download series filtered by product + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | Product 2 | + And the current account has the following "release" rows: + | id | product_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | fa48996c-9c98-41c1-a2c3-21de98aefafe | 2.0.0 | stable | + And the current account has the following "release_download_spark" rows: + | product_id | release_id | count | created_date | created_at | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00Z | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | a499bb93-9902-4b52-8a04-76944ad7f660 | 3 | 2100-08-23 | 2100-08-23T00:00:00Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 2 | 2100-08-24 | 2100-08-24T00:00:00Z | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?product=c9e2cd2e-2543-4d3f-8563-d0bf0b11e233&date[start]=2100-08-20&date[end]=2100-08-27" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "date": "2100-08-23", "count": 5 }, + { "metric": "downloads", "date": "2100-08-24", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + + Scenario: Admin retrieves download series filtered by package + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + And the current account has the following "package" rows: + | id | product_id | name | key | + | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Package 1 | pkg1 | + | f6cac50e-7153-4b0d-897d-3f1a79a13304 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Package 2 | pkg2 | + And the current account has the following "release" rows: + | id | product_id | package_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | f6cac50e-7153-4b0d-897d-3f1a79a13304 | 2.0.0 | stable | + And the current account has the following "release_download_spark" rows: + | product_id | package_id | release_id | count | created_date | created_at | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | f6cac50e-7153-4b0d-897d-3f1a79a13304 | a499bb93-9902-4b52-8a04-76944ad7f660 | 3 | 2100-08-23 | 2100-08-23T00:00:00Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 2 | 2100-08-24 | 2100-08-24T00:00:00Z | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?package=46e034e3-1c8e-4e3b-8a6b-76c2e2ec3694&date[start]=2100-08-20&date[end]=2100-08-27" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "date": "2100-08-23", "count": 5 }, + { "metric": "downloads", "date": "2100-08-24", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + + Scenario: Admin retrieves download series filtered by release + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "product" rows: + | id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | Product 1 | + And the current account has the following "release" rows: + | id | product_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | 2.0.0 | stable | + And the current account has the following "release_download_spark" rows: + | product_id | release_id | count | created_date | created_at | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00Z | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | a499bb93-9902-4b52-8a04-76944ad7f660 | 3 | 2100-08-23 | 2100-08-23T00:00:00Z | + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?release=bf9b523f-dd65-48a2-9512-fb66ba6c3714&date[start]=2100-08-20&date[end]=2100-08-27" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "date": "2100-08-23", "count": 5 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + + Scenario: Admin retrieves downloads spark for isolated environment + Given the current account is "test1" + And time is frozen at "2100-08-30T00:00:00.000Z" + And the current account has the following "environment" rows: + | id | name | code | isolation_strategy | + | bf20fe24-351d-47d0-b3c3-2c576a63d22f | Isolated | isolated | ISOLATED | + | 60e7f35f-5401-4cc2-abd3-999b2a758ee1 | Shared | shared | SHARED | + And the current account has the following "product" rows: + | id | environment_id | name | + | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf20fe24-351d-47d0-b3c3-2c576a63d22f | Isolated | + | fa48996c-9c98-41c1-a2c3-21de98aefafe | | Global | + And the current account has the following "release" rows: + | id | product_id | environment_id | version | channel | + | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf20fe24-351d-47d0-b3c3-2c576a63d22f | 1.0.0 | stable | + | a499bb93-9902-4b52-8a04-76944ad7f660 | fa48996c-9c98-41c1-a2c3-21de98aefafe | | 2.0.0 | stable | + And the current account has the following "release_artifact" rows: + | id | release_id | environment_id | + | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | bf20fe24-351d-47d0-b3c3-2c576a63d22f | + | 19a9aefc-00b9-4905-b236-ff3cca788b3e | a499bb93-9902-4b52-8a04-76944ad7f660 | | + And the current account has the following "event_log" rows: + | environment_id | event | metadata | resource_type | resource_id | + | bf20fe24-351d-47d0-b3c3-2c576a63d22f | artifact.downloaded | { "product": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "release": "bf9b523f-dd65-48a2-9512-fb66ba6c3714", "version": "1.0.0" } | ReleaseArtifact | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | + | bf20fe24-351d-47d0-b3c3-2c576a63d22f | artifact.downloaded | { "product": "c9e2cd2e-2543-4d3f-8563-d0bf0b11e233", "release": "bf9b523f-dd65-48a2-9512-fb66ba6c3714", "version": "1.0.0" } | ReleaseArtifact | d00998f9-d224-4ee7-ac4e-f1e5fe318ff7 | + | | artifact.downloaded | { "product": "fa48996c-9c98-41c1-a2c3-21de98aefafe", "release": "a499bb93-9902-4b52-8a04-76944ad7f660", "version": "2.0.0" } | ReleaseArtifact | 19a9aefc-00b9-4905-b236-ff3cca788b3e | + | | artifact.downloaded | { "product": "fa48996c-9c98-41c1-a2c3-21de98aefafe", "release": "a499bb93-9902-4b52-8a04-76944ad7f660", "version": "2.0.0" } | ReleaseArtifact | 19a9aefc-00b9-4905-b236-ff3cca788b3e | + And the current account has the following "release_download_spark" rows: + | environment_id | product_id | release_id | count | created_date | created_at | + | bf20fe24-351d-47d0-b3c3-2c576a63d22f | c9e2cd2e-2543-4d3f-8563-d0bf0b11e233 | bf9b523f-dd65-48a2-9512-fb66ba6c3714 | 5 | 2100-08-23 | 2100-08-23T00:00:00Z | + | | fa48996c-9c98-41c1-a2c3-21de98aefafe | a499bb93-9902-4b52-8a04-76944ad7f660 | 3 | 2100-08-23 | 2100-08-23T00:00:00Z | + | | fa48996c-9c98-41c1-a2c3-21de98aefafe | a499bb93-9902-4b52-8a04-76944ad7f660 | 2 | 2100-08-24 | 2100-08-24T00:00:00Z | + And the current account has 1 isolated "admin" + And I am the last admin of account "test1" + And I use an authentication token + And I send the following headers: + """ + { "Keygen-Environment": "isolated" } + """ + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?date[start]=2100-08-20&date[end]=2100-08-30" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [ + { "metric": "downloads", "date": "2100-08-23", "count": 5 }, + { "metric": "downloads", "date": "2100-08-30", "count": 2 } + ] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + And time is unfrozen + + Scenario: Admin retrieves download series with no data + Given I am an admin of account "test1" + And the current account is "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads" + Then the response status should be "200" + And the response body should be a JSON document with the following content: + """ + { + "data": [] + } + """ + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: Admin retrieves download series with start date too old + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2024-01-15T00:00:00.000Z" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?date[start]=2020-01-01&date[end]=2024-01-15" + Then the response status should be "400" + And the response body should be an array of 1 error + And the first error should have the following properties: + """ + { + "title": "Bad request", + "detail": "must be greater than or equal to 2023-01-15", + "source": { + "parameter": "date[start]" + } + } + """ + And sidekiq should have 0 "request-log" jobs + And time is unfrozen + + Scenario: Admin retrieves download series with end date in future + Given I am an admin of account "test1" + And the current account is "test1" + And time is frozen at "2024-01-15T00:00:00.000Z" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads?date[start]=2024-01-01&date[end]=2099-01-01" + Then the response status should be "400" + And the response body should be an array of 1 error + And the first error should have the following properties: + """ + { + "title": "Bad request", + "detail": "must be less than or equal to 2024-01-15", + "source": { + "parameter": "date[end]" + } + } + """ + And sidekiq should have 0 "request-log" jobs + And time is unfrozen + + Scenario: Product attempts to retrieve download series for their account + Given the current account is "test1" + And the current account has 1 "product" + And I am a product of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads" + Then the response status should be "403" + And the response body should be an array of 1 error + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: User attempts to retrieve download series for their account + Given the current account is "test1" + And the current account has 1 "user" + And I am a user of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads" + Then the response status should be "403" + And the response body should be an array of 1 error + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs + + Scenario: License attempts to retrieve download series for their account + Given the current account is "test1" + And the current account has 1 "license" + And I am a license of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/analytics/sparks/downloads" + Then the response status should be "403" + And the response body should be an array of 1 error + And sidekiq should have 0 "request-log" jobs + And sidekiq should have 0 "event-log" jobs diff --git a/spec/factories/event_log.rb b/spec/factories/event_log.rb index 9ca8505bc4..8e86e7f34a 100644 --- a/spec/factories/event_log.rb +++ b/spec/factories/event_log.rb @@ -23,6 +23,10 @@ event_type { build(:event_type, event: 'license.validation.failed') } end + trait :artifact_downloaded do + event_type { build(:event_type, event: 'artifact.downloaded') } + end + trait :machine_heartbeat_ping do event_type { build(:event_type, event: 'machine.heartbeat.ping') } end diff --git a/spec/factories/release_download_spark.rb b/spec/factories/release_download_spark.rb new file mode 100644 index 0000000000..10591effb6 --- /dev/null +++ b/spec/factories/release_download_spark.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :release_download_spark do + initialize_with { new(**attributes.reject { _2 in NIL_ACCOUNT | NIL_ENVIRONMENT }) } + + account { NIL_ACCOUNT } + environment { NIL_ENVIRONMENT } + + product_id { SecureRandom.uuid } + package_id { SecureRandom.uuid } + release_id { SecureRandom.uuid } + count { 0 } + created_date { Date.yesterday } + created_at { Time.current } + end +end diff --git a/spec/models/analytics/leaderboard_spec.rb b/spec/models/analytics/leaderboard_spec.rb index 15a3dbdca0..cdd7a7f1c0 100644 --- a/spec/models/analytics/leaderboard_spec.rb +++ b/spec/models/analytics/leaderboard_spec.rb @@ -39,6 +39,20 @@ expect(leaderboard).to be_valid end + it 'returns leaderboard for products' do + leaderboard = described_class.new(:products, account:) + + expect(leaderboard).to be_a(Analytics::Leaderboard) + expect(leaderboard).to be_valid + end + + it 'returns leaderboard for packages' do + leaderboard = described_class.new(:packages, account:) + + expect(leaderboard).to be_a(Analytics::Leaderboard) + expect(leaderboard).to be_valid + end + it 'accepts string names' do leaderboard = described_class.new('ips', account:) @@ -430,4 +444,243 @@ end end end + + describe 'products', :only_clickhouse do + context 'with no sparks' do + it 'returns empty array' do + leaderboard = described_class.new(:products, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to eq([]) + end + end + + context 'with sparks for different products' do + let(:product_a) { create(:product, account:) } + let(:product_b) { create(:product, account:) } + let(:product_c) { create(:product, account:) } + + before do + create(:release_download_spark, account:, product_id: product_a.id, count: 5, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product_a.id, count: 3, created_date: 2.days.ago.to_date, created_at: 2.days.ago) + create(:release_download_spark, account:, product_id: product_b.id, count: 4, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product_c.id, count: 2, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'returns scores ordered by count descending' do + product_a_id = product_a.id + product_b_id = product_b.id + product_c_id = product_c.id + + leaderboard = described_class.new(:products, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy do + it in [ + Analytics::Leaderboard::Score(discriminator: ^product_a_id, count: 8), + Analytics::Leaderboard::Score(discriminator: ^product_b_id, count: 4), + Analytics::Leaderboard::Score(discriminator: ^product_c_id, count: 2), + ] + end + end + end + + context 'with date range filtering' do + let(:product_a) { create(:product, account:) } + let(:product_b) { create(:product, account:) } + + before do + create(:release_download_spark, account:, product_id: product_a.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product_b.id, count: 2, created_date: 10.days.ago.to_date, created_at: 10.days.ago) + end + + it 'only includes sparks within date range' do + product_a_id = product_a.id + + leaderboard = described_class.new(:products, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^product_a_id, count: 3)] } + end + end + + context 'with limit parameter' do + before do + 5.times { create(:release_download_spark, account:, product_id: create(:product, account:).id, count: 1, created_date: 3.days.ago.to_date, created_at: 3.days.ago) } + end + + it 'respects custom limit' do + leaderboard = described_class.new(:products, account:, start_date: 7.days.ago.to_date, end_date: Date.current, limit: 3) + + expect(leaderboard.scores.length).to eq(3) + end + end + + context 'with environment scoping' do + let(:environment) { create(:environment, account:) } + let(:product_a) { create(:product, account:, environment:) } + let(:product_b) { create(:product, account:, environment: nil) } + + before do + create(:release_download_spark, account:, environment:, product_id: product_a.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, environment: nil, product_id: product_b.id, count: 2, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'filters by environment' do + product_a_id = product_a.id + + leaderboard = described_class.new(:products, account:, environment:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^product_a_id, count: 3)] } + end + end + + context 'with sparks for other accounts' do + let(:other_account) { create(:account) } + let(:product) { create(:product, account:) } + let(:other_product) { create(:product, account: other_account) } + + before do + create(:release_download_spark, account:, product_id: product.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account: other_account, product_id: other_product.id, count: 5, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'does not include other accounts' do + product_id = product.id + + leaderboard = described_class.new(:products, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^product_id, count: 3)] } + end + end + end + + describe 'packages', :only_clickhouse do + context 'with no sparks' do + it 'returns empty array' do + leaderboard = described_class.new(:packages, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to eq([]) + end + end + + context 'with sparks for different packages' do + let(:product) { create(:product, account:) } + let(:package_a) { create(:release_package, account:, product:) } + let(:package_b) { create(:release_package, account:, product:) } + let(:package_c) { create(:release_package, account:, product:) } + + before do + create(:release_download_spark, account:, product_id: product.id, package_id: package_a.id, count: 5, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product.id, package_id: package_a.id, count: 3, created_date: 2.days.ago.to_date, created_at: 2.days.ago) + create(:release_download_spark, account:, product_id: product.id, package_id: package_b.id, count: 4, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product.id, package_id: package_c.id, count: 2, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'returns scores ordered by count descending' do + package_a_id = package_a.id + package_b_id = package_b.id + package_c_id = package_c.id + + leaderboard = described_class.new(:packages, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy do + it in [ + Analytics::Leaderboard::Score(discriminator: ^package_a_id, count: 8), + Analytics::Leaderboard::Score(discriminator: ^package_b_id, count: 4), + Analytics::Leaderboard::Score(discriminator: ^package_c_id, count: 2), + ] + end + end + end + + context 'with sparks with nil package_id' do + let(:product) { create(:product, account:) } + let(:package) { create(:release_package, account:, product:) } + + before do + create(:release_download_spark, account:, product_id: product.id, package_id: package.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product.id, package_id: nil, count: 5, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'excludes nil packages' do + package_id = package.id + + leaderboard = described_class.new(:packages, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^package_id, count: 3)] } + end + end + + context 'with date range filtering' do + let(:product) { create(:product, account:) } + let(:package_a) { create(:release_package, account:, product:) } + let(:package_b) { create(:release_package, account:, product:) } + + before do + create(:release_download_spark, account:, product_id: product.id, package_id: package_a.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, product_id: product.id, package_id: package_b.id, count: 2, created_date: 10.days.ago.to_date, created_at: 10.days.ago) + end + + it 'only includes sparks within date range' do + package_a_id = package_a.id + + leaderboard = described_class.new(:packages, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^package_a_id, count: 3)] } + end + end + + context 'with limit parameter' do + let(:product) { create(:product, account:) } + + before do + 5.times { create(:release_download_spark, account:, product_id: product.id, package_id: create(:release_package, account:, product:).id, count: 1, created_date: 3.days.ago.to_date, created_at: 3.days.ago) } + end + + it 'respects custom limit' do + leaderboard = described_class.new(:packages, account:, start_date: 7.days.ago.to_date, end_date: Date.current, limit: 3) + + expect(leaderboard.scores.length).to eq(3) + end + end + + context 'with environment scoping' do + let(:environment) { create(:environment, account:) } + let(:product) { create(:product, account:) } + let(:package_a) { create(:release_package, account:, product:) } + let(:package_b) { create(:release_package, account:, product:) } + + before do + create(:release_download_spark, account:, environment:, product_id: product.id, package_id: package_a.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account:, environment: nil, product_id: product.id, package_id: package_b.id, count: 2, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'filters by environment' do + package_a_id = package_a.id + + leaderboard = described_class.new(:packages, account:, environment:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^package_a_id, count: 3)] } + end + end + + context 'with sparks for other accounts' do + let(:other_account) { create(:account) } + let(:product) { create(:product, account:) } + let(:other_product) { create(:product, account: other_account) } + let(:package) { create(:release_package, account:, product:) } + let(:other_package) { create(:release_package, account: other_account, product: other_product) } + + before do + create(:release_download_spark, account:, product_id: product.id, package_id: package.id, count: 3, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + create(:release_download_spark, account: other_account, product_id: other_product.id, package_id: other_package.id, count: 5, created_date: 3.days.ago.to_date, created_at: 3.days.ago) + end + + it 'does not include other accounts' do + package_id = package.id + + leaderboard = described_class.new(:packages, account:, start_date: 7.days.ago.to_date, end_date: Date.current) + + expect(leaderboard.scores).to satisfy { it in [Analytics::Leaderboard::Score(discriminator: ^package_id, count: 3)] } + end + end + end end diff --git a/spec/workers/record_release_download_spark_worker_spec.rb b/spec/workers/record_release_download_spark_worker_spec.rb new file mode 100644 index 0000000000..76b803331a --- /dev/null +++ b/spec/workers/record_release_download_spark_worker_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'spec_helper' + +describe RecordReleaseDownloadSparkWorker, :only_clickhouse do + let(:account) { create(:account) } + + before { Sidekiq::Testing.inline! } + after { Sidekiq::Testing.fake! } + + it 'should aggregate per-product, per-package, and per-release' do + product_a = create(:product, account:) + product_a_id = product_a.id + + product_b = create(:product, account:) + product_b_id = product_b.id + + package_a = create(:release_package, account:, product: product_a) + package_a_id = package_a.id + + release_a = create(:release, account:, product: product_a, package: package_a) + release_a_id = release_a.id + + release_b = create(:release, account:, product: product_a, package: package_a) + release_b_id = release_b.id + + release_c = create(:release, account:, product: product_b) + release_c_id = release_c.id + + artifact_a = create(:release_artifact, account:, release: release_a) + artifact_b = create(:release_artifact, account:, release: release_b) + artifact_c = create(:release_artifact, account:, release: release_c) + + create_list(:event_log, 3, :artifact_downloaded, account:, resource: artifact_a, metadata: { product: product_a_id, package: package_a_id, release: release_a_id, version: release_a.version }) + create_list(:event_log, 2, :artifact_downloaded, account:, resource: artifact_b, metadata: { product: product_a_id, package: package_a_id, release: release_b_id, version: release_b.version }) + create_list(:event_log, 1, :artifact_downloaded, account:, resource: artifact_c, metadata: { product: product_b_id, package: nil, release: release_c_id, version: release_c.version }) + + travel_to 1.day.from_now do + described_class.perform_async(account.id) + end + + sparks = ReleaseDownloadSpark.for_account(account) + .order( + created_at: :desc, + ) + + expect(sparks).to satisfy do + it in [ + ReleaseDownloadSpark(product_id: ^product_b_id, package_id: nil, release_id: ^release_c_id, count: 1), + ReleaseDownloadSpark(product_id: ^product_a_id, package_id: ^package_a_id, release_id: ^release_b_id, count: 2), + ReleaseDownloadSpark(product_id: ^product_a_id, package_id: ^package_a_id, release_id: ^release_a_id, count: 3), + ] + end + end + + it 'should aggregate per-environment' do + environment_a = create(:environment, :shared, account:) + environment_a_id = environment_a.id + + environment_b = create(:environment, account:) + environment_b_id = environment_b.id + + product_a = create(:product, account:, environment: environment_a) + product_a_id = product_a.id + + product_b = create(:product, account:, environment: environment_b) + product_b_id = product_b.id + + product_c = create(:product, account:, environment: nil) + product_c_id = product_c.id + + release_a = create(:release, account:, product: product_a, environment: environment_a) + release_a_id = release_a.id + + release_b = create(:release, account:, product: product_b, environment: environment_b) + release_b_id = release_b.id + + release_c = create(:release, account:, product: product_c, environment: nil) + release_c_id = release_c.id + + artifact_a = create(:release_artifact, account:, release: release_a, environment: environment_a) + artifact_b = create(:release_artifact, account:, release: release_b, environment: environment_b) + artifact_c = create(:release_artifact, account:, release: release_c, environment: nil) + + create_list(:event_log, 3, :artifact_downloaded, account:, resource: artifact_a, environment: environment_a, metadata: { product: product_a_id, package: nil, release: release_a_id, version: release_a.version }) + create_list(:event_log, 2, :artifact_downloaded, account:, resource: artifact_b, environment: environment_b, metadata: { product: product_b_id, package: nil, release: release_b_id, version: release_b.version }) + create_list(:event_log, 1, :artifact_downloaded, account:, resource: artifact_c, environment: nil, metadata: { product: product_c_id, package: nil, release: release_c_id, version: release_c.version }) + + travel_to 1.day.from_now do + described_class.perform_async(account.id) + end + + sparks = ReleaseDownloadSpark.for_account(account) + .order( + created_at: :desc, + ) + + expect(sparks).to satisfy do + it in [ + ReleaseDownloadSpark(environment_id: nil, release_id: ^release_c_id, count: 1), + ReleaseDownloadSpark(environment_id: ^environment_b_id, release_id: ^release_b_id, count: 2), + ReleaseDownloadSpark(environment_id: ^environment_a_id, release_id: ^release_a_id, count: 3), + ] + end + end + + it 'should not record sparks when there are no download events' do + create(:release, account:) + + travel_to 1.day.from_now do + described_class.perform_async(account.id) + end + + sparks = ReleaseDownloadSpark.for_account(account) + + expect(sparks.count).to eq(0) + end + + it 'should not record sparks for non-download events' do + license = create(:license, account:) + + create_list(:event_log, 3, :license_created, account:, resource: license) + + travel_to 1.day.from_now do + described_class.perform_async(account.id) + end + + sparks = ReleaseDownloadSpark.for_account(account) + + expect(sparks.count).to eq(0) + end + + it 'should not record sparks for other accounts' do + other_account = create(:account) + other_product = create(:product, account: other_account) + other_release = create(:release, account: other_account, product: other_product) + other_artifact = create(:release_artifact, account: other_account, release: other_release) + + product = create(:product, account:) + release = create(:release, account:, product:) + artifact = create(:release_artifact, account:, release:) + + create_list(:event_log, 3, :artifact_downloaded, account: other_account, resource: other_artifact, metadata: { product: other_product.id, package: nil, release: other_release.id, version: other_release.version }) + create_list(:event_log, 3, :artifact_downloaded, account:, resource: artifact, metadata: { product: product.id, package: nil, release: release.id, version: release.version }) + + travel_to 1.day.from_now do + described_class.perform_async(account.id) + end + + other_sparks = ReleaseDownloadSpark.for_account(other_account) + + expect(other_sparks.count).to eq(0) + + sparks = ReleaseDownloadSpark.for_account(account) + + expect(sparks.count).to eq(1) + end +end