Compare commits
2 Commits
81ba67f346
...
b40568dd30
| Author | SHA1 | Date | |
|---|---|---|---|
| b40568dd30 | |||
| 5a1829bc0f |
@@ -10,6 +10,34 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.6.0
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
github.com/redis/rueidis v1.0.45
|
github.com/redis/rueidis v1.0.45
|
||||||
github.com/stretchr/testify v1.9.0
|
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.7 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/paulmach/orb v0.11.1 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.26.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/crypto v0.28.0 // indirect
|
||||||
|
golang.org/x/net v0.30.0 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
|
golang.org/x/text v0.19.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
159
data-layer/api/go.sum
Normal file
159
data-layer/api/go.sum
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||||
|
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
|
||||||
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
|
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||||
|
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
|
||||||
|
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||||
|
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||||
|
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
|
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||||
|
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/rueidis v1.0.45 h1:j7hfcqfLLIqgTK3IkxBhXdeJcP34t3XLXvorDLqXfgM=
|
||||||
|
github.com/redis/rueidis v1.0.45/go.mod h1:by+34b0cFXndxtYmPAHpoTHO5NkosDlBvhexoTURIxM=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
|
||||||
|
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
|
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
|
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
|
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
|
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -77,7 +77,7 @@ func CORS(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id")
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id, X-Workspace-Id")
|
||||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ func (r *AnalyticsRepo) Funnel(ctx context.Context, q FunnelQuery) (*model.Query
|
|||||||
|
|
||||||
args := []any{
|
args := []any{
|
||||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
clickhouse.Named("from", chTime(q.From)),
|
||||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
clickhouse.Named("to", chTime(q.To)),
|
||||||
clickhouse.Named("window_seconds", q.WindowSeconds),
|
clickhouse.Named("window_seconds", chUint(uint64(q.WindowSeconds))),
|
||||||
}
|
}
|
||||||
for i, name := range q.Steps {
|
for i, name := range q.Steps {
|
||||||
args = append(args, clickhouse.Named(fmt.Sprintf("step%d", i), name))
|
args = append(args, clickhouse.Named(fmt.Sprintf("step%d", i), name))
|
||||||
@@ -112,8 +112,8 @@ func (r *AnalyticsRepo) Retention(ctx context.Context, q RetentionQuery) (*model
|
|||||||
|
|
||||||
rows, err := r.ch.Query(ctx, sql,
|
rows, err := r.ch.Query(ctx, sql,
|
||||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
clickhouse.Named("from", chTime(q.From)),
|
||||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
clickhouse.Named("to", chTime(q.To)),
|
||||||
clickhouse.Named("initial_event", q.InitialEvent),
|
clickhouse.Named("initial_event", q.InitialEvent),
|
||||||
clickhouse.Named("return_event", q.ReturnEvent),
|
clickhouse.Named("return_event", q.ReturnEvent),
|
||||||
)
|
)
|
||||||
@@ -148,11 +148,11 @@ func (r *AnalyticsRepo) Sessions(ctx context.Context, q SessionQuery) (*model.Qu
|
|||||||
|
|
||||||
args := []any{
|
args := []any{
|
||||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
clickhouse.Named("from", chTime(q.From)),
|
||||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
clickhouse.Named("to", chTime(q.To)),
|
||||||
clickhouse.Named("timeout_seconds", q.TimeoutSeconds),
|
clickhouse.Named("timeout_seconds", chUint(uint64(q.TimeoutSeconds))),
|
||||||
clickhouse.Named("limit", uint32(q.Limit)),
|
clickhouse.Named("limit", chUint(uint64(q.Limit))),
|
||||||
clickhouse.Named("offset", uint32(q.Offset)),
|
clickhouse.Named("offset", chUint(uint64(q.Offset))),
|
||||||
}
|
}
|
||||||
if q.UserID != "" {
|
if q.UserID != "" {
|
||||||
args = append(args, clickhouse.Named("user_id", q.UserID))
|
args = append(args, clickhouse.Named("user_id", q.UserID))
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package repo
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ClickHouse/clickhouse-go/v2"
|
"github.com/ClickHouse/clickhouse-go/v2"
|
||||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
@@ -11,6 +13,18 @@ import (
|
|||||||
"github.com/dbiz/cdp/data-layer/api/internal/templates"
|
"github.com/dbiz/cdp/data-layer/api/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// chTime formats a Go time.Time for ClickHouse server-side query parameters.
|
||||||
|
// clickhouse-go v2 routes args declared via {name:Type} syntax through the
|
||||||
|
// server-side parameter protocol, which only accepts string values -- typed
|
||||||
|
// helpers like clickhouse.DateNamed fail with
|
||||||
|
// "expected string value in NamedValue for query parameter".
|
||||||
|
// We emit the format ClickHouse parses for DateTime64(3,'UTC').
|
||||||
|
func chTime(t time.Time) string {
|
||||||
|
return t.UTC().Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func chUint(n uint64) string { return strconv.FormatUint(n, 10) }
|
||||||
|
|
||||||
type EventRepo struct {
|
type EventRepo struct {
|
||||||
ch driver.Conn
|
ch driver.Conn
|
||||||
tpl *templates.Store
|
tpl *templates.Store
|
||||||
@@ -39,10 +53,10 @@ func (r *EventRepo) QueryEvents(ctx context.Context, q model.EventQuery) (*model
|
|||||||
|
|
||||||
args := []any{
|
args := []any{
|
||||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
clickhouse.Named("from", chTime(q.From)),
|
||||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
clickhouse.Named("to", chTime(q.To)),
|
||||||
clickhouse.Named("limit", uint32(q.Limit)),
|
clickhouse.Named("limit", chUint(uint64(q.Limit))),
|
||||||
clickhouse.Named("offset", uint32(q.Offset)),
|
clickhouse.Named("offset", chUint(uint64(q.Offset))),
|
||||||
}
|
}
|
||||||
if q.UserID != "" {
|
if q.UserID != "" {
|
||||||
args = append(args, clickhouse.Named("user_id", q.UserID))
|
args = append(args, clickhouse.Named("user_id", q.UserID))
|
||||||
@@ -73,8 +87,8 @@ func (r *EventRepo) QueryProfileTimeline(ctx context.Context, workspaceID, userI
|
|||||||
rows, err := r.ch.Query(ctx, sql,
|
rows, err := r.ch.Query(ctx, sql,
|
||||||
clickhouse.Named("workspace_id", workspaceID),
|
clickhouse.Named("workspace_id", workspaceID),
|
||||||
clickhouse.Named("user_id", userID),
|
clickhouse.Named("user_id", userID),
|
||||||
clickhouse.Named("limit", uint32(limit)),
|
clickhouse.Named("limit", chUint(uint64(limit))),
|
||||||
clickhouse.Named("offset", uint32(offset)),
|
clickhouse.Named("offset", chUint(uint64(offset))),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("clickhouse query: %w", err)
|
return nil, fmt.Errorf("clickhouse query: %w", err)
|
||||||
@@ -146,7 +160,8 @@ func newScanTarget(typeName string) any {
|
|||||||
var v bool
|
var v bool
|
||||||
return &v
|
return &v
|
||||||
case "time.Time":
|
case "time.Time":
|
||||||
return new(any) // let driver fill, deref below handles it
|
var v time.Time
|
||||||
|
return &v
|
||||||
case "map[string]string":
|
case "map[string]string":
|
||||||
var v map[string]string
|
var v map[string]string
|
||||||
return &v
|
return &v
|
||||||
@@ -186,6 +201,8 @@ func derefScanTarget(p any) any {
|
|||||||
return *v
|
return *v
|
||||||
case *[]string:
|
case *[]string:
|
||||||
return *v
|
return *v
|
||||||
|
case *time.Time:
|
||||||
|
return *v
|
||||||
case *any:
|
case *any:
|
||||||
return *v
|
return *v
|
||||||
default:
|
default:
|
||||||
|
|||||||
5197
data-layer/console/package-lock.json
generated
Normal file
5197
data-layer/console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AppShell } from '@/components/AppShell';
|
import { AppShell } from '@/components/AppShell';
|
||||||
|
import { DashboardPage } from '@/pages/Dashboard';
|
||||||
import { ExplorePage } from '@/pages/Explore';
|
import { ExplorePage } from '@/pages/Explore';
|
||||||
import { SQLPage } from '@/pages/SQL';
|
import { SQLPage } from '@/pages/SQL';
|
||||||
import { ProfilesPage } from '@/pages/Profiles';
|
import { ProfilesPage } from '@/pages/Profiles';
|
||||||
@@ -19,7 +20,8 @@ export function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route path="/" element={<ExplorePage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/explore" element={<ExplorePage />} />
|
||||||
<Route path="/sql" element={<SQLPage />} />
|
<Route path="/sql" element={<SQLPage />} />
|
||||||
<Route path="/profiles" element={<ProfilesPage />} />
|
<Route path="/profiles" element={<ProfilesPage />} />
|
||||||
<Route path="/funnels" element={<FunnelsPage />} />
|
<Route path="/funnels" element={<FunnelsPage />} />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, Code2, LineChart, Search, Settings, Tags, Users,
|
Activity, BarChart3, Code2, LineChart, Search, Settings, Tags, Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Explore', icon: Search },
|
{ to: '/', label: 'Dashboard', icon: BarChart3 },
|
||||||
|
{ to: '/explore', label: 'Explore', icon: Search },
|
||||||
{ to: '/sql', label: 'Custom SQL', icon: Code2 },
|
{ to: '/sql', label: 'Custom SQL', icon: Code2 },
|
||||||
{ to: '/profiles', label: 'Profiles', icon: Users },
|
{ to: '/profiles', label: 'Profiles', icon: Users },
|
||||||
{ to: '/funnels', label: 'Funnels', icon: LineChart },
|
{ to: '/funnels', label: 'Funnels', icon: LineChart },
|
||||||
|
|||||||
349
data-layer/console/src/pages/Dashboard.tsx
Normal file
349
data-layer/console/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { analytics, type QueryResult } from '@/api/client';
|
||||||
|
import { useWorkspace } from '@/stores/workspace';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business overview for the e-commerce demo data (Product Viewed /
|
||||||
|
* Product Added / Order Completed). Auto-refreshes every 15 s.
|
||||||
|
*
|
||||||
|
* Pure ClickHouse queries against analytics.events_track via POST /query/sql.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REFRESH_MS = 15_000;
|
||||||
|
const WINDOW = '1 HOUR';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SQL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SQL = {
|
||||||
|
// Revenue, orders, AOV all from the same row to keep network round-trips down.
|
||||||
|
orderKpis: `
|
||||||
|
SELECT
|
||||||
|
countIf(event = 'Order Completed') AS orders,
|
||||||
|
sumIf(toFloat64OrZero(properties['total']), event = 'Order Completed') AS revenue,
|
||||||
|
avgIf(toFloat64OrZero(properties['total']), event = 'Order Completed') AS aov
|
||||||
|
FROM analytics.events_track
|
||||||
|
WHERE received_at >= now() - INTERVAL ${WINDOW}`,
|
||||||
|
|
||||||
|
activeCustomers: `
|
||||||
|
SELECT uniqExact(user_id) AS active
|
||||||
|
FROM analytics.events_track
|
||||||
|
WHERE received_at >= now() - INTERVAL ${WINDOW}
|
||||||
|
AND user_id != ''`,
|
||||||
|
|
||||||
|
// Funnel counts for the canonical 3-step e-commerce funnel.
|
||||||
|
funnel: `
|
||||||
|
SELECT
|
||||||
|
countIf(event = 'Product Viewed') AS viewed,
|
||||||
|
countIf(event = 'Product Added') AS added,
|
||||||
|
countIf(event = 'Order Completed') AS completed
|
||||||
|
FROM analytics.events_track
|
||||||
|
WHERE received_at >= now() - INTERVAL ${WINDOW}`,
|
||||||
|
|
||||||
|
// Top products by views, with the matching "added to cart" count for context.
|
||||||
|
topProducts: `
|
||||||
|
SELECT
|
||||||
|
properties['name'] AS product,
|
||||||
|
properties['category'] AS category,
|
||||||
|
countIf(event = 'Product Viewed') AS views,
|
||||||
|
countIf(event = 'Product Added') AS added,
|
||||||
|
round(
|
||||||
|
if(countIf(event = 'Product Viewed') > 0,
|
||||||
|
countIf(event = 'Product Added') / countIf(event = 'Product Viewed'),
|
||||||
|
0
|
||||||
|
) * 100, 1
|
||||||
|
) AS add_rate_pct
|
||||||
|
FROM analytics.events_track
|
||||||
|
WHERE event IN ('Product Viewed', 'Product Added')
|
||||||
|
AND received_at >= now() - INTERVAL ${WINDOW}
|
||||||
|
AND properties['name'] != ''
|
||||||
|
GROUP BY product, category
|
||||||
|
ORDER BY views DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
|
||||||
|
recentOrders: `
|
||||||
|
SELECT
|
||||||
|
received_at,
|
||||||
|
user_id,
|
||||||
|
properties['order_id'] AS order_id,
|
||||||
|
toFloat64OrZero(properties['total']) AS total,
|
||||||
|
properties['currency'] AS currency,
|
||||||
|
length(JSONExtractArrayRaw(properties['products'])) AS line_items
|
||||||
|
FROM analytics.events_track
|
||||||
|
WHERE event = 'Order Completed'
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
|
||||||
|
// Customers ranked by spend in the window.
|
||||||
|
topCustomers: `
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
anyHeavy(traits['email']) AS email,
|
||||||
|
anyHeavy(traits['plan']) AS plan,
|
||||||
|
countIf(event = 'Order Completed') AS orders,
|
||||||
|
sumIf(toFloat64OrZero(properties['total']), event = 'Order Completed') AS revenue
|
||||||
|
FROM analytics.events_track
|
||||||
|
WHERE received_at >= now() - INTERVAL ${WINDOW}
|
||||||
|
AND user_id != ''
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hooks / helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useSQL(key: string, sql: string) {
|
||||||
|
const workspace = useWorkspace((s) => s.currentWorkspace);
|
||||||
|
return useQuery<QueryResult>({
|
||||||
|
queryKey: ['dashboard', key, workspace],
|
||||||
|
queryFn: () => analytics(workspace).querySQL({ sql }),
|
||||||
|
refetchInterval: REFRESH_MS,
|
||||||
|
staleTime: REFRESH_MS / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalar<T = number>(res: QueryResult | undefined, col = 0, fallback: T = 0 as T): T {
|
||||||
|
if (!res || res.rows.length === 0) return fallback;
|
||||||
|
return (res.rows[0][col] ?? fallback) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtNumber = (n: number) =>
|
||||||
|
n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M'
|
||||||
|
: n >= 1_000 ? (n / 1_000).toFixed(1) + 'K'
|
||||||
|
: new Intl.NumberFormat().format(Math.round(n));
|
||||||
|
|
||||||
|
const fmtMoney = (n: number, currency = 'USD') =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 2 }).format(n);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const orderKpis = useSQL('order_kpis', SQL.orderKpis);
|
||||||
|
const activeCustomers = useSQL('active', SQL.activeCustomers);
|
||||||
|
const funnel = useSQL('funnel', SQL.funnel);
|
||||||
|
const topProducts = useSQL('top_products', SQL.topProducts);
|
||||||
|
const recentOrders = useSQL('recent_orders', SQL.recentOrders);
|
||||||
|
const topCustomers = useSQL('top_customers', SQL.topCustomers);
|
||||||
|
|
||||||
|
const orders = Number(scalar(orderKpis.data, 0));
|
||||||
|
const revenue = Number(scalar(orderKpis.data, 1));
|
||||||
|
const aov = Number(scalar(orderKpis.data, 2));
|
||||||
|
const active = Number(scalar(activeCustomers.data, 0));
|
||||||
|
|
||||||
|
const funnelRow = funnel.data?.rows[0] ?? [0, 0, 0];
|
||||||
|
const viewed = Number(funnelRow[0] ?? 0);
|
||||||
|
const added = Number(funnelRow[1] ?? 0);
|
||||||
|
const completed = Number(funnelRow[2] ?? 0);
|
||||||
|
const funnelData = [
|
||||||
|
{ stage: 'Product Viewed', count: viewed, pct: 100 },
|
||||||
|
{ stage: 'Product Added', count: added, pct: viewed > 0 ? (added / viewed) * 100 : 0 },
|
||||||
|
{ stage: 'Order Completed', count: completed, pct: viewed > 0 ? (completed / viewed) * 100 : 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Overview</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Last 1 hour · refreshes every {REFRESH_MS / 1000}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI strip */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<Kpi title="Revenue" value={fmtMoney(revenue)} loading={orderKpis.isPending} />
|
||||||
|
<Kpi title="Orders" value={fmtNumber(orders)} loading={orderKpis.isPending} />
|
||||||
|
<Kpi title="Avg. order value" value={fmtMoney(aov || 0)} loading={orderKpis.isPending} />
|
||||||
|
<Kpi title="Active customers" value={fmtNumber(active)} loading={activeCustomers.isPending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Funnel + Top products */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Purchase funnel</CardTitle>
|
||||||
|
<CardDescription>Product Viewed → Added → Order Completed</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-72">
|
||||||
|
{funnel.isPending ? (
|
||||||
|
<Skeleton />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={funnelData} layout="vertical" margin={{ left: 0, right: 24 }}>
|
||||||
|
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis dataKey="stage" type="category" width={130} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ fontSize: 12 }}
|
||||||
|
formatter={(v: number, _name, ctx) =>
|
||||||
|
[`${fmtNumber(v)} (${ctx.payload.pct.toFixed(1)}%)`, 'count']
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||||
|
{funnelData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={['#3b82f6', '#8b5cf6', '#10b981'][i]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top products</CardTitle>
|
||||||
|
<CardDescription>Most-viewed, with add-to-cart rate.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{topProducts.isPending ? (
|
||||||
|
<Skeleton />
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-1">product</th>
|
||||||
|
<th className="py-1 text-right">views</th>
|
||||||
|
<th className="py-1 text-right">added</th>
|
||||||
|
<th className="py-1 text-right">rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(topProducts.data?.rows ?? []).map(([product, _cat, views, added, rate], i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1">{String(product)}</td>
|
||||||
|
<td className="py-1 text-right">{fmtNumber(Number(views))}</td>
|
||||||
|
<td className="py-1 text-right">{fmtNumber(Number(added))}</td>
|
||||||
|
<td className="py-1 text-right text-muted-foreground">{Number(rate).toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(topProducts.data?.rows ?? []).length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="py-3 text-center text-muted-foreground">— no product events yet —</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top customers + Recent orders */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top customers</CardTitle>
|
||||||
|
<CardDescription>Ranked by revenue in the window.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{topCustomers.isPending ? (
|
||||||
|
<Skeleton />
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-1">customer</th>
|
||||||
|
<th className="py-1">plan</th>
|
||||||
|
<th className="py-1 text-right">orders</th>
|
||||||
|
<th className="py-1 text-right">revenue</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(topCustomers.data?.rows ?? []).map(([uid, email, plan, count, rev], i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1">
|
||||||
|
<div>{String(email || uid)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">{String(uid)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-muted-foreground">{String(plan ?? '')}</td>
|
||||||
|
<td className="py-1 text-right">{fmtNumber(Number(count))}</td>
|
||||||
|
<td className="py-1 text-right font-medium">{fmtMoney(Number(rev))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(topCustomers.data?.rows ?? []).length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="py-3 text-center text-muted-foreground">— no customer activity yet —</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent orders</CardTitle>
|
||||||
|
<CardDescription>Last 10 completed orders.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentOrders.isPending ? (
|
||||||
|
<Skeleton />
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-1">when</th>
|
||||||
|
<th className="py-1">customer</th>
|
||||||
|
<th className="py-1 text-right">items</th>
|
||||||
|
<th className="py-1 text-right">total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(recentOrders.data?.rows ?? []).map(([ts, uid, _orderId, total, currency, items], i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1 whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{new Date(String(ts)).toLocaleTimeString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 font-mono text-xs">{String(uid)}</td>
|
||||||
|
<td className="py-1 text-right">{fmtNumber(Number(items))}</td>
|
||||||
|
<td className="py-1 text-right font-medium">{fmtMoney(Number(total), String(currency || 'USD'))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(recentOrders.data?.rows ?? []).length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="py-3 text-center text-muted-foreground">— no orders yet —</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Kpi({ title, value, loading }: { title: string; value: string; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>{title}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading
|
||||||
|
? <div className="h-7 w-24 animate-pulse rounded bg-muted" />
|
||||||
|
: <div className="text-3xl font-semibold tracking-tight">{value}</div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,334 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Activity, BookmarkPlus, Repeat, ShoppingCart, Sparkles, TrendingUp, Wand2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { analytics, ApiError, type QueryResult } from '@/api/client';
|
||||||
|
import { useWorkspace } from '@/stores/workspace';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cohort retention with pre-baked templates (PostHog-style).
|
||||||
|
*
|
||||||
|
* Users pick a template card -> form auto-fills and a query fires. Power
|
||||||
|
* users can still tweak the form below before re-running. A future "Custom
|
||||||
|
* builder" will replace the raw form with a typed expression UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_PERIODS = 7;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Templates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Sparkles;
|
||||||
|
initial_event: string;
|
||||||
|
return_event: string;
|
||||||
|
periods: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATES: Template[] = [
|
||||||
|
{
|
||||||
|
id: 'engaged-browsers',
|
||||||
|
name: 'Engaged browsers',
|
||||||
|
description: 'Of users who viewed a product, how many come back to browse on day N.',
|
||||||
|
icon: Activity,
|
||||||
|
initial_event: 'Product Viewed',
|
||||||
|
return_event: 'Product Viewed',
|
||||||
|
periods: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cart-to-purchase',
|
||||||
|
name: 'Cart → purchase',
|
||||||
|
description: 'Users who added to cart, then completed an order on day N. Conversion proxy.',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
initial_event: 'Product Added',
|
||||||
|
return_event: 'Order Completed',
|
||||||
|
periods: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'repeat-buyers',
|
||||||
|
name: 'Repeat buyers',
|
||||||
|
description: 'Of users who completed an order, how many bought again on day N. Loyalty.',
|
||||||
|
icon: Repeat,
|
||||||
|
initial_event: 'Order Completed',
|
||||||
|
return_event: 'Order Completed',
|
||||||
|
periods: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post-purchase-browse',
|
||||||
|
name: 'Post-purchase browsing',
|
||||||
|
description: 'After buying, do customers come back to browse? Engagement after revenue.',
|
||||||
|
icon: TrendingUp,
|
||||||
|
initial_event: 'Order Completed',
|
||||||
|
return_event: 'Product Viewed',
|
||||||
|
periods: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 're-engagement',
|
||||||
|
name: 'Re-engagement',
|
||||||
|
description: 'Of browsers, how many converted to a purchase on day N. Close the loop.',
|
||||||
|
icon: Sparkles,
|
||||||
|
initial_event: 'Product Viewed',
|
||||||
|
return_event: 'Order Completed',
|
||||||
|
periods: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom',
|
||||||
|
name: 'Custom',
|
||||||
|
description: 'Use the form below to define your own cohort.',
|
||||||
|
icon: Wand2,
|
||||||
|
initial_event: 'Product Viewed',
|
||||||
|
return_event: 'Product Viewed',
|
||||||
|
periods: DEFAULT_PERIODS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isoDate(daysOffset: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
d.setUTCDate(d.getUTCDate() + daysOffset);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function RetentionPage() {
|
export function RetentionPage() {
|
||||||
|
const workspace = useWorkspace((s) => s.currentWorkspace);
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState<string>(TEMPLATES[0].id);
|
||||||
|
const [initialEvent, setInitialEvent] = useState(TEMPLATES[0].initial_event);
|
||||||
|
const [returnEvent, setReturnEvent] = useState(TEMPLATES[0].return_event);
|
||||||
|
const [periods, setPeriods] = useState(TEMPLATES[0].periods);
|
||||||
|
const [from, setFrom] = useState(isoDate(-(TEMPLATES[0].periods + 1)));
|
||||||
|
const [to, setTo] = useState(isoDate(1));
|
||||||
|
|
||||||
|
const run = useMutation<QueryResult, ApiError>({
|
||||||
|
mutationFn: () =>
|
||||||
|
analytics(workspace).queryRetention({
|
||||||
|
initial_event: initialEvent,
|
||||||
|
return_event: returnEvent,
|
||||||
|
from, to,
|
||||||
|
periods,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyTemplate(t: Template, shouldRun = true) {
|
||||||
|
setActiveId(t.id);
|
||||||
|
setInitialEvent(t.initial_event);
|
||||||
|
setReturnEvent(t.return_event);
|
||||||
|
setPeriods(t.periods);
|
||||||
|
setFrom(isoDate(-(t.periods + 1)));
|
||||||
|
setTo(isoDate(1));
|
||||||
|
if (shouldRun && t.id !== 'custom') {
|
||||||
|
// Defer so the state above is committed before the request fires.
|
||||||
|
setTimeout(() => run.mutate(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run the first template once on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
run.mutate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold">Retention</h1>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<h1 className="text-2xl font-semibold">Cohort retention</h1>
|
||||||
Cohort retention curves.
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Pick a template, or tweak the form below for a one-off cohort.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template gallery */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{TEMPLATES.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
const active = t.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => applyTemplate(t)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition',
|
||||||
|
active
|
||||||
|
? 'border-primary ring-2 ring-primary/30'
|
||||||
|
: 'hover:border-primary/40 hover:bg-accent/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 mt-0.5 shrink-0 text-primary" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium leading-none">{t.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t.description}</div>
|
||||||
|
{t.id !== 'custom' && (
|
||||||
|
<div className="pt-1 text-[10px] font-mono text-muted-foreground">
|
||||||
|
{t.initial_event} → {t.return_event} · {t.periods}d
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form (always visible; flipping to custom on any field edit) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Definition</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{activeId === 'custom' ? 'Build your own cohort.' : 'Tweak the selected template.'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" disabled title="Saving custom cohorts comes in a later phase">
|
||||||
|
<BookmarkPlus className="mr-1 h-4 w-4" /> Save (soon)
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm flex flex-col gap-1">
|
||||||
|
Initial event
|
||||||
|
<Input value={initialEvent} onChange={(e) => { setInitialEvent(e.target.value); setActiveId('custom'); }} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm flex flex-col gap-1">
|
||||||
|
Return event
|
||||||
|
<Input value={returnEvent} onChange={(e) => { setReturnEvent(e.target.value); setActiveId('custom'); }} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm flex flex-col gap-1">
|
||||||
|
From (received_at >=)
|
||||||
|
<Input value={from} onChange={(e) => { setFrom(e.target.value); setActiveId('custom'); }} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm flex flex-col gap-1">
|
||||||
|
To (received_at <)
|
||||||
|
<Input value={to} onChange={(e) => { setTo(e.target.value); setActiveId('custom'); }} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm flex flex-col gap-1">
|
||||||
|
Periods (days)
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={periods}
|
||||||
|
min={1} max={30}
|
||||||
|
onChange={(e) => { setPeriods(Number(e.target.value) || DEFAULT_PERIODS); setActiveId('custom'); }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Button onClick={() => run.mutate()} disabled={run.isPending}>
|
||||||
|
{run.isPending ? 'Running…' : 'Compute'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{run.error && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Error</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="destructive">{run.error.status}</Badge>{' '}
|
||||||
|
<span className="text-sm">{run.error.message}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{run.data && (
|
||||||
|
<Matrix
|
||||||
|
result={run.data}
|
||||||
|
periods={periods}
|
||||||
|
headline={TEMPLATES.find((t) => t.id === activeId)?.name ?? 'Custom cohort'}
|
||||||
|
initialEvent={initialEvent}
|
||||||
|
returnEvent={returnEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Heatmap-ish retention matrix
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Matrix({
|
||||||
|
result, periods, headline, initialEvent, returnEvent,
|
||||||
|
}: {
|
||||||
|
result: QueryResult;
|
||||||
|
periods: number;
|
||||||
|
headline: string;
|
||||||
|
initialEvent: string;
|
||||||
|
returnEvent: string;
|
||||||
|
}) {
|
||||||
|
const rows = result.rows ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{headline}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Cohort = day a user first triggered <code>{initialEvent}</code>.
|
||||||
|
{' '}Cells show the share who triggered <code>{returnEvent}</code> on D+k.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-auto">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">— no cohorts matched the filter —</div>
|
||||||
|
) : (
|
||||||
|
<table className="text-xs font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="px-2 py-1 text-left">cohort day</th>
|
||||||
|
<th className="px-2 py-1 text-right">size</th>
|
||||||
|
{Array.from({ length: periods }).map((_, i) => (
|
||||||
|
<th key={i} className="px-2 py-1 text-right">D{i}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const cohortDay = String(row[0]).slice(0, 10);
|
||||||
|
const cohortSize = Number(row[1] ?? 0);
|
||||||
|
const dCells = row.slice(2, 2 + periods).map(Number);
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">{cohortDay}</td>
|
||||||
|
<td className="px-2 py-1 text-right">{cohortSize}</td>
|
||||||
|
{dCells.map((n, di) => {
|
||||||
|
const pct = cohortSize > 0 ? (n / cohortSize) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={di}
|
||||||
|
className="px-2 py-1 text-right"
|
||||||
|
style={{ background: heat(pct), color: pct > 60 ? 'white' : undefined }}
|
||||||
|
title={`${n} of ${cohortSize}`}
|
||||||
|
>
|
||||||
|
{cohortSize === 0 ? '–' : `${pct.toFixed(0)}%`}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function heat(pct: number): string {
|
||||||
|
if (pct <= 0) return 'transparent';
|
||||||
|
const alpha = 0.08 + (Math.min(pct, 100) / 100) * 0.87;
|
||||||
|
return `rgba(59, 130, 246, ${alpha.toFixed(2)})`;
|
||||||
|
}
|
||||||
|
|||||||
9
data-layer/console/src/vite-env.d.ts
vendored
Normal file
9
data-layer/console/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_ANALYTICS_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -1,41 +1,43 @@
|
|||||||
-- Retention Cohort -- of users whose first `initial_event` lands on day D,
|
-- Retention Cohort -- of users whose first `initial_event` lands on day D,
|
||||||
-- what share triggered `return_event` on day D+k for k in 1..Periods.
|
-- what share triggered `return_event` on day D+k for k in 1..Periods.
|
||||||
--
|
--
|
||||||
-- Required parameters (clickhouse.Named):
|
-- We compute the cohort day in a CTE first, then LEFT JOIN events_track and
|
||||||
|
-- count distinct returners per (cohort_day, day_offset). Doing it this way
|
||||||
|
-- avoids ClickHouse's "aggregate inside another aggregate" restriction that
|
||||||
|
-- the older retention()-based form ran into.
|
||||||
|
--
|
||||||
|
-- Required parameters (clickhouse.Named, string-valued):
|
||||||
-- workspace_id : String
|
-- workspace_id : String
|
||||||
-- from : DateTime64(3,'UTC')
|
-- from : DateTime64(3,'UTC') (formatted as 'YYYY-MM-DD HH:MM:SS.mmm')
|
||||||
-- to : DateTime64(3,'UTC')
|
-- to : DateTime64(3,'UTC')
|
||||||
-- initial_event : String
|
-- initial_event : String
|
||||||
-- return_event : String
|
-- return_event : String
|
||||||
--
|
--
|
||||||
-- Template inputs:
|
-- Template inputs:
|
||||||
-- .Outer : []{ RIndex int; OffsetDay int; Last bool }
|
-- .Outer : []{ OffsetDay int; Last bool }
|
||||||
-- One entry per follow-up day. RIndex is the position in the retention()
|
WITH cohorts AS (
|
||||||
-- output array; OffsetDay is the day delta from the cohort day.
|
|
||||||
SELECT
|
|
||||||
cohort_day,
|
|
||||||
countIf(arrayElement(r, 1)) AS cohort_size,
|
|
||||||
{{- range $p := .Outer }}
|
|
||||||
countIf(arrayElement(r, {{ $p.RIndex }})) AS retained_d{{ $p.OffsetDay }}{{ if not $p.Last }},{{ end }}
|
|
||||||
{{- end }}
|
|
||||||
FROM (
|
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
toDate(min(if(event = {initial_event:String}, timestamp, NULL))) AS cohort_day,
|
toDate(min(timestamp)) AS cohort_day
|
||||||
retention(
|
|
||||||
event = {initial_event:String} AND toDate(timestamp) = cohort_day,
|
|
||||||
{{- range $p := .Outer }}
|
|
||||||
event = {return_event:String} AND toDate(timestamp) = addDays(cohort_day, {{ $p.OffsetDay }}){{ if not $p.Last }},{{ end }}
|
|
||||||
{{- end }}
|
|
||||||
) AS r
|
|
||||||
FROM events_track
|
FROM events_track
|
||||||
WHERE workspace_id = {workspace_id:String}
|
WHERE workspace_id = {workspace_id:String}
|
||||||
AND received_at >= {from:DateTime64(3,'UTC')}
|
AND received_at >= {from:DateTime64(3,'UTC')}
|
||||||
AND received_at < {to:DateTime64(3,'UTC')}
|
AND received_at < {to:DateTime64(3,'UTC')}
|
||||||
AND user_id != ''
|
AND user_id != ''
|
||||||
AND event IN ({initial_event:String}, {return_event:String})
|
AND event = {initial_event:String}
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
HAVING cohort_day IS NOT NULL
|
|
||||||
)
|
)
|
||||||
GROUP BY cohort_day
|
SELECT
|
||||||
ORDER BY cohort_day
|
c.cohort_day AS cohort_day,
|
||||||
|
uniqExact(c.user_id) AS cohort_size,
|
||||||
|
{{- range $p := .Outer }}
|
||||||
|
uniqExactIf(c.user_id, e.event = {return_event:String} AND toDate(e.timestamp) = addDays(c.cohort_day, {{ $p.OffsetDay }})) AS retained_d{{ $p.OffsetDay }}{{ if not $p.Last }},{{ end }}
|
||||||
|
{{- end }}
|
||||||
|
FROM cohorts AS c
|
||||||
|
LEFT JOIN events_track AS e
|
||||||
|
ON e.workspace_id = {workspace_id:String}
|
||||||
|
AND e.user_id = c.user_id
|
||||||
|
AND e.received_at >= {from:DateTime64(3,'UTC')}
|
||||||
|
AND e.received_at < {to:DateTime64(3,'UTC')}
|
||||||
|
GROUP BY c.cohort_day
|
||||||
|
ORDER BY c.cohort_day
|
||||||
|
|||||||
211
ingestion/tests/k6/cohort.js
Normal file
211
ingestion/tests/k6/cohort.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
// k6 generator -- backfills events with timestamps spread over the last N days
|
||||||
|
// so the analytics console can show a realistic cohort retention matrix.
|
||||||
|
//
|
||||||
|
// How it works
|
||||||
|
// 1. setup() pre-computes a schedule: each of S shoppers gets a "first day"
|
||||||
|
// between [-N, 0], then revisits later days with a decay probability.
|
||||||
|
// 2. The default function fires one scheduled event per iteration; we run
|
||||||
|
// `shared-iterations` so all VUs collaborate to drain the schedule.
|
||||||
|
//
|
||||||
|
// We deliberately omit `sentAt` from the payload. The ingest's 24h late-event
|
||||||
|
// check is on sent_at (default = now when omitted), not on timestamp; so the
|
||||||
|
// timestamp column lands in the past while the request itself looks live.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// k6 run tests/k6/cohort.js
|
||||||
|
// k6 run -e DAYS=14 -e SHOPPERS=80 -e VUS=10 tests/k6/cohort.js
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import encoding from 'k6/encoding';
|
||||||
|
import { randomItem, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||||
|
|
||||||
|
const BASE = __ENV.BASE ?? 'http://localhost:3049';
|
||||||
|
const WRITE_KEY = __ENV.WRITE_KEY ?? 'cdp_dev_writekey_1234567890';
|
||||||
|
const SHOPPERS = parseInt(__ENV.SHOPPERS ?? '50', 10); // unique users
|
||||||
|
const DAYS = parseInt(__ENV.DAYS ?? '7', 10); // cohort window
|
||||||
|
const VUS = parseInt(__ENV.VUS ?? '5', 10);
|
||||||
|
|
||||||
|
const AUTH = 'Basic ' + encoding.b64encode(`${WRITE_KEY}:`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
{ id: 'sku_alpha', name: 'Alpha Hoodie', category: 'apparel', brand: 'CDP', price: 49.0 },
|
||||||
|
{ id: 'sku_beta', name: 'Beta Mug', category: 'drinkware', brand: 'CDP', price: 12.5 },
|
||||||
|
{ id: 'sku_gamma', name: 'Gamma Backpack', category: 'bags', brand: 'CDP', price: 89.0 },
|
||||||
|
{ id: 'sku_delta', name: 'Delta Sneakers', category: 'footwear', brand: 'Athleta', price: 129.0 },
|
||||||
|
{ id: 'sku_eps', name: 'Epsilon Headset', category: 'electronics',brand: 'Sonix', price: 199.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLANS = ['free', 'pro', 'team'];
|
||||||
|
const COUNTRIES = ['US', 'VN', 'GB', 'SG', 'JP', 'DE', 'FR'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pre-compute schedule (runs once before any VU starts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||||||
|
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||||
|
|
||||||
|
function isoOnDay(daysAgo, hourSeed) {
|
||||||
|
// anchor at "today 12:00 UTC" minus N days, jitter a few hours so events
|
||||||
|
// spread across the day rather than clumping at the same minute.
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCHours(12, 0, 0, 0);
|
||||||
|
d.setUTCDate(d.getUTCDate() - daysAgo);
|
||||||
|
d.setUTCHours(d.getUTCHours() + (hourSeed % 9) - 4);
|
||||||
|
d.setUTCMinutes(d.getUTCMinutes() + rand(0, 59));
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const events = [];
|
||||||
|
for (let i = 1; i <= SHOPPERS; i++) {
|
||||||
|
const userId = `c_${String(i).padStart(3, '0')}`;
|
||||||
|
const traits = {
|
||||||
|
email: `cohort${i}@example.com`,
|
||||||
|
plan: pick(PLANS),
|
||||||
|
country: pick(COUNTRIES),
|
||||||
|
};
|
||||||
|
|
||||||
|
// first day = day -N .. day -1 (no first-day-today so retention makes sense)
|
||||||
|
const firstDay = rand(1, DAYS - 1);
|
||||||
|
|
||||||
|
// Day 0 of this user's lifecycle = signup behavior.
|
||||||
|
push(events, userId, traits, firstDay, 'Product Viewed', null);
|
||||||
|
if (Math.random() < 0.7) push(events, userId, traits, firstDay, 'Product Added', null);
|
||||||
|
if (Math.random() < 0.4) push(events, userId, traits, firstDay, 'Order Completed', null);
|
||||||
|
|
||||||
|
// Later days: decaying return probability.
|
||||||
|
for (let d = firstDay - 1; d >= 0; d--) {
|
||||||
|
const since = firstDay - d; // days since first
|
||||||
|
const pReturn = Math.max(0.05, 0.85 - 0.15 * since); // 85% → 5%
|
||||||
|
if (Math.random() < pReturn) {
|
||||||
|
push(events, userId, traits, d, 'Product Viewed', null);
|
||||||
|
if (Math.random() < 0.3) push(events, userId, traits, d, 'Product Added', null);
|
||||||
|
if (Math.random() < 0.15) push(events, userId, traits, d, 'Order Completed', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shuffle so partition keys spread evenly.
|
||||||
|
for (let i = events.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[events[i], events[j]] = [events[j], events[i]];
|
||||||
|
}
|
||||||
|
return { events };
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(events, userId, traits, daysAgo, eventName, _) {
|
||||||
|
events.push({
|
||||||
|
userId,
|
||||||
|
traits,
|
||||||
|
daysAgo,
|
||||||
|
eventName,
|
||||||
|
seed: events.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
backfill: {
|
||||||
|
executor: 'shared-iterations',
|
||||||
|
vus: VUS,
|
||||||
|
// Set this big enough; setup() decides the real cap. We just stop once
|
||||||
|
// we run out of events (returns early in default()).
|
||||||
|
iterations: 20_000,
|
||||||
|
maxDuration: '5m',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ['rate<0.02'],
|
||||||
|
checks: ['rate>0.98'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
const ev = data.events[__ITER];
|
||||||
|
if (!ev) return; // schedule exhausted
|
||||||
|
|
||||||
|
const ts = isoOnDay(ev.daysAgo, ev.seed);
|
||||||
|
const product = randomItem(PRODUCTS);
|
||||||
|
const messageId = `cohort_${ev.userId}_${ev.seed}_${Date.now()}`;
|
||||||
|
|
||||||
|
let properties;
|
||||||
|
switch (ev.eventName) {
|
||||||
|
case 'Product Viewed':
|
||||||
|
properties = productProps(product);
|
||||||
|
break;
|
||||||
|
case 'Product Added':
|
||||||
|
properties = { ...productProps(product), quantity: randomIntBetween(1, 3) };
|
||||||
|
break;
|
||||||
|
case 'Order Completed':
|
||||||
|
properties = orderProps();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: 'track',
|
||||||
|
messageId,
|
||||||
|
userId: ev.userId,
|
||||||
|
anonymousId: `anon_${ev.userId}`,
|
||||||
|
event: ev.eventName,
|
||||||
|
properties,
|
||||||
|
traits: ev.traits,
|
||||||
|
// event time in the past...
|
||||||
|
timestamp: ts,
|
||||||
|
// ...request time intentionally omitted so the ingest's late-event guard
|
||||||
|
// (which checks sent_at, not timestamp) does not reject us.
|
||||||
|
context: {
|
||||||
|
library_name: 'k6-cohort-sim',
|
||||||
|
library_version: '0.1.0',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'k6/cohort',
|
||||||
|
locale: 'en-US',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = http.post(`${BASE}/v1/track`, payload, {
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: AUTH },
|
||||||
|
tags: { event: ev.eventName },
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'status 200': (r) => r.status === 200,
|
||||||
|
'body ok': (r) => r.json('ok') === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function productProps(p) {
|
||||||
|
return {
|
||||||
|
product_id: p.id, sku: p.id, name: p.name, category: p.category,
|
||||||
|
brand: p.brand, price: p.price, currency: 'USD',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderProps() {
|
||||||
|
const lines = [];
|
||||||
|
const n = randomIntBetween(1, 3);
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const p = randomItem(PRODUCTS);
|
||||||
|
const qty = randomIntBetween(1, 2);
|
||||||
|
total += p.price * qty;
|
||||||
|
lines.push({ product_id: p.id, sku: p.id, name: p.name, price: p.price, quantity: qty });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
order_id: `ord_${Date.now()}_${randomIntBetween(1000, 9999)}`,
|
||||||
|
revenue: Number(total.toFixed(2)),
|
||||||
|
currency: 'USD',
|
||||||
|
tax: Number((total * 0.08).toFixed(2)),
|
||||||
|
shipping: 5,
|
||||||
|
total: Number((total + total * 0.08 + 5).toFixed(2)),
|
||||||
|
products: lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,69 +1,121 @@
|
|||||||
// k6 load test — POST /v1/track against the local cdp-ingest service.
|
// k6 load test simulating realistic e-commerce traffic for 5 shoppers.
|
||||||
|
//
|
||||||
|
// Each VU plays one shopper with a stable userId + anonymousId. Per iteration
|
||||||
|
// the shopper fires a random Segment-spec event from a small catalog
|
||||||
|
// (Product Viewed / Product Added / Order Completed), then "thinks" for a
|
||||||
|
// short while before the next action.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// brew install k6 # one-time
|
// brew install k6
|
||||||
// k6 run tests/k6/track.js # defaults: 50 CCU, 1m
|
// k6 run tests/k6/track.js
|
||||||
//
|
//
|
||||||
// Override at the CLI:
|
// Overrides:
|
||||||
// k6 run -e WRITE_KEY=xxx -e BASE=http://localhost:3049 \
|
// k6 run -e WRITE_KEY=xxx -e BASE=http://localhost:3049 \
|
||||||
// -e VUS=100 -e DURATION=2m tests/k6/track.js
|
// -e VUS=5 -e DURATION=2m tests/k6/track.js
|
||||||
|
|
||||||
import http from 'k6/http';
|
import http from 'k6/http';
|
||||||
import { check } from 'k6';
|
import { check, sleep } from 'k6';
|
||||||
|
import { randomItem, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||||
import encoding from 'k6/encoding';
|
import encoding from 'k6/encoding';
|
||||||
|
|
||||||
const BASE = __ENV.BASE ?? 'http://localhost:3049';
|
const BASE = __ENV.BASE ?? 'http://localhost:3049';
|
||||||
const WRITE_KEY = __ENV.WRITE_KEY ?? 'cdp_dev_writekey_1234567890';
|
const WRITE_KEY = __ENV.WRITE_KEY ?? 'cdp_dev_writekey_1234567890';
|
||||||
const VUS = parseInt(__ENV.VUS ?? '50', 10);
|
const VUS = parseInt(__ENV.VUS ?? '5', 10);
|
||||||
const DURATION = __ENV.DURATION ?? '1m';
|
const DURATION = __ENV.DURATION ?? '1m';
|
||||||
|
|
||||||
// Segment-compatible auth: Basic base64(writeKey + ":")
|
|
||||||
const AUTH = 'Basic ' + encoding.b64encode(`${WRITE_KEY}:`);
|
const AUTH = 'Basic ' + encoding.b64encode(`${WRITE_KEY}:`);
|
||||||
|
|
||||||
export const options = {
|
export const options = {
|
||||||
scenarios: {
|
scenarios: {
|
||||||
constant_load: {
|
shoppers: {
|
||||||
executor: 'constant-vus',
|
executor: 'constant-vus',
|
||||||
vus: VUS,
|
vus: VUS,
|
||||||
duration: DURATION,
|
duration: DURATION,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
thresholds: {
|
thresholds: {
|
||||||
http_req_failed: ['rate<0.01'], // < 1% errors
|
http_req_failed: ['rate<0.01'],
|
||||||
http_req_duration: ['p(95)<300', 'p(99)<800'],
|
http_req_duration: ['p(95)<500', 'p(99)<1500'],
|
||||||
checks: ['rate>0.99'],
|
checks: ['rate>0.99'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SHOPPERS = [
|
||||||
|
{ user_id: 'u_001', email: 'alice@example.com', plan: 'pro', country: 'US' },
|
||||||
|
{ user_id: 'u_002', email: 'bob@example.com', plan: 'free', country: 'VN' },
|
||||||
|
{ user_id: 'u_003', email: 'charlie@example.com', plan: 'pro', country: 'GB' },
|
||||||
|
{ user_id: 'u_004', email: 'dana@example.com', plan: 'free', country: 'SG' },
|
||||||
|
{ user_id: 'u_005', email: 'eric@example.com', plan: 'team', country: 'JP' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
{ id: 'sku_alpha', name: 'Alpha Hoodie', category: 'apparel', brand: 'CDP', price: 49.0 },
|
||||||
|
{ id: 'sku_beta', name: 'Beta Mug', category: 'drinkware', brand: 'CDP', price: 12.5 },
|
||||||
|
{ id: 'sku_gamma', name: 'Gamma Backpack', category: 'bags', brand: 'CDP', price: 89.0 },
|
||||||
|
{ id: 'sku_delta', name: 'Delta Sneakers', category: 'footwear', brand: 'Athleta', price: 129.0 },
|
||||||
|
{ id: 'sku_eps', name: 'Epsilon Headset', category: 'electronics',brand: 'Sonix', price: 199.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_KINDS = ['Product Viewed', 'Product Added', 'Order Completed'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-iteration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
// Stable per-VU identity. __VU starts at 1.
|
||||||
|
const shopper = SHOPPERS[(__VU - 1) % SHOPPERS.length];
|
||||||
|
const anonymousId = `anon_${shopper.user_id}`;
|
||||||
|
|
||||||
|
const eventName = randomItem(EVENT_KINDS);
|
||||||
|
let properties;
|
||||||
|
switch (eventName) {
|
||||||
|
case 'Product Viewed':
|
||||||
|
properties = productProps(randomItem(PRODUCTS));
|
||||||
|
break;
|
||||||
|
case 'Product Added':
|
||||||
|
properties = {
|
||||||
|
...productProps(randomItem(PRODUCTS)),
|
||||||
|
quantity: randomIntBetween(1, 3),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'Order Completed':
|
||||||
|
properties = orderProps();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const messageId = `k6_${__VU}_${__ITER}_${Date.now()}`;
|
const messageId = `k6_${shopper.user_id}_${__ITER}_${Date.now()}`;
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
type: 'track',
|
type: 'track',
|
||||||
messageId,
|
messageId,
|
||||||
anonymousId: `anon_${__VU}`,
|
userId: shopper.user_id,
|
||||||
userId: `user_${__VU}@example.com`,
|
anonymousId,
|
||||||
event: 'k6 Test Event',
|
event: eventName,
|
||||||
properties: {
|
properties,
|
||||||
testProp: 'load test',
|
traits: {
|
||||||
vu: __VU,
|
email: shopper.email,
|
||||||
iter: __ITER,
|
plan: shopper.plan,
|
||||||
price: 42.5,
|
country: shopper.country,
|
||||||
},
|
},
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
sentAt: now,
|
sentAt: now,
|
||||||
context: {
|
context: {
|
||||||
library_name: 'k6',
|
library_name: 'k6-ecommerce-sim',
|
||||||
library_version: '0.1.0',
|
library_version: '0.2.0',
|
||||||
ip: '127.0.0.1',
|
ip: '127.0.0.1',
|
||||||
userAgent: 'k6/loadtest',
|
userAgent: 'k6/loadtest',
|
||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
page: {
|
page: {
|
||||||
path: '/',
|
path: '/checkout',
|
||||||
host: 'example.com',
|
host: 'shop.example.com',
|
||||||
title: 'Example page',
|
title: 'Shop',
|
||||||
url: 'https://example.com/',
|
url: 'https://shop.example.com/checkout',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -73,6 +125,7 @@ export default function () {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: AUTH,
|
Authorization: AUTH,
|
||||||
},
|
},
|
||||||
|
tags: { event: eventName },
|
||||||
});
|
});
|
||||||
|
|
||||||
check(res, {
|
check(res, {
|
||||||
@@ -80,4 +133,51 @@ export default function () {
|
|||||||
'body ok': (r) => r.json('ok') === true,
|
'body ok': (r) => r.json('ok') === true,
|
||||||
'fast (<500ms)': (r) => r.timings.duration < 500,
|
'fast (<500ms)': (r) => r.timings.duration < 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Think time -- a real shopper does not click 100x/sec.
|
||||||
|
sleep(randomIntBetween(1, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function productProps(p) {
|
||||||
|
return {
|
||||||
|
product_id: p.id,
|
||||||
|
sku: p.id,
|
||||||
|
name: p.name,
|
||||||
|
category: p.category,
|
||||||
|
brand: p.brand,
|
||||||
|
price: p.price,
|
||||||
|
currency: 'USD',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderProps() {
|
||||||
|
// 1 - 3 line items
|
||||||
|
const lines = [];
|
||||||
|
const n = randomIntBetween(1, 3);
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const p = randomItem(PRODUCTS);
|
||||||
|
const qty = randomIntBetween(1, 2);
|
||||||
|
total += p.price * qty;
|
||||||
|
lines.push({
|
||||||
|
product_id: p.id,
|
||||||
|
sku: p.id,
|
||||||
|
name: p.name,
|
||||||
|
price: p.price,
|
||||||
|
quantity: qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
order_id: `ord_${Date.now()}_${randomIntBetween(1000, 9999)}`,
|
||||||
|
revenue: Number(total.toFixed(2)),
|
||||||
|
currency: 'USD',
|
||||||
|
tax: Number((total * 0.08).toFixed(2)),
|
||||||
|
shipping: 5,
|
||||||
|
total: Number((total + total * 0.08 + 5).toFixed(2)),
|
||||||
|
products: lines,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user