diff --git a/.github/workflows/send-total-results-digest.yml b/.github/workflows/send-total-results-digest.yml new file mode 100644 index 0000000000..d015253763 --- /dev/null +++ b/.github/workflows/send-total-results-digest.yml @@ -0,0 +1,21 @@ +name: Send total results daily digest + +on: + schedule: + - cron: '0 5 * * *' + +jobs: + send: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/scripts + env: + DATABASE_URL: '${{ secrets.DATABASE_URL }}' + TELEMETRY_WEBHOOK_URL: '${{ secrets.TELEMETRY_WEBHOOK_URL }}' + TELEMETRY_WEBHOOK_BEARER_TOKEN: '${{ secrets.TELEMETRY_WEBHOOK_BEARER_TOKEN }}' + steps: + - uses: actions/checkout@v2 + - uses: pnpm/action-setup@v2.2.2 + - run: pnpm i --frozen-lockfile + - run: pnpm turbo run telemetry:sendTotalResultsDigest diff --git a/apps/builder/package.json b/apps/builder/package.json index fa06ae2c9d..04dfe54fc0 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -78,6 +78,7 @@ "nodemailer": "6.9.1", "nprogress": "0.2.0", "papaparse": "5.3.2", + "posthog-node": "^2.5.4", "prettier": "2.8.4", "qs": "6.11.0", "react": "18.2.0", diff --git a/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts b/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts index 0aa3357e3f..7da0db1aec 100644 --- a/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts +++ b/apps/builder/src/features/billing/api/procedures/createCheckoutSession.ts @@ -107,7 +107,13 @@ export const createCheckoutSession = authenticatedProcedure name: 'never', }, mode: 'subscription', - metadata: { workspaceId, plan, additionalChats, additionalStorage }, + metadata: { + workspaceId, + plan, + additionalChats, + additionalStorage, + userId: user.id, + }, currency, billing_address_collection: 'required', automatic_tax: { enabled: true }, diff --git a/apps/builder/src/features/billing/api/procedures/updateSubscription.ts b/apps/builder/src/features/billing/api/procedures/updateSubscription.ts index ddaec42d51..35a573e235 100644 --- a/apps/builder/src/features/billing/api/procedures/updateSubscription.ts +++ b/apps/builder/src/features/billing/api/procedures/updateSubscription.ts @@ -1,3 +1,4 @@ +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' import prisma from '@/lib/prisma' import { authenticatedProcedure } from '@/utils/server/trpc' import { TRPCError } from '@trpc/server' @@ -141,6 +142,19 @@ export const updateSubscription = authenticatedProcedure }, }) + await sendTelemetryEvents([ + { + name: 'Subscription updated', + workspaceId, + userId: user.id, + data: { + plan, + additionalChatsIndex: additionalChats, + additionalStorageIndex: additionalStorage, + }, + }, + ]) + return { workspace: updatedWorkspace } } ) diff --git a/apps/builder/src/features/telemetry/api/processTelemetryEvent.ts b/apps/builder/src/features/telemetry/api/processTelemetryEvent.ts new file mode 100644 index 0000000000..23e70273d3 --- /dev/null +++ b/apps/builder/src/features/telemetry/api/processTelemetryEvent.ts @@ -0,0 +1,93 @@ +import { eventSchema } from 'models/features/telemetry' +import { z } from 'zod' +import { PostHog } from 'posthog-node' +import { TRPCError } from '@trpc/server' +import got from 'got' +import { authenticatedProcedure } from '@/utils/server/trpc' + +// Only used for the cloud version of Typebot. It's the way it processes telemetry events and inject it to thrid-party services. +export const processTelemetryEvent = authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/t/process', + description: + "Only used for the cloud version of Typebot. It's the way it processes telemetry events and inject it to thrid-party services.", + }, + }) + .input( + z.object({ + events: z.array(eventSchema), + }) + ) + .output( + z.object({ + message: z.literal('Events injected'), + }) + ) + .query(async ({ input: { events }, ctx: { user } }) => { + if (user.email !== process.env.ADMIN_EMAIL) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Only app admin can process telemetry events', + }) + if (!process.env.POSTHOG_API_KEY) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Server does not have POSTHOG_API_KEY configured', + }) + const client = new PostHog(process.env.POSTHOG_API_KEY, { + host: 'https://eu.posthog.com', + }) + + events.forEach(async (event) => { + if (event.name === 'User created') { + client.identify({ + distinctId: event.userId, + properties: event.data, + }) + } + if ( + event.name === 'Workspace created' || + event.name === 'Subscription updated' + ) + client.groupIdentify({ + groupType: 'workspace', + groupKey: event.workspaceId, + properties: event.data, + }) + if ( + event.name === 'Typebot created' || + event.name === 'Typebot published' + ) + client.groupIdentify({ + groupType: 'typebot', + groupKey: event.typebotId, + properties: { name: event.data.name }, + }) + if ( + event.name === 'User created' && + process.env.USER_CREATED_WEBHOOK_URL + ) { + await got.post(process.env.USER_CREATED_WEBHOOK_URL, { + json: { + email: event.data.email, + name: event.data.name ? event.data.name.split(' ')[0] : undefined, + }, + }) + } + const groups: { workspace?: string; typebot?: string } = {} + if ('workspaceId' in event) groups['workspace'] = event.workspaceId + if ('typebotId' in event) groups['typebot'] = event.typebotId + client.capture({ + distinctId: event.userId, + event: event.name, + properties: event.data, + groups, + }) + }) + + await client.shutdownAsync() + + return { message: 'Events injected' } + }) diff --git a/apps/builder/src/features/workspace/api/procedures/createWorkspaceProcedure.ts b/apps/builder/src/features/workspace/api/procedures/createWorkspaceProcedure.ts index 6174ac8775..e996a5134b 100644 --- a/apps/builder/src/features/workspace/api/procedures/createWorkspaceProcedure.ts +++ b/apps/builder/src/features/workspace/api/procedures/createWorkspaceProcedure.ts @@ -1,3 +1,4 @@ +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' import prisma from '@/lib/prisma' import { authenticatedProcedure } from '@/utils/server/trpc' import { TRPCError } from '@trpc/server' @@ -49,6 +50,18 @@ export const createWorkspaceProcedure = authenticatedProcedure }, })) as Workspace + await sendTelemetryEvents([ + { + name: 'Workspace created', + workspaceId: newWorkspace.id, + userId: user.id, + data: { + name, + plan, + }, + }, + ]) + return { workspace: newWorkspace, } diff --git a/apps/builder/src/pages/api/auth/adapter.ts b/apps/builder/src/pages/api/auth/adapter.ts index 87019b5c97..bef0c6e72c 100644 --- a/apps/builder/src/pages/api/auth/adapter.ts +++ b/apps/builder/src/pages/api/auth/adapter.ts @@ -2,7 +2,6 @@ import { PrismaClient, Prisma, WorkspaceRole, Session } from 'db' import type { Adapter, AdapterUser } from 'next-auth/adapters' import { createId } from '@paralleldrive/cuid2' -import { got } from 'got' import { generateId } from 'utils' import { parseWorkspaceDefaultPlan } from '@/features/workspace' import { @@ -10,6 +9,8 @@ import { convertInvitationsToCollaborations, joinWorkspaces, } from '@/features/auth/api' +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' +import { TelemetryEvent } from 'models/features/telemetry' export function CustomAdapter(p: PrismaClient): Adapter { return { @@ -28,6 +29,11 @@ export function CustomAdapter(p: PrismaClient): Adapter { workspaceInvitations.length === 0 ) throw Error('New users are forbidden') + + const newWorkspaceData = { + name: data.name ? `${data.name}'s workspace` : `My workspace`, + plan: parseWorkspaceDefaultPlan(data.email), + } const createdUser = await p.user.create({ data: { ...data, @@ -42,25 +48,35 @@ export function CustomAdapter(p: PrismaClient): Adapter { create: { role: WorkspaceRole.ADMIN, workspace: { - create: { - name: data.name - ? `${data.name}'s workspace` - : `My workspace`, - plan: parseWorkspaceDefaultPlan(data.email), - }, + create: newWorkspaceData, }, }, }, onboardingCategories: [], }, + include: { + workspaces: { select: { workspaceId: true } }, + }, }) - if (process.env.USER_CREATED_WEBHOOK_URL) - await got.post(process.env.USER_CREATED_WEBHOOK_URL, { - json: { - email: data.email, - name: data.name ? (data.name as string).split(' ')[0] : undefined, - }, + const newWorkspaceId = createdUser.workspaces.pop()?.workspaceId + const events: TelemetryEvent[] = [] + if (newWorkspaceId) { + events.push({ + name: 'Workspace created', + workspaceId: newWorkspaceId, + userId: createdUser.id, + data: newWorkspaceData, }) + } + events.push({ + name: 'User created', + userId: createdUser.id, + data: { + email: data.email, + name: data.name ? (data.name as string).split(' ')[0] : undefined, + }, + }) + await sendTelemetryEvents(events) if (invitations.length > 0) await convertInvitationsToCollaborations(p, user, invitations) if (workspaceInvitations.length > 0) diff --git a/apps/builder/src/pages/api/publicTypebots.ts b/apps/builder/src/pages/api/publicTypebots.ts index 7b5c1870e8..ebccc22d43 100644 --- a/apps/builder/src/pages/api/publicTypebots.ts +++ b/apps/builder/src/pages/api/publicTypebots.ts @@ -4,6 +4,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { canPublishFileInput } from '@/utils/api/dbRules' import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api' import { getAuthenticatedUser } from '@/features/auth/api' +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) @@ -23,10 +24,25 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { !(await canPublishFileInput({ userId: user.id, workspaceId, res })) ) return - const typebot = await prisma.publicTypebot.create({ + const publicTypebot = await prisma.publicTypebot.create({ data: { ...data }, + include: { + typebot: { select: { name: true } }, + }, }) - return res.send(typebot) + await sendTelemetryEvents([ + { + name: 'Typebot published', + userId: user.id, + workspaceId, + typebotId: publicTypebot.typebotId, + data: { + isFirstPublish: true, + name: publicTypebot.typebot.name, + }, + }, + ]) + return res.send(publicTypebot) } return methodNotAllowed(res) } catch (err) { diff --git a/apps/builder/src/pages/api/publicTypebots/[id].ts b/apps/builder/src/pages/api/publicTypebots/[id].ts index 3eba2414a3..9276f5b5a0 100644 --- a/apps/builder/src/pages/api/publicTypebots/[id].ts +++ b/apps/builder/src/pages/api/publicTypebots/[id].ts @@ -4,6 +4,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { canPublishFileInput, canWriteTypebots } from '@/utils/api/dbRules' import { getAuthenticatedUser } from '@/features/auth/api' import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api' +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) @@ -25,11 +26,25 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { !(await canPublishFileInput({ userId: user.id, workspaceId, res })) ) return - const typebots = await prisma.publicTypebot.update({ + const publicTypebot = await prisma.publicTypebot.update({ where: { id }, data, + include: { + typebot: { select: { name: true } }, + }, }) - return res.send({ typebots }) + await sendTelemetryEvents([ + { + name: 'Typebot published', + userId: user.id, + workspaceId, + typebotId: publicTypebot.typebotId, + data: { + name: publicTypebot.typebot.name, + }, + }, + ]) + return res.send({ typebot: publicTypebot }) } if (req.method === 'DELETE') { const publishedTypebotId = req.query.id as string diff --git a/apps/builder/src/pages/api/stripe/custom-plan-checkout.ts b/apps/builder/src/pages/api/stripe/custom-plan-checkout.ts index b8f91b0d26..f31bae0799 100644 --- a/apps/builder/src/pages/api/stripe/custom-plan-checkout.ts +++ b/apps/builder/src/pages/api/stripe/custom-plan-checkout.ts @@ -36,6 +36,7 @@ const createCheckoutSession = async (userId: string) => { mode: 'subscription', metadata: { claimableCustomPlanId: claimableCustomPlan.id, + userId, }, currency: claimableCustomPlan.currency, automatic_tax: { enabled: true }, diff --git a/apps/builder/src/pages/api/stripe/webhook.ts b/apps/builder/src/pages/api/stripe/webhook.ts index c0806cfe6d..a08f6d4db0 100644 --- a/apps/builder/src/pages/api/stripe/webhook.ts +++ b/apps/builder/src/pages/api/stripe/webhook.ts @@ -6,6 +6,7 @@ import { buffer } from 'micro' import prisma from '@/lib/prisma' import { Plan } from 'db' import { RequestHandler } from 'next/dist/server/next' +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing') @@ -46,11 +47,17 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { additionalChats: string additionalStorage: string workspaceId: string + userId: string } - | { claimableCustomPlanId: string } + | { claimableCustomPlanId: string; userId: string } if ('plan' in metadata) { - const { workspaceId, plan, additionalChats, additionalStorage } = - metadata + const { + workspaceId, + plan, + additionalChats, + additionalStorage, + userId, + } = metadata if (!workspaceId || !plan || !additionalChats || !additionalStorage) return res .status(500) @@ -58,7 +65,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { await prisma.workspace.update({ where: { id: workspaceId }, data: { - plan: plan, + plan, stripeId: session.customer as string, additionalChatsIndex: parseInt(additionalChats), additionalStorageIndex: parseInt(additionalStorage), @@ -68,8 +75,21 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { storageLimitSecondEmailSentAt: null, }, }) + + await sendTelemetryEvents([ + { + name: 'Subscription updated', + workspaceId, + userId, + data: { + plan, + additionalChatsIndex: parseInt(additionalChats), + additionalStorageIndex: parseInt(additionalStorage), + }, + }, + ]) } else { - const { claimableCustomPlanId } = metadata + const { claimableCustomPlanId, userId } = metadata if (!claimableCustomPlanId) return res .status(500) @@ -90,6 +110,19 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { customSeatsLimit: seatsLimit, }, }) + + await sendTelemetryEvents([ + { + name: 'Subscription updated', + workspaceId, + userId, + data: { + plan: Plan.CUSTOM, + additionalChatsIndex: 0, + additionalStorageIndex: 0, + }, + }, + ]) } return res.status(200).send({ message: 'workspace upgraded in DB' }) diff --git a/apps/builder/src/pages/api/typebots.ts b/apps/builder/src/pages/api/typebots.ts index 109d06abd8..efff54977f 100644 --- a/apps/builder/src/pages/api/typebots.ts +++ b/apps/builder/src/pages/api/typebots.ts @@ -11,6 +11,7 @@ import { getAuthenticatedUser } from '@/features/auth/api' import { parseNewTypebot } from '@/features/dashboard' import { NewTypebotProps } from '@/features/dashboard/api/parseNewTypebot' import { omit } from 'utils' +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) @@ -65,6 +66,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { ...data, }), }) + await sendTelemetryEvents([ + { + name: 'Typebot created', + userId: user.id, + workspaceId: typebot.workspaceId, + typebotId: typebot.id, + data: { + name: typebot.name, + }, + }, + ]) return res.send(typebot) } return methodNotAllowed(res) diff --git a/apps/builder/src/utils/server/routers/v1/trpcRouter.ts b/apps/builder/src/utils/server/routers/v1/trpcRouter.ts index e2cf8cc3cf..d3e29ef50f 100644 --- a/apps/builder/src/utils/server/routers/v1/trpcRouter.ts +++ b/apps/builder/src/utils/server/routers/v1/trpcRouter.ts @@ -3,12 +3,14 @@ import { webhookRouter } from '@/features/blocks/integrations/webhook/api' import { credentialsRouter } from '@/features/credentials/api/router' import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure' import { resultsRouter } from '@/features/results/api' +import { processTelemetryEvent } from '@/features/telemetry/api/processTelemetryEvent' import { typebotRouter } from '@/features/typebot/api' import { workspaceRouter } from '@/features/workspace/api' import { router } from '../../trpc' export const trpcRouter = router({ getAppVersionProcedure, + processTelemetryEvent, workspace: workspaceRouter, typebot: typebotRouter, webhook: webhookRouter, diff --git a/apps/docs/docs/self-hosting/configuration/builder.mdx b/apps/docs/docs/self-hosting/configuration/builder.mdx index 607efae0ff..f513906ea7 100644 --- a/apps/docs/docs/self-hosting/configuration/builder.mdx +++ b/apps/docs/docs/self-hosting/configuration/builder.mdx @@ -235,12 +235,23 @@ These can also be added to the `viewer` environment

