Daily Active User Metrics (#3735)

* agent: add StatsReporter

* Stabilize protoc
This commit is contained in:
Ammar Bandukwala
2022-09-01 14:58:23 -05:00
committed by GitHub
parent e0cb52ceea
commit 30f8fd9b95
47 changed files with 2006 additions and 279 deletions
+172
View File
@@ -0,0 +1,172 @@
package metricscache
import (
"context"
"sync/atomic"
"time"
"golang.org/x/xerrors"
"github.com/google/uuid"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
"github.com/coder/retry"
)
// Cache holds the template DAU cache.
// The aggregation queries responsible for these values can take up to a minute
// on large deployments. Even in small deployments, aggregation queries can
// take a few hundred milliseconds, which would ruin page load times and
// database performance if in the hot path.
type Cache struct {
database database.Store
log slog.Logger
templateDAUResponses atomic.Pointer[map[string]codersdk.TemplateDAUsResponse]
doneCh chan struct{}
cancel func()
interval time.Duration
}
func New(db database.Store, log slog.Logger, interval time.Duration) *Cache {
if interval <= 0 {
interval = time.Hour
}
ctx, cancel := context.WithCancel(context.Background())
c := &Cache{
database: db,
log: log,
doneCh: make(chan struct{}),
cancel: cancel,
interval: interval,
}
go c.run(ctx)
return c
}
func fillEmptyDays(rows []database.GetTemplateDAUsRow) []database.GetTemplateDAUsRow {
var newRows []database.GetTemplateDAUsRow
for i, row := range rows {
if i == 0 {
newRows = append(newRows, row)
continue
}
last := rows[i-1]
const day = time.Hour * 24
diff := row.Date.Sub(last.Date)
for diff > day {
if diff <= day {
break
}
last.Date = last.Date.Add(day)
last.Amount = 0
newRows = append(newRows, last)
diff -= day
}
newRows = append(newRows, row)
continue
}
return newRows
}
func (c *Cache) refresh(ctx context.Context) error {
err := c.database.DeleteOldAgentStats(ctx)
if err != nil {
return xerrors.Errorf("delete old stats: %w", err)
}
templates, err := c.database.GetTemplates(ctx)
if err != nil {
return err
}
templateDAUs := make(map[string]codersdk.TemplateDAUsResponse, len(templates))
for _, template := range templates {
daus, err := c.database.GetTemplateDAUs(ctx, template.ID)
if err != nil {
return err
}
var resp codersdk.TemplateDAUsResponse
for _, ent := range fillEmptyDays(daus) {
resp.Entries = append(resp.Entries, codersdk.DAUEntry{
Date: ent.Date,
Amount: int(ent.Amount),
})
}
templateDAUs[template.ID.String()] = resp
}
c.templateDAUResponses.Store(&templateDAUs)
return nil
}
func (c *Cache) run(ctx context.Context) {
defer close(c.doneCh)
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
for r := retry.New(time.Millisecond*100, time.Minute); r.Wait(ctx); {
start := time.Now()
err := c.refresh(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
c.log.Error(ctx, "refresh", slog.Error(err))
continue
}
c.log.Debug(
ctx,
"metrics refreshed",
slog.F("took", time.Since(start)),
slog.F("interval", c.interval),
)
break
}
select {
case <-ticker.C:
case <-c.doneCh:
return
case <-ctx.Done():
return
}
}
}
func (c *Cache) Close() error {
c.cancel()
<-c.doneCh
return nil
}
// TemplateDAUs returns an empty response if the template doesn't have users
// or is loading for the first time.
func (c *Cache) TemplateDAUs(id uuid.UUID) codersdk.TemplateDAUsResponse {
m := c.templateDAUResponses.Load()
if m == nil {
// Data loading.
return codersdk.TemplateDAUsResponse{}
}
resp, ok := (*m)[id.String()]
if !ok {
// Probably no data.
return codersdk.TemplateDAUsResponse{}
}
return resp
}
+185
View File
@@ -0,0 +1,185 @@
package metricscache_test
import (
"context"
"reflect"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/coderd/metricscache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
func date(year, month, day int) time.Time {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
func TestCache(t *testing.T) {
t.Parallel()
var (
zebra = uuid.New()
tiger = uuid.New()
)
type args struct {
rows []database.InsertAgentStatParams
}
tests := []struct {
name string
args args
want []codersdk.DAUEntry
}{
{"empty", args{}, nil},
{"one hole", args{
rows: []database.InsertAgentStatParams{
{
CreatedAt: date(2022, 8, 27),
UserID: zebra,
},
{
CreatedAt: date(2022, 8, 30),
UserID: zebra,
},
},
}, []codersdk.DAUEntry{
{
Date: date(2022, 8, 27),
Amount: 1,
},
{
Date: date(2022, 8, 28),
Amount: 0,
},
{
Date: date(2022, 8, 29),
Amount: 0,
},
{
Date: date(2022, 8, 30),
Amount: 1,
},
}},
{"no holes", args{
rows: []database.InsertAgentStatParams{
{
CreatedAt: date(2022, 8, 27),
UserID: zebra,
},
{
CreatedAt: date(2022, 8, 28),
UserID: zebra,
},
{
CreatedAt: date(2022, 8, 29),
UserID: zebra,
},
},
}, []codersdk.DAUEntry{
{
Date: date(2022, 8, 27),
Amount: 1,
},
{
Date: date(2022, 8, 28),
Amount: 1,
},
{
Date: date(2022, 8, 29),
Amount: 1,
},
}},
{"holes", args{
rows: []database.InsertAgentStatParams{
{
CreatedAt: date(2022, 1, 1),
UserID: zebra,
},
{
CreatedAt: date(2022, 1, 1),
UserID: tiger,
},
{
CreatedAt: date(2022, 1, 4),
UserID: zebra,
},
{
CreatedAt: date(2022, 1, 7),
UserID: zebra,
},
{
CreatedAt: date(2022, 1, 7),
UserID: tiger,
},
},
}, []codersdk.DAUEntry{
{
Date: date(2022, 1, 1),
Amount: 2,
},
{
Date: date(2022, 1, 2),
Amount: 0,
},
{
Date: date(2022, 1, 3),
Amount: 0,
},
{
Date: date(2022, 1, 4),
Amount: 1,
},
{
Date: date(2022, 1, 5),
Amount: 0,
},
{
Date: date(2022, 1, 6),
Amount: 0,
},
{
Date: date(2022, 1, 7),
Amount: 2,
},
}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
cache = metricscache.New(db, slogtest.Make(t, nil), time.Millisecond*100)
)
defer cache.Close()
templateID := uuid.New()
db.InsertTemplate(context.Background(), database.InsertTemplateParams{
ID: templateID,
})
for _, row := range tt.args.rows {
row.TemplateID = templateID
db.InsertAgentStat(context.Background(), row)
}
var got codersdk.TemplateDAUsResponse
require.Eventuallyf(t, func() bool {
got = cache.TemplateDAUs(templateID)
return reflect.DeepEqual(got.Entries, tt.want)
}, testutil.WaitShort, testutil.IntervalFast,
"GetDAUs() = %v, want %v", got, tt.want,
)
})
}
}