data layer

This commit is contained in:
2026-05-25 08:38:26 +07:00
parent 4e8c11d545
commit a428170fef
81 changed files with 3941 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
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.DateNamed("from", q.From, clickhouse.MilliSeconds),
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
clickhouse.Named("window_seconds", 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.DateNamed("from", q.From, clickhouse.MilliSeconds),
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
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.DateNamed("from", q.From, clickhouse.MilliSeconds),
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
clickhouse.Named("timeout_seconds", q.TimeoutSeconds),
clickhouse.Named("limit", uint32(q.Limit)),
clickhouse.Named("offset", uint32(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)
}