diff --git a/internal/billing/active_users.go b/internal/billing/active_users.go new file mode 100644 index 0000000000..6549726e47 --- /dev/null +++ b/internal/billing/active_users.go @@ -0,0 +1,15 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import "time" + +// The ActiveUsersCount field is the number of unique users +// counted between the start and end dates. +// The start date is inclusive and the end date is exclusive. +type ActiveUsers struct { + StartTime time.Time + EndTime time.Time + ActiveUsersCount uint64 +} diff --git a/internal/billing/doc.go b/internal/billing/doc.go new file mode 100644 index 0000000000..ce68d0cdea --- /dev/null +++ b/internal/billing/doc.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package billing provides usage numbers that can be used for +// billing purposes. The currently supported metric is monthly +// active users. A user is considered active within a month +// if they have at least one issued auth token within the time +// range of the start and end of a given month. +package billing diff --git a/internal/billing/options.go b/internal/billing/options.go new file mode 100644 index 0000000000..a1b5a35403 --- /dev/null +++ b/internal/billing/options.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "time" +) + +// getOpts - iterate the inbound Options and return a struct +func getOpts(opt ...Option) options { + opts := getDefaultOptions() + for _, o := range opt { + o(&opts) + } + return opts +} + +// Option - how Options are passed as arguments. +type Option func(*options) + +// options = how options are represented +type options struct { + withStartTime *time.Time + withEndTime *time.Time +} + +func getDefaultOptions() options { + return options{ + withStartTime: nil, + withEndTime: nil, + } +} + +// WithStartTime allows setting the start time for the query. +func WithStartTime(startTime *time.Time) Option { + return func(o *options) { + o.withStartTime = startTime + } +} + +// WithEndTime allows setting the end time for the query. +func WithEndTime(endTime *time.Time) Option { + return func(o *options) { + o.withEndTime = endTime + } +} diff --git a/internal/billing/options_test.go b/internal/billing/options_test.go new file mode 100644 index 0000000000..a774f1e533 --- /dev/null +++ b/internal/billing/options_test.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// Test_GetOpts provides unit tests for GetOpts and all the options +func Test_GetOpts(t *testing.T) { + t.Parallel() + + oneMonthAgo := time.Now().AddDate(0, -1, 0) + + t.Run("WithStartTime", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithStartTime(&oneMonthAgo)) + testOpts := getDefaultOptions() + testOpts.withStartTime = &oneMonthAgo + assert.Equal(opts, testOpts) + }) + + t.Run("WithEndTime", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithEndTime(&oneMonthAgo)) + testOpts := getDefaultOptions() + testOpts.withEndTime = &oneMonthAgo + assert.Equal(opts, testOpts) + }) +} diff --git a/internal/billing/query.go b/internal/billing/query.go new file mode 100644 index 0000000000..a792313c27 --- /dev/null +++ b/internal/billing/query.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +const ( + activeUsersLastTwoMonthsQuery = ` +select * + from hcp_billing_monthly_active_users_last_2_months +` + activeUsersWithStartTimeQuery = ` +select * + from hcp_billing_monthly_active_users_all + where start_time >= @start_time +` + activeUsersWithStartTimeAndEndTimeQuery = ` +select * + from hcp_billing_monthly_active_users_all + where start_time >= @start_time + and end_time < @end_time +` +) diff --git a/internal/billing/repository.go b/internal/billing/repository.go new file mode 100644 index 0000000000..bdff024c8f --- /dev/null +++ b/internal/billing/repository.go @@ -0,0 +1,121 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "context" + "database/sql" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" +) + +// A Repository retrieves the persistent type in the billing +// package. It is not safe to use a repository concurrently. +// It provides a method for requesting pre-aggregated user counts +// per month. Depending on whether a start time and/or end time are given, +// an ActiveUsers object will be returned: +// - for every month from the provided start date until the present date, +// with the present date being a cumulative count up to the present date. +// - for every month from the provided start date until the provided end date. +// - for the previous month and current month, with the current month being a +// cumulative count up to the present date. +type Repository struct { + reader db.Reader + writer db.Writer +} + +// NewRepository creates a new Repository. The returned repository is not safe for concurrent go +// routines to access it. +func NewRepository(ctx context.Context, r db.Reader, w db.Writer) (*Repository, error) { + const op = "billing.NewRepository" + switch { + case r == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db reader") + case w == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db writer") + } + + return &Repository{ + reader: r, + writer: w, + }, nil +} + +// MonthlyActiveUsers returns the active users for a range of months, from most recent to least. +// If no start or end time is provided, it will return the active users for the last two months. +// If a start time is provided, it will return the active users for that month until the current month. +// If both a start and end time are provided, it will return the active users for that time range, +// starting time inclusive and ending time exclusive. +// The times provided must be the start of the month at midnight UTC. +func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]ActiveUsers, error) { + const op = "billing.Repository.MonthlyActiveUsers" + + opts := getOpts(opt...) + + switch { + case opts.withEndTime != nil && opts.withStartTime == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "end time set without start time") + case opts.withEndTime != nil && !opts.withEndTime.After(*opts.withStartTime): + return nil, errors.New(ctx, errors.InvalidParameter, op, "start time is not before end time") + } + query := activeUsersLastTwoMonthsQuery + var args []any + if opts.withStartTime != nil { + if *opts.withStartTime != time.Date(opts.withStartTime.Year(), opts.withStartTime.Month(), 1, 0, 0, 0, 0, time.UTC) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "start time must be the first day of the month at midnight UTC") + } + query = activeUsersWithStartTimeQuery + args = append(args, + sql.Named("start_time", opts.withStartTime)) + } + if opts.withEndTime != nil { + if *opts.withEndTime != time.Date(opts.withEndTime.Year(), opts.withEndTime.Month(), 1, 0, 0, 0, 0, time.UTC) { + return nil, errors.New(ctx, errors.InvalidParameter, op, "end time must be the first day of the month at midnight UTC") + } + query = activeUsersWithStartTimeAndEndTimeQuery + args = append(args, + sql.Named("end_time", opts.withEndTime)) + } + + var activeUsers []ActiveUsers + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + _, err := w.Exec(ctx, `set timezone to 'utc'`, nil) + if err != nil { + return err + } + + rows, err := r.Query(ctx, query, args) + if err != nil { + return err + } + defer rows.Close() + if err := rows.Err(); err != nil { + return err + } + for rows.Next() { + var start_time time.Time + var end_time time.Time + var count uint64 + if err := rows.Scan(&start_time, &end_time, &count); err != nil { + return err + } + + // set start and end times to be in UTC + auUTC := ActiveUsers{ + ActiveUsersCount: count, + StartTime: start_time.UTC(), + EndTime: end_time.UTC(), + } + activeUsers = append(activeUsers, auUTC) + + } + return nil + }); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + return activeUsers, nil +} diff --git a/internal/billing/repository_test.go b/internal/billing/repository_test.go new file mode 100644 index 0000000000..f84bde4514 --- /dev/null +++ b/internal/billing/repository_test.go @@ -0,0 +1,264 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package billing + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" +) + +const insertQuery = ` +with + month_range (date_key, time_key, month) as ( + select wh_date_key(s), wh_time_key(s), s + from generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + users (user_id, u) as ( + select 'u_____user'||u, u + from generate_series(1, 6, 1) as u + ), + user_key (key, user_id) as ( + insert into wh_user_dimension ( + user_id, user_name, user_description, + auth_account_id, auth_account_type, auth_account_name, auth_account_description, + auth_method_id, auth_method_type, auth_method_name, auth_method_description, + user_organization_id, user_organization_name, user_organization_description, + current_row_indicator, + row_effective_time, row_expiration_time, + auth_method_external_id, auth_account_external_id, auth_account_full_name, auth_account_email) + select users.user_id, 'None', 'None', + 'a______acc1', 'None', 'None', 'None', + 'am______am1', 'None', 'None', 'None', + 'o______org1', 'None', 'None', + 'current', + now(), 'infinity'::timestamptz, + 'None', 'None', 'None', 'None' + from users + returning key, user_id + ), + tokens (date_key, time_key, user_id, token_id) as ( + select wh_date_key(s), wh_time_key(s), users.user_id, 't_____u'||users.u||'tok'||s as token_id + from users, + generate_series(date_trunc('month', now()) - interval '1 year', + date_trunc('month', now()) - interval '1 month', + interval '1 month') as s + ), + tokens_user_keys (date_key, time_key, user_id, token_id, user_key) as ( + select tokens.date_key, tokens.time_key, tokens.user_id, tokens.token_id, user_key.key + from tokens + join user_key + on user_key.user_id = tokens.user_id + ), + auth_tokens (user_key, user_id, token_id, valid_range) as ( + select tokens_user_keys.user_key, tokens_user_keys.user_id, tokens_user_keys.token_id, tstzrange(month_range.month, month_range.month + interval '5 minutes', '[)') + from tokens_user_keys + join month_range + on month_range.date_key = tokens_user_keys.date_key + and month_range.time_key = tokens_user_keys.time_key + ) + insert into wh_auth_token_accumulating_fact ( + auth_token_id, user_key, + auth_token_issued_date_key, auth_token_issued_time_key, auth_token_issued_time, + auth_token_deleted_date_key, auth_token_deleted_time_key, auth_token_deleted_time, + auth_token_approximate_last_access_date_key, auth_token_approximate_last_access_time_key, auth_token_approximate_last_access_time, + auth_token_approximate_active_time_range, + auth_token_valid_time_range, + auth_token_count + ) + select auth_tokens.token_id, auth_tokens.user_key, + wh_date_key(lower(auth_tokens.valid_range)), wh_time_key(lower(auth_tokens.valid_range)), lower(auth_tokens.valid_range), + coalesce(wh_date_key(upper(auth_tokens.valid_range)), -1), coalesce(wh_time_key(upper(auth_tokens.valid_range)), -1), upper(auth_tokens.valid_range), + wh_date_key(upper(auth_tokens.valid_range)), wh_time_key(upper(auth_tokens.valid_range)), upper(auth_tokens.valid_range), + auth_tokens.valid_range, + auth_tokens.valid_range, + 1 + from auth_tokens; + ` + +func TestRepository_New(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + + type args struct { + r db.Reader + w db.Writer + } + + tests := []struct { + name string + args args + want *Repository + wantIsErr errors.Code + wantErrMsg string + }{ + { + name: "valid", + args: args{ + r: rw, + w: rw, + }, + want: &Repository{ + reader: rw, + writer: rw, + }, + }, + { + name: "nil-reader", + args: args{ + r: nil, + w: rw, + }, + want: nil, + wantIsErr: errors.InvalidParameter, + wantErrMsg: "billing.NewRepository: nil db reader: parameter violation: error #100", + }, + { + name: "nil-writer", + args: args{ + r: rw, + w: nil, + }, + want: nil, + wantIsErr: errors.InvalidParameter, + wantErrMsg: "billing.NewRepository: nil db writer: parameter violation: error #100", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + got, err := NewRepository(context.Background(), tt.args.r, tt.args.w) + if tt.wantIsErr != 0 { + assert.Truef(errors.Match(errors.T(tt.wantIsErr), err), "Unexpected error %s", err) + assert.Equal(tt.wantErrMsg, err.Error()) + return + } + assert.NoError(err) + assert.NotNil(got) + assert.Equal(tt.want, got) + }) + } +} + +func TestRepository_MonthlyActiveUsers(t *testing.T) { + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + + today := time.Now().UTC() + threeMonthsAgo := time.Date(today.AddDate(0, -3, 0).Year(), today.AddDate(0, -3, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + oneMonthAgo := time.Date(today.AddDate(0, -1, 0).Year(), today.AddDate(0, -1, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + midMonth := time.Date(today.Year(), today.Month(), 15, 0, 0, 0, 0, time.UTC) + + db, err := conn.SqlDB(ctx) + if err != nil { + t.Errorf("error getting db connection %s", err) + } + _, err = db.Exec(insertQuery) + if err != nil { + t.Errorf("error %s", err) + } + + t.Run("valid-no-options", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + activeUsers, err := repo.MonthlyActiveUsers(ctx) + assert.NoError(t, err) + require.Len(t, activeUsers, 2) + // check counts for the last two months + require.Equal(t, uint64(0), activeUsers[0].ActiveUsersCount) + require.Equal(t, uint64(6), activeUsers[1].ActiveUsersCount) + // assert start and end times are correct + // the current month (contains the hour) + assert.Equal(t, time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC), activeUsers[0].StartTime) + assert.Equal(t, time.Date(today.Year(), today.Month(), today.Day(), today.Hour(), 0, 0, 0, time.UTC), activeUsers[0].EndTime) + // the previous month + assert.Equal(t, time.Date(today.Year(), today.Month()-1, 1, 0, 0, 0, 0, time.UTC), activeUsers[1].StartTime) + assert.Equal(t, time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC), activeUsers[1].EndTime) + }) + + t.Run("valid-with-start-time", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo)) + assert.NoError(t, err) + require.Len(t, activeUsers, 4) + for i := 0; i < 4; i++ { + // check counts for the last four months + if i == 0 { + assert.Equal(t, uint64(0), activeUsers[i].ActiveUsersCount) + // the current month (contains the hour) + assert.Equal(t, time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC), activeUsers[i].StartTime) + assert.Equal(t, time.Date(today.Year(), today.Month(), today.Day(), today.Hour(), 0, 0, 0, time.UTC), activeUsers[i].EndTime) + } else { + // create a sliding window of dates to assert start and end times are correct + expectedStartTime := time.Date(today.AddDate(0, -i, 0).Year(), today.AddDate(0, -i, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + expectedEndTime := time.Date(today.AddDate(0, -i+1, 0).Year(), today.AddDate(0, -i+1, 0).Month(), 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, uint64(6), activeUsers[i].ActiveUsersCount) + assert.Equal(t, expectedStartTime, activeUsers[i].StartTime) + assert.Equal(t, expectedEndTime, activeUsers[i].EndTime) + } + } + }) + + t.Run("valid-with-start-and-end-time", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + activeUsers, err := repo.MonthlyActiveUsers(ctx, WithStartTime(&threeMonthsAgo), WithEndTime(&oneMonthAgo)) + assert.NoError(t, err) + // since the end time is exclusive, we should only get one record of active users + // for the month of three months ago + require.Len(t, activeUsers, 1) + require.Equal(t, uint64(6), activeUsers[0].ActiveUsersCount) + }) + + t.Run("invalid-end-time-without-start-time", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, err = repo.MonthlyActiveUsers(ctx, WithEndTime(&oneMonthAgo)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: end time set without start time: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-end-time-before-start-time", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&threeMonthsAgo)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: start time is not before end time: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-start-time-equals-end-time", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&oneMonthAgo)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: start time is not before end time: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-start-time-not-first-day-of-month", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&midMonth)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: start time must be the first day of the month at midnight UTC: parameter violation: error #100", err.Error()) + }) + + t.Run("invalid-end-time-not-first-day-of-month", func(t *testing.T) { + repo, err := NewRepository(ctx, rw, rw) + assert.NoError(t, err) + _, err = repo.MonthlyActiveUsers(ctx, WithStartTime(&oneMonthAgo), WithEndTime(&midMonth)) + assert.Error(t, err) + assert.Equal(t, "billing.Repository.MonthlyActiveUsers: end time must be the first day of the month at midnight UTC: parameter violation: error #100", err.Error()) + }) +}