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