mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Daily Active User Metrics (#3735)
* agent: add StatsReporter * Stabilize protoc
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user