// Command server runs the CDP analytics HTTP API. package main import ( "context" "errors" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/go-chi/chi/v5" "github.com/redis/rueidis" "go.uber.org/zap" "github.com/dbiz/cdp/data-layer/api/internal/cache" "github.com/dbiz/cdp/data-layer/api/internal/config" "github.com/dbiz/cdp/data-layer/api/internal/handler" mw "github.com/dbiz/cdp/data-layer/api/internal/middleware" "github.com/dbiz/cdp/data-layer/api/internal/repo" "github.com/dbiz/cdp/data-layer/api/internal/service" "github.com/dbiz/cdp/data-layer/api/internal/templates" ) func main() { if err := run(); err != nil { log.Fatal(err) } } func run() error { cfg, err := config.Load() if err != nil { return err } logger, err := newLogger(cfg.LogLevel) if err != nil { return err } defer func() { _ = logger.Sync() }() ctx, cancel := context.WithCancel(context.Background()) defer cancel() // ---- infra clients ---------------------------------------------------- pg, err := repo.NewPool(ctx, cfg.PostgresDSN) if err != nil { return err } defer pg.Close() redisClient, err := rueidis.NewClient(rueidis.ClientOption{ InitAddress: []string{cfg.RedisAddr}, }) if err != nil { return err } defer redisClient.Close() chMain, err := repo.NewClickHouse(ctx, cfg.ClickHouseAddr, cfg.ClickHouseDB, cfg.ClickHouseUser, cfg.ClickHousePassword) if err != nil { return err } defer func() { _ = chMain.Close() }() chRO, err := repo.NewClickHouseReadOnly(ctx, cfg.ClickHouseAddr, cfg.ClickHouseDB, cfg.ClickHouseSQLUser, cfg.ClickHouseSQLPassword) if err != nil { // Read-only user might not be provisioned in dev. Log + fall back to // the main connection so /query/sql still works locally; production // must provide separate credentials. logger.Warn("read-only clickhouse user unavailable; /query/sql will use the main connection (dev only)", zap.Error(err)) chRO = chMain } else { defer func() { _ = chRO.Close() }() } // ---- shared singletons ------------------------------------------------ tpl := templates.New(cfg.ClickHouseTemplatesDir) c := cache.New(redisClient) eventRepo := repo.NewEventRepo(chMain, tpl) analyticsRepo := repo.NewAnalyticsRepo(chMain, tpl) profileRepo := repo.NewProfileRepo(pg) savedRepo := repo.NewSavedQueryRepo(pg) querySvc := service.NewQueryService(eventRepo, analyticsRepo, c, cfg.CacheTTLQuery, logger) sqlSvc := service.NewSQLService(chRO, logger) profileSvc := service.NewProfileService(profileRepo, eventRepo, c, cfg.CacheTTLProfile, logger) eventH := handler.NewEventHandler(querySvc, logger) sqlH := handler.NewSQLHandler(sqlSvc, logger) profileH := handler.NewProfileHandler(profileSvc, logger) analyticsH := handler.NewAnalyticsHandler(querySvc, logger) savedH := handler.NewSavedQueryHandler(savedRepo, logger) // ---- HTTP router ------------------------------------------------------ r := chi.NewRouter() r.Use(mw.RequestID) r.Use(mw.Recover(logger)) r.Use(mw.Logger(logger)) r.Use(mw.CORS) r.Get("/health", eventH.Health) r.Get("/ready", eventH.Ready) r.Group(func(rr chi.Router) { rr.Use(mw.Workspace) rr.Post("/query/events", eventH.QueryEvents) rr.Post("/query/sql", sqlH.CustomSQL) rr.Post("/query/funnel", analyticsH.Funnel) rr.Post("/query/retention", analyticsH.Retention) rr.Post("/query/session", analyticsH.Session) rr.Get("/profiles/{id}", profileH.Get) rr.Get("/profiles/{id}/events", profileH.Timeline) rr.Post("/queries", savedH.Create) rr.Get("/queries", savedH.List) rr.Get("/queries/{id}", savedH.Get) rr.Put("/queries/{id}", savedH.Update) rr.Delete("/queries/{id}", savedH.Delete) }) srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: r, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } // ---- graceful shutdown ------------------------------------------------ shutdownErr := make(chan error, 1) go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh logger.Info("shutdown signal received; draining...") shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() shutdownErr <- srv.Shutdown(shutCtx) }() logger.Info("analytics api listening", zap.String("addr", cfg.HTTPAddr)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return <-shutdownErr } func newLogger(level string) (*zap.Logger, error) { lvl, err := zap.ParseAtomicLevel(level) if err != nil { lvl = zap.NewAtomicLevelAt(zap.InfoLevel) } cfg := zap.NewProductionConfig() cfg.Level = lvl cfg.EncoderConfig.TimeKey = "ts" cfg.EncoderConfig.MessageKey = "msg" return cfg.Build() }