168 lines
4.4 KiB
Go
168 lines
4.4 KiB
Go
package repo
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/ClickHouse/clickhouse-go/v2"
|
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
|
|
|
"github.com/dbiz/cdp/data-layer/api/internal/model"
|
|
"github.com/dbiz/cdp/data-layer/api/internal/templates"
|
|
)
|
|
|
|
// AnalyticsRepo runs the higher-level P1 query templates (funnel, retention,
|
|
// session) against ClickHouse. It shares the read connection with EventRepo
|
|
// but lives in its own file because the templates need their own data shapes.
|
|
type AnalyticsRepo struct {
|
|
ch driver.Conn
|
|
tpl *templates.Store
|
|
}
|
|
|
|
func NewAnalyticsRepo(ch driver.Conn, tpl *templates.Store) *AnalyticsRepo {
|
|
return &AnalyticsRepo{ch: ch, tpl: tpl}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Funnel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type FunnelQuery struct {
|
|
WorkspaceID string
|
|
Steps []string
|
|
From time.Time
|
|
To time.Time
|
|
WindowSeconds uint32
|
|
}
|
|
|
|
func (r *AnalyticsRepo) Funnel(ctx context.Context, q FunnelQuery) (*model.QueryResult, error) {
|
|
if len(q.Steps) < 2 {
|
|
return nil, fmt.Errorf("funnel requires at least 2 steps")
|
|
}
|
|
|
|
type stepTpl struct {
|
|
Index int
|
|
Last bool
|
|
}
|
|
stepsTpl := make([]stepTpl, len(q.Steps))
|
|
for i := range q.Steps {
|
|
stepsTpl[i] = stepTpl{Index: i, Last: i == len(q.Steps)-1}
|
|
}
|
|
|
|
sql, err := r.tpl.Render("funnel_analysis.sql.tmpl", map[string]any{
|
|
"Steps": stepsTpl,
|
|
"StepCount": len(q.Steps),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
args := []any{
|
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
|
clickhouse.Named("from", chTime(q.From)),
|
|
clickhouse.Named("to", chTime(q.To)),
|
|
clickhouse.Named("window_seconds", chUint(uint64(q.WindowSeconds))),
|
|
}
|
|
for i, name := range q.Steps {
|
|
args = append(args, clickhouse.Named(fmt.Sprintf("step%d", i), name))
|
|
}
|
|
|
|
rows, err := r.ch.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("clickhouse funnel: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return ScanRows(rows)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Retention
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type RetentionQuery struct {
|
|
WorkspaceID string
|
|
InitialEvent string
|
|
ReturnEvent string
|
|
From time.Time
|
|
To time.Time
|
|
Periods int // e.g. 14 => D0..D13
|
|
}
|
|
|
|
func (r *AnalyticsRepo) Retention(ctx context.Context, q RetentionQuery) (*model.QueryResult, error) {
|
|
if q.Periods < 1 {
|
|
q.Periods = 14
|
|
}
|
|
type periodTpl struct {
|
|
RIndex int
|
|
OffsetDay int
|
|
Last bool
|
|
}
|
|
outer := make([]periodTpl, q.Periods)
|
|
for i := 0; i < q.Periods; i++ {
|
|
outer[i] = periodTpl{RIndex: i + 2, OffsetDay: i + 1, Last: i == q.Periods-1}
|
|
}
|
|
|
|
sql, err := r.tpl.Render("retention_cohort.sql.tmpl", map[string]any{
|
|
"Outer": outer,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := r.ch.Query(ctx, sql,
|
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
|
clickhouse.Named("from", chTime(q.From)),
|
|
clickhouse.Named("to", chTime(q.To)),
|
|
clickhouse.Named("initial_event", q.InitialEvent),
|
|
clickhouse.Named("return_event", q.ReturnEvent),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("clickhouse retention: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return ScanRows(rows)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type SessionQuery struct {
|
|
WorkspaceID string
|
|
UserID string // optional
|
|
From time.Time
|
|
To time.Time
|
|
TimeoutSeconds uint32
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
func (r *AnalyticsRepo) Sessions(ctx context.Context, q SessionQuery) (*model.QueryResult, error) {
|
|
sql, err := r.tpl.Render("session_analysis.sql.tmpl", map[string]any{
|
|
"HasUserID": q.UserID != "",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
args := []any{
|
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
|
clickhouse.Named("from", chTime(q.From)),
|
|
clickhouse.Named("to", chTime(q.To)),
|
|
clickhouse.Named("timeout_seconds", chUint(uint64(q.TimeoutSeconds))),
|
|
clickhouse.Named("limit", chUint(uint64(q.Limit))),
|
|
clickhouse.Named("offset", chUint(uint64(q.Offset))),
|
|
}
|
|
if q.UserID != "" {
|
|
args = append(args, clickhouse.Named("user_id", q.UserID))
|
|
}
|
|
|
|
rows, err := r.ch.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("clickhouse session: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return ScanRows(rows)
|
|
}
|