-

Internal Webhooks

+

Telemetry

-| Parameter | Default | Description | -| ------------------------ | ------- | --------------------------------------------------------------------------------------------- | -| USER_CREATED_WEBHOOK_URL | | Webhook URL called whenever a new user is created (used for importing a new SendGrid contact) | +| Parameter | Default | Description | +| ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------- | +| TELEMETRY_WEBHOOK_URL | | Webhook URL called whenever a new telemetry event is captured. See this file that lists all the possible events | +| TELEMETRY_WEBHOOK_BEARER_TOKEN | | Bearer token to add if the request needs to be authenticated | + +

+ +

PostHog

+

+ +| Parameter | Default | Description | +| ---------------- | ------- | ---------------- | +| POSTHOG_API_KEY | | PostHog API Key | +| POSTHOG_API_HOST | | PostHog API Host |

diff --git a/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts b/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts index 50a21f7441..bb1adf5e88 100644 --- a/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts +++ b/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts @@ -164,11 +164,10 @@ const saveAnswer = content: reply, }) - if (reply.includes('http') && block.type === InputBlockType.FILE) { - answer.storageUsed = await computeStorageUsed(reply) - } - - if (resultId) + if (resultId) { + if (reply.includes('http') && block.type === InputBlockType.FILE) { + answer.storageUsed = await computeStorageUsed(reply) + } await prisma.answer.upsert({ where: { resultId_blockId_groupId: { @@ -180,6 +179,8 @@ const saveAnswer = create: answer as Prisma.AnswerUncheckedCreateInput, update: answer, }) + } + return newSessionState } diff --git a/packages/models/features/telemetry.ts b/packages/models/features/telemetry.ts new file mode 100644 index 0000000000..9e6bcbb332 --- /dev/null +++ b/packages/models/features/telemetry.ts @@ -0,0 +1,89 @@ +import { Plan } from 'db' +import { z } from 'zod' + +const userEvent = z.object({ + userId: z.string(), +}) + +const workspaceEvent = userEvent.merge( + z.object({ + workspaceId: z.string(), + }) +) + +const typebotEvent = workspaceEvent.merge( + z.object({ + typebotId: z.string(), + }) +) + +const workspaceCreatedEventSchema = workspaceEvent.merge( + z.object({ + name: z.literal('Workspace created'), + data: z.object({ + name: z.string().optional(), + plan: z.nativeEnum(Plan), + }), + }) +) + +const userCreatedEventSchema = userEvent.merge( + z.object({ + name: z.literal('User created'), + data: z.object({ + email: z.string(), + name: z.string().optional(), + }), + }) +) + +const typebotCreatedEventSchema = typebotEvent.merge( + z.object({ + name: z.literal('Typebot created'), + data: z.object({ + name: z.string(), + template: z.string().optional(), + }), + }) +) + +const publishedTypebotEventSchema = typebotEvent.merge( + z.object({ + name: z.literal('Typebot published'), + data: z.object({ + name: z.string(), + isFirstPublish: z.literal(true).optional(), + }), + }) +) + +const subscriptionUpdatedEventSchema = workspaceEvent.merge( + z.object({ + name: z.literal('Subscription updated'), + data: z.object({ + plan: z.nativeEnum(Plan), + additionalChatsIndex: z.number(), + additionalStorageIndex: z.number(), + }), + }) +) + +const newResultsCollectedEventSchema = typebotEvent.merge( + z.object({ + name: z.literal('New results collected'), + data: z.object({ + total: z.number(), + }), + }) +) + +export const eventSchema = z.discriminatedUnion('name', [ + workspaceCreatedEventSchema, + userCreatedEventSchema, + typebotCreatedEventSchema, + publishedTypebotEventSchema, + subscriptionUpdatedEventSchema, + newResultsCollectedEventSchema, +]) + +export type TelemetryEvent = z.infer diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 0725c88c16..9f7680e424 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -11,7 +11,8 @@ "db:restore": "tsx restoreDatabase.ts", "db:setCustomPlan": "tsx setCustomPlan.ts", "db:bulkUpdate": "tsx bulkUpdate.ts", - "db:fixTypebots": "tsx fixTypebots.ts" + "db:fixTypebots": "tsx fixTypebots.ts", + "telemetry:sendTotalResultsDigest": "tsx sendTotalResultsDigest.ts" }, "devDependencies": { "@types/node": "18.14.0", diff --git a/packages/scripts/sendTotalResultsDigest.ts b/packages/scripts/sendTotalResultsDigest.ts new file mode 100644 index 0000000000..e83d84fe6c --- /dev/null +++ b/packages/scripts/sendTotalResultsDigest.ts @@ -0,0 +1,85 @@ +import { PrismaClient, WorkspaceRole } from 'db' +import { isDefined } from 'utils' +import { promptAndSetEnvironment } from './utils' +import { TelemetryEvent } from 'models/features/telemetry' +import { sendTelemetryEvents } from 'utils/telemetry/sendTelemetryEvent' + +const prisma = new PrismaClient() + +export const sendTotalResultsDigest = async () => { + await promptAndSetEnvironment('production') + + console.log("Generating total results yesterday's digest...") + const todayMidnight = new Date() + todayMidnight.setHours(0, 0, 0, 0) + const yesterday = new Date(todayMidnight) + yesterday.setDate(yesterday.getDate() - 1) + + const results = await prisma.result.groupBy({ + by: ['typebotId'], + _count: { + _all: true, + }, + where: { + hasStarted: true, + createdAt: { + gte: yesterday, + lt: todayMidnight, + }, + }, + }) + + console.log( + `Found ${results.reduce( + (total, result) => total + result._count._all, + 0 + )} results collected yesterday.` + ) + + const workspaces = await prisma.workspace.findMany({ + where: { + typebots: { + some: { + id: { in: results.map((result) => result.typebotId) }, + }, + }, + }, + select: { + id: true, + typebots: { select: { id: true } }, + members: { select: { userId: true, role: true } }, + }, + }) + + const resultsWithWorkspaces = results + .flatMap((result) => { + const workspace = workspaces.find((workspace) => + workspace.typebots.some((typebot) => typebot.id === result.typebotId) + ) + if (!workspace) return + return workspace.members + .filter((member) => member.role !== WorkspaceRole.GUEST) + .map((member) => ({ + userId: member.userId, + workspaceId: workspace.id, + typebotId: result.typebotId, + totalResultsYesterday: result._count._all, + })) + }) + .filter(isDefined) + + const events = resultsWithWorkspaces.map((result) => ({ + name: 'New results collected', + userId: result.userId, + workspaceId: result.workspaceId, + typebotId: result.typebotId, + data: { + total: result.totalResultsYesterday, + }, + })) satisfies TelemetryEvent[] + + await sendTelemetryEvents(events) + console.log(`Sent ${events.length} events.`) +} + +sendTotalResultsDigest().then() diff --git a/packages/utils/package.json b/packages/utils/package.json index db5b0b2b27..02307f2dbe 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -6,10 +6,10 @@ "main": "./index.ts", "types": "./index.ts", "devDependencies": { + "@paralleldrive/cuid2": "2.2.0", "@playwright/test": "1.31.1", "@types/nodemailer": "6.4.7", "aws-sdk": "2.1321.0", - "@paralleldrive/cuid2": "2.2.0", "db": "workspace:*", "dotenv": "16.0.3", "models": "workspace:*", @@ -22,5 +22,8 @@ "aws-sdk": "2.1152.0", "next": "13.0.0", "nodemailer": "6.7.8" + }, + "dependencies": { + "got": "12.5.3" } } diff --git a/packages/utils/telemetry/sendTelemetryEvent.ts b/packages/utils/telemetry/sendTelemetryEvent.ts new file mode 100644 index 0000000000..3759760297 --- /dev/null +++ b/packages/utils/telemetry/sendTelemetryEvent.ts @@ -0,0 +1,29 @@ +import got from 'got' +import { TelemetryEvent } from 'models/features/telemetry' +import { isEmpty, isNotEmpty } from '../utils' + +export const sendTelemetryEvents = async (events: TelemetryEvent[]) => { + if (isEmpty(process.env.TELEMETRY_WEBHOOK_URL)) + return { message: 'Telemetry not enabled' } + + try { + await got.post(process.env.TELEMETRY_WEBHOOK_URL, { + json: { events }, + headers: { + authorization: isNotEmpty(process.env.TELEMETRY_WEBHOOK_BEARER_TOKEN) + ? `Bearer ${process.env.TELEMETRY_WEBHOOK_BEARER_TOKEN}` + : undefined, + }, + }) + } catch (err) { + console.error('Failed to send event', err) + return { + message: 'Failed to send event', + error: err instanceof Error ? err.message : 'Unknown error', + } + } + + return { + message: 'Event sent', + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10300f22a9..2eb1d653f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,7 @@ importers: nodemailer: 6.9.1 nprogress: 0.2.0 papaparse: 5.3.2 + posthog-node: ^2.5.4 prettier: 2.8.4 qs: 6.11.0 react: 18.2.0 @@ -189,6 +190,7 @@ importers: nodemailer: 6.9.1 nprogress: 0.2.0 papaparse: 5.3.2 + posthog-node: 2.5.4 prettier: 2.8.4 qs: 6.11.0 react: 18.2.0 @@ -753,11 +755,14 @@ importers: aws-sdk: 2.1321.0 db: workspace:* dotenv: 16.0.3 + got: 12.5.3 models: workspace:* next: 13.1.6 nodemailer: 6.9.1 tsconfig: workspace:* typescript: 4.9.5 + dependencies: + got: 12.5.3 devDependencies: '@paralleldrive/cuid2': 2.2.0 '@playwright/test': 1.31.1 @@ -8469,6 +8474,15 @@ packages: - debug dev: false + /axios/0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + dependencies: + follow-redirects: 1.15.2 + form-data: 4.0.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query/3.1.1: resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} dependencies: @@ -16535,6 +16549,15 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /posthog-node/2.5.4: + resolution: {integrity: sha512-CdywlVh0CZU05/3MrBc0qY/zsLdU2X9XSz/yL1qMRhbyZhD8lrnuGlI69G2cpzZtli6S/nu64wcmULz/mFFA5w==} + engines: {node: '>=15.0.0'} + dependencies: + axios: 0.27.2 + transitivePeerDependencies: + - debug + dev: false + /postman-code-generators/1.3.0: resolution: {integrity: sha512-ikjYTukybZ97SMyyBYNPtcYNpc8/nf5kpRUgThddadC4RkgQwfGBarormcdUQkPKTgQpDd889KVTwTLVGC0RUg==} engines: {node: '>=6'} diff --git a/turbo.json b/turbo.json index 72e216c89d..3b5905a83f 100644 --- a/turbo.json +++ b/turbo.json @@ -39,6 +39,10 @@ "db:cleanDatabase": { "dependsOn": ["db#db:generate"], "cache": false + }, + "telemetry:sendTotalResultsDigest": { + "dependsOn": ["db#db:generate"], + "cache": false } } }