diff --git a/.vscode/settings.json b/.vscode/settings.json index 29519146..8e6f3602 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "eslint.workingDirectories": ["apps", "packages"], - "prettier.enable": true + "prettier.enable": true, + "cSpell.words": ["clvh", "clyetopos"] } diff --git a/apps/bullmq-receiver/package.json b/apps/bullmq-receiver/package.json new file mode 100644 index 00000000..de3fb608 --- /dev/null +++ b/apps/bullmq-receiver/package.json @@ -0,0 +1,22 @@ +{ + "name": "bullmq-receiver", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "bullmq": "^5.8.2", + "ioredis": "^5.4.1" + }, + "devDependencies": { + "@types/node": "^14.14.31", + "ts-node-dev": "^1.1.6", + "typescript": "^4.2.3" + } +} diff --git a/apps/bullmq-receiver/src/index.ts b/apps/bullmq-receiver/src/index.ts new file mode 100644 index 00000000..d914c606 --- /dev/null +++ b/apps/bullmq-receiver/src/index.ts @@ -0,0 +1 @@ +console.log("hi"); diff --git a/apps/web/clickhouse/createDatabase.ts b/apps/web/clickhouse/createDatabase.ts new file mode 100644 index 00000000..921e7ae7 --- /dev/null +++ b/apps/web/clickhouse/createDatabase.ts @@ -0,0 +1,64 @@ +import { createClient } from "@clickhouse/client"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { AbbyEventType } from "@tryabby/core"; + +const client = createClient({ + url: "http://localhost:8123", +}); + +// client +// .command({ +// query: "CREATE DATABASE IF NOT EXISTS abby", +// }) +// .then((res) => { +// client.command({ +// query: ` +// DROP TABLE IF EXISTS abby.Event; +// `, +// }); +// }) +// .then((res) => { +// client.command({ +// query: ` +// CREATE TABLE IF NOT EXISTS abby.Event ( +// id UUID, +// project_id String, +// testName String, +// type Int, +// selectedVariant String, +// createdAt DateTime DEFAULT toDateTime(now()) NOT NULL, +// ) +// ENGINE = MergeTree() +// ORDER BY (project_id, testName) +// `, +// }); +// }) +// .catch((error) => { +// console.error("Error creating table:", error); +// }); + +async function insertEvents() { + const projectId = "clvh4sv5n0001furg6tj08z63"; + const testName = "clyetopos0001yd6wa4yybkvw"; + + for (let i = 0; i < 100_000; i++) { + await client.insert({ + table: "abby.Event", + format: "JSONEachRow", + values: [ + { + project_id: projectId, + testName: testName, + type: Math.random() < 0.5 ? AbbyEventType.PING : AbbyEventType.ACT, + selectedVariant: + Math.random() < 0.5 ? "New Variant 1" : "New Variant 2", + createdAt: + Math.floor(Date.now() / 1000) - + Math.floor(Math.random() * 24 * 60 * 60), + }, + ], + }); + } +} + +insertEvents(); diff --git a/apps/web/package.json b/apps/web/package.json index 8acd01c0..c7380661 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,12 +14,15 @@ "seed:events": "ts-node --compiler-options {\\\"module\\\":\\\"CommonJS\\\"} prisma/seedEvents.ts", "db:migrate": "prisma migrate dev", "generate:coupons": "pnpm ts-node -r tsconfig-paths/register --compiler-options {\\\"module\\\":\\\"CommonJS\\\"} prisma/generateCoupons.ts", + "clickhouse:migrate": "pnpm ts-node -r tsconfig-paths/register --compiler-options {\\\"module\\\":\\\"CommonJS\\\"} clickhouse/setupDB.ts", + "clickhouse:create": "pnpm ts-node -r tsconfig-paths/register --compiler-options {\\\"module\\\":\\\"CommonJS\\\"} clickhouse/createDatabase.ts", "mailhog:up": "docker-compose -f docker-compose.mailhog.yaml up", "mailhog:down": "docker-compose -f docker-compose.mailhog.yaml down", "test": "vitest", "start:docker": "npx prisma migrate deploy || { echo 'Migration failed, exiting'; exit 1; } && node server.js" }, "dependencies": { + "@clickhouse/client": "^1.0.1", "@code-hike/mdx": "0.9.0", "@databases/cache": "^1.0.0", "@dnd-kit/core": "^6.0.8", diff --git a/apps/web/src/api/routes/v1_event.ts b/apps/web/src/api/routes/v1_event.ts index ec41592f..07324a97 100644 --- a/apps/web/src/api/routes/v1_event.ts +++ b/apps/web/src/api/routes/v1_event.ts @@ -1,11 +1,8 @@ import { zValidator } from "@hono/zod-validator"; -import { abbyEventSchema, AbbyEventType } from "@tryabby/core"; +import { abbyEventSchema } from "@tryabby/core"; import { Hono } from "hono"; import isbot from "isbot"; -import { EventService } from "server/services/EventService"; -import { RequestCache } from "server/services/RequestCache"; -import { RequestService } from "server/services/RequestService"; import { checkRateLimit } from "server/common/ratelimit"; import { eventQueue } from "server/queue/queues"; diff --git a/apps/web/src/components/Test/Metrics.tsx b/apps/web/src/components/Test/Metrics.tsx index e2421cb2..3fa8c246 100644 --- a/apps/web/src/components/Test/Metrics.tsx +++ b/apps/web/src/components/Test/Metrics.tsx @@ -30,10 +30,10 @@ export const OPTIONS: ChartOptions<"bar"> = { maintainAspectRatio: false, scales: { y: { - min: 0, - max: 100, + beginAtZero: true, }, }, + plugins: { legend: { position: "top" as const, @@ -42,28 +42,18 @@ export const OPTIONS: ChartOptions<"bar"> = { }; const Metrics = ({ - pingEvents, - options, + visitData, }: { - pingEvents: Event[]; - options: ClientOption[]; + visitData: (ClientOption & { actEventCount: number })[]; }) => { - const labels = options.map((option) => option.identifier); - const actualData = useMemo(() => { - return options.map((option) => { - return { - pings: pingEvents.filter( - (event) => event.selectedVariant === option.identifier - ).length, - weight: option.chance, - }; - }); - }, [options, pingEvents]); + const labels = visitData.map((data) => data.identifier); - const absPings = actualData.reduce((accumulator, value) => { - return accumulator + value.pings; + const absPings = visitData.reduce((accumulator, value) => { + return accumulator + value.actEventCount; }, 0); + console.log(absPings, visitData); + return (
d.pings), + data: visitData.map((data) => data.actEventCount), + backgroundColor: "#A9E4EF", }, { label: "Expected", - data: actualData.map((data) => absPings * data.weight), + data: visitData.map((data) => absPings * data.chance), backgroundColor: "#f472b6", }, ], diff --git a/apps/web/src/components/Test/Section.tsx b/apps/web/src/components/Test/Section.tsx index 5b435185..c4539650 100644 --- a/apps/web/src/components/Test/Section.tsx +++ b/apps/web/src/components/Test/Section.tsx @@ -18,28 +18,27 @@ import { TitleEdit } from "components/TitleEdit"; import { Modal } from "components/Modal"; import { cn } from "lib/utils"; -function getBestVariant({ - absPings, - options, -}: { - absPings: number; - options: ClientOption[]; -}) { - const bestVariant = options.reduce( - (accumulator, option) => { - const pings = absPings * option.chance; - if (pings > accumulator.pings) { - return { - pings, - identifier: option.identifier, - }; - } - return accumulator; - }, - { pings: 0, identifier: "" } - ); +function getBestVariant(visitData: VisitData) { + const absPings = visitData + .map((data) => data.actEventCount) + .reduce((acc, curr) => acc + curr); - return bestVariant; + const diffToExpected = visitData.map((data) => { + return { + variantName: data.variantName, + difference: data.actEventCount - data.chance * absPings, + }; + }); + + let currentMax = diffToExpected[0]; + + diffToExpected.forEach((diff) => { + if (currentMax!.difference < diff.difference) { + currentMax = diff; + } + }); + + return currentMax?.variantName; } const DeleteTestModal = ({ @@ -125,24 +124,31 @@ export const Card = ({ ); }; +export type VisitData = { + visitedEventCount: number; + actEventCount: number; + id: string; + identifier: string; + testId: string; + chance: number; + + variantName: string; +}[]; + const Section = ({ name, - options = [], - events = [], id, -}: Test & { - options: ClientOption[]; - events: Event[]; + visitData, +}: { + name: string; + id: string; + visitData: VisitData; }) => { const router = useRouter(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const trpcContext = trpc.useContext(); const showAdvancedTestStats = useFeatureFlag("AdvancedTestStats"); - - const bestVariant = getBestVariant({ - absPings: events.filter((event) => event.type === AbbyEventType.ACT).length, - options, - }).identifier; + const bestVariant = getBestVariant(visitData); const { mutate: updateTestName } = trpc.tests.updateName.useMutation({ onSuccess() { @@ -190,7 +196,7 @@ const Section = ({

} > - + } > - event.type === AbbyEventType.PING - )} - /> + } > - event.type === AbbyEventType.ACT - )} - /> +
diff --git a/apps/web/src/components/Test/Serves.tsx b/apps/web/src/components/Test/Serves.tsx index 8934cf37..d00010a5 100644 --- a/apps/web/src/components/Test/Serves.tsx +++ b/apps/web/src/components/Test/Serves.tsx @@ -57,28 +57,17 @@ export const OPTIONS: ChartOptions<"bar"> = { }; const Serves = ({ - pingEvents, - options, + visitData, }: { - pingEvents: Event[]; - options: ClientOption[]; + visitData: (ClientOption & { visitedEventCount: number })[]; }) => { - const labels = options.map((option) => option.identifier); - - const actualData = useMemo(() => { - return options.map((option) => { - return pingEvents.filter( - (event) => event.selectedVariant === option.identifier - ).length; - }); - }, [options, pingEvents]); - - const absPings = actualData.reduce((accumulator, value) => { - return accumulator + value; + const labels = visitData.map((data) => data.identifier); + const absPings = visitData.reduce((accumulator, value) => { + return accumulator + value.visitedEventCount; }, 0); return ( -
+
parseFloat(option.chance.toString()) * 100 - ), + data: visitData.map((data) => { + return parseFloat(data.chance.toString()) * 100; + }), backgroundColor: "#A9E4EF", }, { label: "Actual", - data: actualData.map((data) => - Math.round((data / absPings) * 100) - ), + data: visitData.map((data) => { + return Math.round((data.visitedEventCount / absPings) * 100); + }), backgroundColor: "#f472b6", }, ], diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts index bdb5233c..93cb0808 100644 --- a/apps/web/src/lib/events.ts +++ b/apps/web/src/lib/events.ts @@ -1,8 +1,9 @@ +import { assertUnreachable } from "@tryabby/core"; import dayjs from "dayjs"; export enum SpecialTimeInterval { DAY = "day", - MONTH_TO_DATE = "month", + Last30DAYS = "30d", ALL_TIME = "all", } @@ -11,22 +12,10 @@ export const INTERVALS = [ label: "Today", value: SpecialTimeInterval.DAY, }, - // { - // label: "Last 7 days", - // value: "7d", - // }, { label: "Last 30 days", - value: "30d", + value: SpecialTimeInterval.Last30DAYS, }, - // { - // label: "Year to Date", - // value: SpecialTimeInterval.MONTH_TO_DATE, - // }, - // { - // label: "Last 12 months", - // value: "12mo", - // }, { label: "All Time", value: SpecialTimeInterval.ALL_TIME, @@ -54,12 +43,14 @@ export function getMSFromSpecialTimeInterval( case SpecialTimeInterval.DAY: { return 1000 * 60 * 60 * 24; } - case SpecialTimeInterval.MONTH_TO_DATE: { - return new Date().getTime() - new Date().setDate(1); + case SpecialTimeInterval.Last30DAYS: { + return 1000 * 60 * 60 * 24 * 30; } case SpecialTimeInterval.ALL_TIME: { return Infinity; } + default: + assertUnreachable(timeInterval); } } @@ -80,26 +71,41 @@ export function getFormattingByInterval(interval: INTERVAL) { export function getLabelsByInterval( interval: (typeof INTERVALS)[number]["value"], fistEventDate: Date -): Array { +): { labels: Array; dates: Array } { const formatting = getFormattingByInterval(interval); switch (interval) { case SpecialTimeInterval.DAY: { const baseData = dayjs().set("minute", 0); - return [0, 3, 6, 9, 12, 15, 18, 21].map((hour) => - baseData.set("hour", hour).format(formatting) + const dateArray = [0, 3, 6, 9, 12, 15, 18, 21].map((hour) => + baseData.set("hour", hour) ); + return { + labels: dateArray.map((date) => date.format(formatting)), + dates: dateArray.map((date) => date.toDate()), + }; } - case "30d": { - return Array.from({ length: 30 }, (_, i) => - dayjs().subtract(i, "day").format(formatting) + + case SpecialTimeInterval.Last30DAYS: { + const dateArray = Array.from({ length: 30 }, (_, i) => + dayjs().subtract(i, "day") ).reverse(); + return { + labels: dateArray.map((date) => date.format(formatting)), + dates: dateArray.map((date) => date.set("minute", 0).toDate()), + }; } case SpecialTimeInterval.ALL_TIME: { const diff = dayjs().diff(dayjs(fistEventDate), "month"); - return Array.from({ length: Math.max(diff, 6) }, (_, i) => - dayjs(fistEventDate).add(i, "month").format(formatting) + const dateArray = Array.from({ length: Math.max(diff, 6) }, (_, i) => + dayjs(fistEventDate).add(i, "month") ).reverse(); + return { + labels: dateArray.map((date) => date.format(formatting)), + dates: dateArray.map((date) => date.toDate()), + }; } + default: + return assertUnreachable(interval); } } diff --git a/apps/web/src/pages/projects/[projectId]/index.tsx b/apps/web/src/pages/projects/[projectId]/index.tsx index fe5c2819..b810a61a 100644 --- a/apps/web/src/pages/projects/[projectId]/index.tsx +++ b/apps/web/src/pages/projects/[projectId]/index.tsx @@ -10,6 +10,7 @@ import { AiOutlinePlus } from "react-icons/ai"; import { trpc } from "utils/trpc"; import { Button } from "components/ui/button"; import { GetStaticProps, GetStaticPaths } from "next"; +import { AbbyEventType } from "@tryabby/core"; const Projects: NextPageWithLayout = () => { const [isCreateTestModalOpen, setIsCreateTestModalOpen] = useState(false); @@ -58,9 +59,16 @@ const Projects: NextPageWithLayout = () => { />
- {data?.project?.tests.map((test) => ( -
- ))} + {data?.project?.tests.map((test) => { + return ( +
+ ); + })}
); diff --git a/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx b/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx index 03b8fcca..8c8bfc03 100644 --- a/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx +++ b/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx @@ -29,6 +29,7 @@ import { Legend, Filler, ChartOptions, + ChartData, } from "chart.js"; import colors from "tailwindcss/colors"; import { useMemo } from "react"; @@ -100,8 +101,8 @@ const TestDetailPage: NextPageWithLayout = () => { enabled: !!testId, } ); - - const { data: events } = trpc.events.getEventsByTestId.useQuery( + //TODO beide zusammen fassen + const { data } = trpc.events.getEventsByTestId.useQuery( { testId, interval, @@ -111,70 +112,50 @@ const TestDetailPage: NextPageWithLayout = () => { } ); - const eventsByVariant = useMemo(() => { - const eventsByVariant = groupBy(events, (e) => e.selectedVariant); - // make sure all variants are present - test?.options.map((option) => { - eventsByVariant[option.identifier] ??= []; - }); - return eventsByVariant; - }, [events, test?.options]); + const events = useMemo(() => data ?? [], [data]); const labels = getLabelsByInterval( interval, - minBy(events, "createdAt")?.createdAt! + minBy(events, "createdAt")?.startTime! ); - const formattedEvents = useMemo(() => { - return Object.entries(eventsByVariant).map(([variant, events], i) => { - const eventsByDate = groupBy(events, (e) => { - const date = dayjs(e.createdAt); - // round by 3 hours - const hour = Math.floor(date.hour() / 3) * 3; - - return date - .set("hour", hour) - .set("minute", 0) - .format(getFormattingByInterval(interval)); - }); - return { eventsByDate, variant }; - }); - }, [eventsByVariant, interval]); - - const viewEvents = useMemo( + const viewEvents: ChartData<"line", number[], unknown> = useMemo( () => ({ - labels, - datasets: formattedEvents.map(({ eventsByDate, variant }, i) => { - return { - data: labels.map( - (label) => - eventsByDate[label]?.filter((e) => e.type === AbbyEventType.PING) - ?.length ?? 0 - ), - ...getChartOptions(i, variant), - }; - }), + labelsAndDates: labels.labels, + datasets: events + .filter((event) => event.type === AbbyEventType.PING) + .map((event, i) => { + return { + data: labels.dates.map((date) => { + console.log("Label", date, event.startTime); + return events.find((e) => e.startTime === date)?.count ?? 0; + }), + ...getChartOptions(i, event.selectedVariant), + }; + }), }), - [formattedEvents, labels] + [events, labels] ); - const actEvents = useMemo( + console.log(viewEvents); + + const actEvents: ChartData<"line", number[], unknown> = useMemo( () => ({ - labels, - datasets: formattedEvents.map(({ eventsByDate, variant }, i) => { - return { - data: labels.map( - (label) => - eventsByDate[label]?.filter((e) => e.type === AbbyEventType.ACT) - ?.length ?? 0 - ), - ...getChartOptions(i, variant), - }; - }), + labels: labels.labels, + datasets: events + .filter((event) => event.type === AbbyEventType.ACT) + .map((event, i) => { + return { + data: labels.dates.map((date) => event.count), + ...getChartOptions(i, event.selectedVariant), + }; + }), }), - [formattedEvents, labels] + [events, labels] ); + if (!events) return ; + if (isTestLoading || isTestError) { return ; } diff --git a/apps/web/src/server/db/clickhouseClient.ts b/apps/web/src/server/db/clickhouseClient.ts new file mode 100644 index 00000000..5032801b --- /dev/null +++ b/apps/web/src/server/db/clickhouseClient.ts @@ -0,0 +1,14 @@ +import { NodeClickHouseClient } from "@clickhouse/client/dist/client"; +import { env } from "../../env/server.mjs"; +import { createClient } from "@clickhouse/client"; + +declare global { + // eslint-disable-next-line no-var + var clickhouseClient: NodeClickHouseClient | undefined; +} + +export const clickhouseClient = global.clickhouseClient || createClient(); + +if (env.NODE_ENV !== "production") { + global.clickhouseClient = clickhouseClient; +} diff --git a/apps/web/src/server/queue/AfterDataRequest.ts b/apps/web/src/server/queue/AfterDataRequest.ts index 7a91b874..0392a0ae 100644 --- a/apps/web/src/server/queue/AfterDataRequest.ts +++ b/apps/web/src/server/queue/AfterDataRequest.ts @@ -3,9 +3,9 @@ import { ApiVersion } from "@prisma/client"; import { trackPlanOverage } from "lib/logsnag"; import { RequestCache } from "server/services/RequestCache"; import { RequestService } from "server/services/RequestService"; -import { EventService } from "server/services/EventService"; import { afterDataRequestQueue, getQueueingRedisConnection } from "./queues"; import { env } from "env/server.mjs"; +import { ClickHouseEventService } from "server/services/ClickHouseEventService"; export type AfterRequestJobPayload = { functionDuration: number; @@ -17,7 +17,7 @@ const afterDataRequestWorker = new Worker( afterDataRequestQueue.name, async ({ data: { apiVersion, functionDuration, projectId } }) => { const { events, planLimits, plan, is80PercentOfLimit } = - await EventService.getEventsForCurrentPeriod(projectId); + await ClickHouseEventService.getEventsForCurrentPeriod(projectId); if (events > planLimits.eventsPerMonth) { // TODO: send email diff --git a/apps/web/src/server/queue/event.ts b/apps/web/src/server/queue/event.ts index 3215e6a2..ab825e85 100644 --- a/apps/web/src/server/queue/event.ts +++ b/apps/web/src/server/queue/event.ts @@ -2,11 +2,11 @@ import { Worker } from "bullmq"; import { trackPlanOverage } from "lib/logsnag"; import { RequestCache } from "server/services/RequestCache"; import { RequestService } from "server/services/RequestService"; -import { EventService } from "server/services/EventService"; import { eventQueue, getQueueingRedisConnection } from "./queues"; import { AbbyEvent, AbbyEventType } from "@tryabby/core"; import { env } from "env/server.mjs"; import { ApiRequestType } from "@prisma/client"; +import { ClickHouseEventService } from "server/services/ClickHouseEventService"; export type EventJobPayload = AbbyEvent & { functionDuration: number; @@ -24,15 +24,18 @@ const eventWorker = new Worker( switch (event.type) { case AbbyEventType.PING: case AbbyEventType.ACT: { - await EventService.createEvent(event); + await ClickHouseEventService.createEvent(event); + break; } default: { event.type satisfies never; } } + + //could be moved into a cron job and checked only once a hour const { events, planLimits, plan, is80PercentOfLimit } = - await EventService.getEventsForCurrentPeriod(event.projectId); + await ClickHouseEventService.getEventsForCurrentPeriod(event.projectId); if (events > planLimits.eventsPerMonth) { // TODO: send email diff --git a/apps/web/src/server/services/ClickHouseEventService.ts b/apps/web/src/server/services/ClickHouseEventService.ts new file mode 100644 index 00000000..a5052dc5 --- /dev/null +++ b/apps/web/src/server/services/ClickHouseEventService.ts @@ -0,0 +1,200 @@ +import { SpecialTimeInterval } from "lib/events"; +import { getLimitByPlan, PlanName, PLANS } from "server/common/plans"; +import { prisma } from "server/db/client"; +import { AbbyEvent, AbbyEventType, assertUnreachable } from "@tryabby/core"; +import { RequestCache } from "./RequestCache"; +import { clickhouseClient } from "server/db/clickhouseClient"; +import { z } from "zod"; +import dayjs from "dayjs"; + +const GroupedTestQueryResultSchema = z.object({ + selectedVariant: z.string(), + type: z.number(), + count: z.string(), +}); + +const GroupedTestQueryResultSchemaWithTimeSchema = z.intersection( + GroupedTestQueryResultSchema, + z.object({ startTime: z.string() }) +); + +const EventCurrentPeriodQueryResultSchema = z.object({ + apiRequestCount: z.string(), +}); + +type GroupedTestQueryResultSchemaWithTime = z.infer< + typeof GroupedTestQueryResultSchemaWithTimeSchema +>; +type GroupedTestQueryResult = z.infer; + +export abstract class ClickHouseEventService { + static async createEvent({ + projectId, + selectedVariant, + testName, + type, + }: AbbyEvent) { + const insertedEvent = await clickhouseClient.insert({ + table: "abby.Event", + format: "JSONEachRow", + values: [ + { + project_id: projectId, + testName: testName, + type, + selectedVariant: selectedVariant, + }, + ], + }); + + return insertedEvent; + } + + static async getEventsByProjectId(projectId: string): Promise< + { + id: string; + testId: string; + type: number; + selectedVariant: string; + createdAt: Date; + }[] + > { + const queryResult = await clickhouseClient.query({ + query: `SELECT * FROM abby.events WHERE projectId = '${projectId}'`, + }); + + return (await queryResult.json()).data as any; + } + + static async getGroupedEventsByTestId( + test: { + options: { + id: string; + identifier: string; + testId: string; + }[]; + } & { + id: string; + projectId: string; + createdAt: Date; + updatedAt: Date; + name: string; + } + ) { + const queryResult = await clickhouseClient.query({ + query: `select count(*) as count, type, selectedVariant from abby.Event + where testName ='${test.id}' + group by type, selectedVariant; + `, + }); + const parsedJson = (await queryResult.json()).data; + + return parsedJson.map((row) => { + const { count, selectedVariant, type } = + GroupedTestQueryResultSchema.parse(row); + return { + variant: selectedVariant, + type: type === 0 ? AbbyEventType.PING : AbbyEventType.ACT, + count: parseInt(count), + }; + }); + } + + //brauchen wir das? + static async getEventsByTestId( + testId: string, + timeInterval: SpecialTimeInterval + ) { + const computedBucketSize = this.computeBucketSize(timeInterval); + + try { + const result = await clickhouseClient.query({ + query: ` + SELECT + ${computedBucketSize} AS startTime, + Count(selectedVariant) AS count, + selectedVariant, + type + FROM abby.Event + WHERE testName = '${testId}' + GROUP BY startTime, selectedVariant, type + ORDER BY startTime ASC; +`, + }); + + const parsedJson = (await result.json()).data; + console.log(parsedJson); + const parsedRes = parsedJson.map((row) => { + const { count, selectedVariant, type, startTime } = + GroupedTestQueryResultSchemaWithTimeSchema.parse(row); + return { + startTime: new Date(startTime), + selectedVariant, + type: type === 0 ? AbbyEventType.PING : AbbyEventType.ACT, + count: parseInt(count), + }; + }); + + return parsedRes; + } catch (e) { + console.log("error", e); + } + } + + static async getEventsForCurrentPeriod(projectId: string) { + const [project, eventCount] = await Promise.all([ + prisma.project.findUnique({ + where: { id: projectId }, + select: { stripePriceId: true, currentPeriodEnd: true }, + }), + RequestCache.get(projectId), + ]); + + if (!project) throw new Error("Project not found"); + + const billingPeriodStartDate = dayjs(project.currentPeriodEnd) + .subtract(30, "days") + .format("YYYY-MM-DD"); + + const res = await clickhouseClient + .query({ + query: ` + SELECT + Count(*) as apiRequestCount + FROM abby.ApiRequest + WHERE projectId = '${projectId}' AND createdAt >= toDate('${billingPeriodStartDate}'); + +`, + }) + .then((res) => res.json()); + + const plan = Object.keys(PLANS).find( + (plan) => PLANS[plan as PlanName] === project.stripePriceId + ) as PlanName | undefined; + + const planLimits = getLimitByPlan(plan ?? null); + + return { + events: parseInt( + EventCurrentPeriodQueryResultSchema.parse(res.data[0]).apiRequestCount + ), + planLimits, + plan, + is80PercentOfLimit: planLimits.eventsPerMonth * 0.8 === eventCount, + }; + } + + static computeBucketSize(timeInterval: SpecialTimeInterval) { + switch (timeInterval) { + case SpecialTimeInterval.DAY: { + return "toStartOfHour(createdAt)"; + } + case SpecialTimeInterval.ALL_TIME: + case SpecialTimeInterval.Last30DAYS: { + return "toStartOfDay(createdAt)"; + } + default: + return assertUnreachable(timeInterval); + } + } +} diff --git a/apps/web/src/server/services/EventService.ts b/apps/web/src/server/services/EventService.ts deleted file mode 100644 index dba9ace5..00000000 --- a/apps/web/src/server/services/EventService.ts +++ /dev/null @@ -1,110 +0,0 @@ -import dayjs from "dayjs"; -import { - getMSFromSpecialTimeInterval, - isSpecialTimeInterval, - SpecialTimeInterval, -} from "lib/events"; -import ms from "ms"; -import { getLimitByPlan, PlanName, PLANS } from "server/common/plans"; -import { prisma } from "server/db/client"; -import { AbbyEvent } from "@tryabby/core"; -import { RequestCache } from "./RequestCache"; - -export abstract class EventService { - static async createEvent({ - projectId, - selectedVariant, - testName, - type, - }: AbbyEvent) { - return prisma.event.create({ - data: { - selectedVariant, - type, - test: { - connect: { - projectId_name: { - projectId, - name: testName, - }, - }, - }, - }, - }); - } - - static async getEventsByProjectId(projectId: string) { - return prisma.event.findMany({ - where: { - test: { - projectId, - }, - }, - }); - } - - static async getEventsByTestId(testId: string, timeInterval: string) { - const now = new Date().getTime(); - - if (isSpecialTimeInterval(timeInterval)) { - const specialIntervalInMs = getMSFromSpecialTimeInterval(timeInterval); - return prisma.event.findMany({ - where: { - testId, - ...(specialIntervalInMs !== Infinity && - timeInterval !== SpecialTimeInterval.DAY && { - createdAt: { - gte: new Date(now - getMSFromSpecialTimeInterval(timeInterval)), - }, - }), - // Special case for day, since we want to include the current day - ...(timeInterval === SpecialTimeInterval.DAY && { - createdAt: { - gte: dayjs().startOf("day").toDate(), - }, - }), - }, - }); - } - - const parsedInterval = ms(timeInterval) as number | undefined; - - if (parsedInterval === undefined) { - throw new Error("Invalid time interval"); - } - - return prisma.event.findMany({ - where: { - testId, - createdAt: { - gte: new Date(now - ms(timeInterval)), - }, - }, - }); - } - - static async getEventsForCurrentPeriod(projectId: string) { - const [project, eventCount] = await Promise.all([ - prisma.project.findUnique({ - where: { id: projectId }, - select: { stripePriceId: true }, - }), - RequestCache.get(projectId), - ]); - - if (!project) throw new Error("Project not found"); - - const plan = Object.keys(PLANS).find( - (plan) => PLANS[plan as PlanName] === project.stripePriceId - ) as PlanName | undefined; - - const planLimits = getLimitByPlan(plan ?? null); - - return { - events: eventCount, - planLimits, - plan, - is80PercentOfLimit: planLimits.eventsPerMonth * 0.8 === eventCount, - }; - } -} diff --git a/apps/web/src/server/services/RequestService.ts b/apps/web/src/server/services/RequestService.ts index 79a11f92..f16da152 100644 --- a/apps/web/src/server/services/RequestService.ts +++ b/apps/web/src/server/services/RequestService.ts @@ -1,12 +1,24 @@ import { ApiRequest } from "@prisma/client"; import { prisma } from "server/db/client"; +import { clickhouseClient } from "server/db/clickhouseClient"; export abstract class RequestService { static async storeRequest(request: Omit) { - await prisma.apiRequest.create({ - data: { - ...request, - }, - }); + const {} = await Promise.all([ + await prisma.apiRequest.create({ + data: { + ...request, + }, + }), + await clickhouseClient.insert({ + table: "abby.Event", + format: "JSONEachRow", + values: [ + { + project_id: request.projectId, + }, + ], + }), + ]); } } diff --git a/apps/web/src/server/trpc/router/events.ts b/apps/web/src/server/trpc/router/events.ts index 991c601d..cf09aca7 100644 --- a/apps/web/src/server/trpc/router/events.ts +++ b/apps/web/src/server/trpc/router/events.ts @@ -1,8 +1,9 @@ import { TRPCError } from "@trpc/server"; -import { EventService } from "server/services/EventService"; import { ProjectService } from "server/services/ProjectService"; import { z } from "zod"; import { protectedProcedure, router } from "../trpc"; +import { ClickHouseEventService } from "server/services/ClickHouseEventService"; +import { SpecialTimeInterval } from "lib/events"; export const eventRouter = router({ getEvents: protectedProcedure @@ -17,13 +18,13 @@ export const eventRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - return EventService.getEventsByProjectId(input.projectId); + return ClickHouseEventService.getEventsByProjectId(input.projectId); }), getEventsByTestId: protectedProcedure .input( z.object({ testId: z.string(), - interval: z.string(), + interval: z.nativeEnum(SpecialTimeInterval), }) ) .query(async ({ ctx, input }) => { @@ -44,11 +45,11 @@ export const eventRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const tests = await EventService.getEventsByTestId( + const clickhouseEvents = await ClickHouseEventService.getEventsByTestId( input.testId, input.interval ); - return tests; + return clickhouseEvents; }), }); diff --git a/apps/web/src/server/trpc/router/project.ts b/apps/web/src/server/trpc/router/project.ts index 408c73f7..3b4787a1 100644 --- a/apps/web/src/server/trpc/router/project.ts +++ b/apps/web/src/server/trpc/router/project.ts @@ -2,10 +2,9 @@ import { Option, ROLE } from "@prisma/client"; import { TRPCError } from "@trpc/server"; import { PLANS, planNameSchema } from "server/common/plans"; import { stripe } from "server/common/stripe"; -import { EventService } from "server/services/EventService"; import { ProjectService } from "server/services/ProjectService"; import { generateCodeSnippets } from "utils/snippets"; -import { z } from "zod"; +import { ParseStatus, z } from "zod"; export type ClientOption = Omit & { chance: number; @@ -13,6 +12,8 @@ export type ClientOption = Omit & { import { updateProjectsOnSession } from "utils/updateSession"; import { protectedProcedure, router } from "../trpc"; +import { AbbyEventType } from "@tryabby/core"; +import { ClickHouseEventService } from "server/services/ClickHouseEventService"; export const projectRouter = router({ getProjectData: protectedProcedure @@ -29,7 +30,7 @@ export const projectRouter = router({ }, include: { tests: { - include: { options: true, events: true }, + include: { options: true }, }, environments: true, featureFlags: true, @@ -41,19 +42,48 @@ export const projectRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED" }); } const { events: eventsThisPeriod } = - await EventService.getEventsForCurrentPeriod(project.id); + await ClickHouseEventService.getEventsForCurrentPeriod(project.id); + + const tests = await Promise.all( + project.tests.map(async (test) => { + const visitData = await Promise.all( + test.options.map(async (option) => { + const clickhouseResult = + await ClickHouseEventService.getGroupedEventsByTestId(test); + + return { + variantName: option.identifier, + ...option, + chance: option.chance.toNumber(), + visitedEventCount: + clickhouseResult.find( + (res) => + res.variant === option.identifier && + res.type === AbbyEventType.PING + )?.count ?? 0, + actEventCount: + clickhouseResult.find( + (res) => + res.variant === option.identifier && + res.type === AbbyEventType.ACT + )?.count ?? 0, + }; + }) + ); + + return { + id: test.id, + name: test.name, + visitData, + }; + }) + ); return { project: { ...project, eventsThisPeriod, - tests: project.tests.map((test) => ({ - ...test, - options: test.options.map((option) => ({ - ...option, - chance: option.chance.toNumber(), - })), - })), + tests, }, }; }), diff --git a/apps/web/src/server/trpc/router/tests.ts b/apps/web/src/server/trpc/router/tests.ts index 5d1b5606..d4b94d5c 100644 --- a/apps/web/src/server/trpc/router/tests.ts +++ b/apps/web/src/server/trpc/router/tests.ts @@ -1,11 +1,7 @@ import { TRPCError } from "@trpc/server"; -import { ProjectService } from "server/services/ProjectService"; import { z } from "zod"; import { protectedProcedure, router } from "../trpc"; import { prisma } from "server/db/client"; -import { getLimitByPlan } from "server/common/plans"; -import { getProjectPaidPlan } from "lib/stripe"; -import { EventService } from "server/services/EventService"; import { TestService } from "server/services/TestService"; import { ConfigCache } from "server/common/config-cache"; diff --git a/docker-compose.yaml b/docker-compose.yaml index 071a43cc..178dd7ac 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,13 @@ services: ports: - "1025:1025" - "8025:8025" + clickhouse: + image: clickhouse/clickhouse-server + restart: always + ports: + - "8123:8123" + - "9000:9000" + - "9009:9009" # Names our volume volumes: my-db: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6773e962..f840862e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,25 @@ importers: specifier: ~4.9.4 version: 4.9.5 + apps/bullmq-receiver: + dependencies: + bullmq: + specifier: ^5.8.2 + version: 5.8.2 + ioredis: + specifier: ^5.4.1 + version: 5.4.1 + devDependencies: + '@types/node': + specifier: ^14.14.31 + version: 14.18.63 + ts-node-dev: + specifier: ^1.1.6 + version: 1.1.8(typescript@4.9.5) + typescript: + specifier: ^4.2.3 + version: 4.9.5 + apps/cdn: dependencies: hono: @@ -188,6 +207,9 @@ importers: apps/web: dependencies: + '@clickhouse/client': + specifier: ^1.0.1 + version: 1.0.1 '@code-hike/mdx': specifier: 0.9.0 version: 0.9.0(react@18.2.0) @@ -5763,6 +5785,17 @@ packages: prettier: 2.8.8 dev: true + /@clickhouse/client-common@1.0.1: + resolution: {integrity: sha512-3L6e0foP6VOktScoi6XWMjJyOpKCWgLUYgPVxP2c7gm6Kotq+iRmmmXtXTSg7B7uozcLZycTtPfIw2d80SYsYw==} + dev: false + + /@clickhouse/client@1.0.1: + resolution: {integrity: sha512-fluUNnE2R7COJ6rn6DorYfi4D+AQn3t2qeBtEq37bQV3pD4EbKrBfKAwJ13e1lmMWdQ2B9bJUTMqGsRIDdWhJw==} + engines: {node: '>=16'} + dependencies: + '@clickhouse/client-common': 1.0.1 + dev: false + /@cloudflare/kv-asset-handler@0.2.0: resolution: {integrity: sha512-MVbXLbTcAotOPUj0pAMhVtJ+3/kFkwJqc5qNOleOZTv6QkZZABDMS21dSrSlVswEHwrpWC03e4fWytjqKvuE2A==} dependencies: @@ -12181,6 +12214,10 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true + /@types/node@14.18.63: + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + dev: true + /@types/node@16.18.35: resolution: {integrity: sha512-yqU2Rf94HFZqgHf6Tuyc/IqVD0l3U91KjvypSr1GtJKyrnl6L/kfnxVqN4QOwcF5Zx9tO/HKK+fozGr5AtqA+g==} dev: true @@ -12339,6 +12376,14 @@ packages: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true + /@types/strip-bom@3.0.0: + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + dev: true + + /@types/strip-json-comments@0.0.30: + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + dev: true + /@types/stripe-v3@3.1.28: resolution: {integrity: sha512-5poJyz1QFXpi1hE2bAWy7gFMdj5Fgofm94DNCaTK9V2LeWPdhCQIaP/6qUagZwgCcTURXzih1J7f3sjXH1cOsw==} dev: false @@ -13090,7 +13135,6 @@ packages: resolution: {integrity: sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==} engines: {node: '>=0.4.0'} hasBin: true - dev: false /acorn@8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} @@ -15797,6 +15841,12 @@ packages: stream-shift: 1.0.1 dev: true + /dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + dependencies: + xtend: 4.0.2 + dev: true + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -24580,7 +24630,7 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} dependencies: - resolve: 1.22.2 + resolve: 1.22.8 dev: true /redent@3.0.0: @@ -24948,6 +24998,7 @@ packages: /rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -24955,6 +25006,7 @@ packages: /rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -26154,6 +26206,11 @@ packages: min-indent: 1.0.1 dev: true + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -26824,6 +26881,30 @@ packages: /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + /ts-node-dev@1.1.8(typescript@4.9.5): + resolution: {integrity: sha512-Q/m3vEwzYwLZKmV6/0VlFxcZzVV/xcgOt+Tx/VjaaRHyiBcFlV0541yrT09QjzzCxlDZ34OzKjrFAynlmtflEg==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + chokidar: 3.5.3 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.8 + rimraf: 2.7.1 + source-map-support: 0.5.21 + tree-kill: 1.2.2 + ts-node: 9.1.1(typescript@4.9.5) + tsconfig: 7.0.0 + typescript: 4.9.5 + dev: true + /ts-node@10.9.1(@types/node@18.16.17)(typescript@4.9.5): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true @@ -26874,8 +26955,8 @@ packages: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 20.3.1 - acorn: 8.8.2 - acorn-walk: 8.2.0 + acorn: 8.12.0 + acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 @@ -26884,6 +26965,22 @@ packages: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + /ts-node@9.1.1(typescript@4.9.5): + resolution: {integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==} + engines: {node: '>=10.0.0'} + hasBin: true + peerDependencies: + typescript: '>=2.7' + dependencies: + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + source-map-support: 0.5.21 + typescript: 4.9.5 + yn: 3.1.1 + dev: true + /ts-pattern@4.3.0: resolution: {integrity: sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==} dev: false @@ -26930,6 +27027,15 @@ packages: strip-bom: 3.0.0 dev: true + /tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + dev: true + /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}