feat(billing): Add domain level package for monthly active user counts

pull/4445/head
Michael Milton 2 years ago committed by Timothy Messier
parent 324c474f5d
commit 7d5c3e48cd
No known key found for this signature in database
GPG Key ID: EFD2F184F7600572

@ -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
}

@ -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

@ -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
}
}

@ -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)
})
}

@ -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
`
)

@ -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
}

@ -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())
})
}
Loading…
Cancel
Save