diff --git a/.github/workflows/send-total-results-digest.yml b/.github/workflows/send-total-results-digest.yml new file mode 100644 index 00000000000..d0152537634 --- /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 fa06ae2c9da..04dfe54fc00 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 0aa3357e3fb..7da0db1aec1 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 ddaec42d51e..35a573e2357 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 00000000000..23e70273d36 --- /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 6174ac8775a..e996a5134b8 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 87019b5c973..bef0c6e72c8 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 7b5c1870e83..ebccc22d43b 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 3eba2414a35..9276f5b5a05 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 b8f91b0d26c..f31bae07995 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 c0806cfe6df..a08f6d4db07 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 109d06abd82..efff54977fe 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 e2cf8cc3cf6..d3e29ef50ff 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 607efae0ffd..f513906ea78 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
--| 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 | + +
+ +| Parameter | Default | Description | +| ---------------- | ------- | ---------------- | +| POSTHOG_API_KEY | | PostHog API Key | +| POSTHOG_API_HOST | | PostHog API Host |