From 342df56ae200fdd4dde0a1cda51cb340fba4794d Mon Sep 17 00:00:00 2001 From: Othman Abu Ajamieh <52608229+othmanemad@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:14:00 +0300 Subject: [PATCH 1/6] fix(flows): stop template imports from failing when the v23 upgrade has no stored flow (#15736) --- brain/knowledge/flows-execution/flows.md | 3 +- .../flow-version/piece-upgrade.service.ts | 9 +- .../ce/flows/flow/flow-operations.test.ts | 40 ++++++ .../piece-upgrade.service.test.ts | 118 ++++++++++++++++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts diff --git a/brain/knowledge/flows-execution/flows.md b/brain/knowledge/flows-execution/flows.md index ea6732d5bafa..cb6dbc914e6f 100644 --- a/brain/knowledge/flows-execution/flows.md +++ b/brain/knowledge/flows-execution/flows.md @@ -8,7 +8,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger + ### Entities & services - **Flow** — persistent record: status (ENABLED/DISABLED), folderId, publishedVersionId, externalId, operationStatus (NONE/DELETING/ENABLING/DISABLING), ownerId, createdBy (`FlowCreator`: `{type: MCP|AGENT, id}`, null for humans → "AI" badge). -- **FlowVersion** — immutable-once-LOCKED snapshot of the graph. DRAFT is editable; current schemaVersion is `'22'`. Holds trigger (full graph JSONB), connectionIds, agentIds, notes. +- **FlowVersion** — immutable-once-LOCKED snapshot of the graph. DRAFT is editable; current schemaVersion is `'27'` (`LATEST_FLOW_SCHEMA_VERSION`). Holds trigger (full graph JSONB), connectionIds, agentIds, notes. - **Folder** — simple per-project grouping, case-insensitive unique. - Core service: `flow.service.ts`; single controller endpoint `POST /v1/flows/:id`. @@ -24,6 +24,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger + - **A CODE step's compiled size is its `packageJson`, not its code — bundling *inlines* `node_modules`, it does not exclude it.** This gets assumed backwards a lot: there is no `node_modules` at runtime precisely *because* esbuild inlines every dependency into the step's `index.js`. Measured on cloud, Aug 2026: a step with **1,870 characters** of user source and `{"pdfkit":"0.14.0","aws-sdk":"2.1531.0","uuid":"9.0.1"}` compiles to **24.13 MB**, of which 24.13 MB is `node_modules` — **21.16 MB of it `aws-sdk` alone**. v2 of that SDK resolves its ~200 service clients by dynamic `require`, so esbuild cannot tree-shake it and inlines all of them; `@aws-sdk/client-*` (v3) would be a few hundred KB. Fleet-wide there were 13,918 compiled steps totalling 1.4 GB, **61 of them over 10 MB**, and per *flow* the totals reach **190.7 MB across 40 code steps**. That per-flow number is the one that matters operationally, because `flowBundleStore.publish` holds a flow's entire compiled output in memory at once (three copies — see the OOM gotcha on [[workers]]). To find the offenders: esbuild leaves `// node_modules//…` markers in the output, so you can attribute bytes per package by summing the lines between markers. - **Step output nesting (schema v21+)**: every step output is wrapped as `{ output, error? }`; expressions must use the `['output']` accessor. The v20→v21 migration rewrites existing expressions via `expression-rewriter`. - **Migrations run on every single-version read, including the worker's own `getFlowVersion`** (`findOne` → `flowVersionMigrationService.migrate`). So no run can execute an unmigrated version, and restoring an old version migrates it forward too. The listing path is the exception: `flowVersionService.list()` paginates the repository directly and hands back whatever schema version the row holds, so a caller reading the version history can see unmigrated rows. The result is persisted, then short-circuits at `LATEST_FLOW_SCHEMA_VERSION`. The old version is saved to `backupFiles[oldSchemaVersion]` first — **that** is what a restore reads, so there's no need to keep an old code path alive "just in case". Nothing restores on its own: `flowVersionBackupService.get()` has no callers, so recovering a version is a deliberate manual step. A migration that throws pages on-call (`FLOW_MIGRATION_FAILED`). +- **Template migrations run on a flow version that is not stored.** `migrateFlowVersionTemplate` (the `IMPORT_FLOW` pre-validation and the template controllers) builds a stand-in version with `flowId: ''` and passes no `MigrationContext`, so there is no project or platform either. A migration that looks anything up by flow id must treat "not found" as "nothing stored", never throw: the v23 piece-upgrade audit event used `findOneByOrFail`, which turned every import of an old template holding a register-listed piece into a 500 (the event-destinations "Generate handler flow" hit it with webhook `0.1.33`). - **Continue on Failure**: CODE/PIECE steps with `continueOnFailure.value: true` carry `onSuccess`/`onFailure` sub-trees under `settings.errorHandlingOptions.continueOnFailureBranches`. - **`addActionUtils.clone()` is the single chokepoint for renaming copied steps and rewriting their `{{ }}` references** — paste, duplicate step, and duplicate branch all route through it. It walks the **whole `settings`** deliberately, not just `settings.input`: router conditions live at `settings.branches[].conditions[][].firstValue/.secondValue` and loop expressions at `settings.items`, and an `'input' in settings` guard silently skipped both (GIT-1075). Two things to respect when touching it: `settings.sourceCode` is excluded on purpose (it holds a user program, and a literal `{{ … }}` in a code step was being rewritten), and the remap must stay a **single pass** over the name map — applying one rename at a time rewrites its own output whenever a copied step's new name equals another copied step's old name, which happens on cross-flow paste into a flow that lacks the clipboard's names. - **Paste order follows selection order, not flow order.** `_getActionsForCopy`'s `.sort((a, b) => allSteps.indexOf(a) - ...)` compares deep clones, so `indexOf` is always `-1` and the sort is a no-op. Harmless for a single step; it decides the chaining order for a multi-step paste. diff --git a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts index 1b4a84282347..4b53cbd0bdb0 100644 --- a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts +++ b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts @@ -243,9 +243,12 @@ async function resolveFlowVersionUpgrades({ flowVersion, log }: ResolveFlowVersi } async function sendUpgradeAuditEvent({ platformId, projectId, flowId, flowVersionId, decisions, log }: SendUpgradeAuditEventParams): Promise { - projectId = projectId ?? await flowRepo().findOneByOrFail({ id: flowId }).then((flow) => flow.projectId) - platformId = platformId ?? await projectService(log).getPlatformId(projectId!) - applicationEvents(log).sendUserEvent({ platformId, projectId }, { + const resolvedProjectId = projectId ?? (await flowRepo().findOneBy({ id: flowId }))?.projectId + if (isNil(resolvedProjectId)) { + return + } + const resolvedPlatformId = platformId ?? await projectService(log).getPlatformId(resolvedProjectId) + applicationEvents(log).sendUserEvent({ platformId: resolvedPlatformId, projectId: resolvedProjectId }, { action: ApplicationEventName.FLOW_PIECES_UPGRADED, data: { flowId, diff --git a/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts b/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts index 77d31055e329..03437e16bd1c 100644 --- a/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow/flow-operations.test.ts @@ -6,6 +6,7 @@ import { FlowTriggerType, FlowVersion, FlowVersionState, + LATEST_FLOW_SCHEMA_VERSION, PackageType, PieceType, PopulatedFlow, @@ -794,6 +795,45 @@ describe('Flow Operations API', () => { const persisted: PopulatedFlow = afterImport?.json() expect(persisted.version.trigger.nextAction).toBeUndefined() }) + + it('imports an old template whose piece version the upgrade register moves forward', async () => { + const ctx = await createTestContext(app!) + + const createResponse = await ctx.post('/v1/flows', { + displayName: 'test flow', + projectId: ctx.project.id, + }, { query: { projectId: ctx.project.id } }) + const flow: PopulatedFlow = createResponse?.json() + + const response = await ctx.post(`/v1/flows/${flow.id}`, { + type: FlowOperationType.IMPORT_FLOW, + request: { + displayName: 'Event handler', + trigger: { + type: FlowTriggerType.PIECE, + name: 'trigger', + displayName: 'Catch Webhook', + valid: true, + lastUpdatedDate: new Date().toISOString(), + settings: { + pieceName: '@activepieces/piece-webhook', + pieceVersion: '0.1.33', + triggerName: 'catch_webhook', + input: { authType: 'none', authFields: {} }, + propertySettings: {}, + }, + }, + schemaVersion: '20', + notes: [], + }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body: PopulatedFlow = response?.json() + expect(body.version.schemaVersion).toBe(LATEST_FLOW_SCHEMA_VERSION) + expect(body.version.trigger.type).toBe(FlowTriggerType.PIECE) + expect(body.version.trigger.settings.pieceVersion).not.toBe('0.1.33') + }) }) describe('POST /v1/flows/:id draft creation rollback', () => { diff --git a/packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts b/packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts new file mode 100644 index 000000000000..b3e5e20886e1 --- /dev/null +++ b/packages/server/api/test/unit/app/flows/flow-version/piece-upgrade.service.test.ts @@ -0,0 +1,118 @@ +import { + FlowTriggerType, + FlowVersionState, + LATEST_FLOW_SCHEMA_VERSION, +} from '@activepieces/shared' +import type { FlowTrigger, FlowVersion } from '@activepieces/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { migrateFlowVersionTemplate } from '../../../../../src/app/flows/flow-version/migrations' +import { pieceUpgradeService } from '../../../../../src/app/flows/flow-version/piece-upgrade.service' +import { system } from '../../../../../src/app/helper/system/system' + +const mockFlowFindOneBy = vi.fn() +const mockSendUserEvent = vi.fn() + +vi.mock('../../../../../src/app/flows/flow-version/piece-upgrade-register', () => ({ + pieceUpgradeRegister: { + lookup: vi.fn(async ({ pieceName, pieceVersion }: { pieceName: string, pieceVersion: string }) => + pieceName === WEBHOOK_PIECE_NAME && pieceVersion === OLD_WEBHOOK_VERSION + ? { target: UPGRADED_WEBHOOK_VERSION } + : undefined), + resolveDecision: vi.fn(({ entry }: { entry: { target: string } }) => ({ outcome: 'upgraded', toVersion: entry.target })), + }, +})) + +vi.mock('../../../../../src/app/flows/flow/flow.repo', () => ({ + flowRepo: () => ({ + findOneBy: mockFlowFindOneBy, + }), +})) + +vi.mock('../../../../../src/app/project/project-service', () => ({ + projectService: vi.fn(() => ({ + getPlatformId: vi.fn().mockResolvedValue('platform-1'), + })), +})) + +vi.mock('../../../../../src/app/helper/application-events', () => ({ + applicationEvents: vi.fn(() => ({ + sendUserEvent: mockSendUserEvent, + })), +})) + +describe('pieceUpgradeService.migrateFlowVersion', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlowFindOneBy.mockResolvedValue(null) + }) + + it('migrates a template that has no stored flow without sending an upgrade audit event', async () => { + const migrated = await migrateFlowVersionTemplate({ + displayName: 'Handler flow', + trigger: webhookTrigger(), + schemaVersion: '20', + notes: [], + valid: false, + }) + + expect(migrated.schemaVersion).toBe(LATEST_FLOW_SCHEMA_VERSION) + expect(pieceVersionOf(migrated.trigger)).toBe(UPGRADED_WEBHOOK_VERSION) + expect(mockSendUserEvent).not.toHaveBeenCalled() + }) + + it('sends an upgrade audit event for a stored flow version resolved by its flow id', async () => { + mockFlowFindOneBy.mockResolvedValue({ id: 'flow-1', projectId: 'project-1' }) + + const result = await pieceUpgradeService(system.globalLogger()).migrateFlowVersion({ flowVersion: storedFlowVersion() }) + + expect(pieceVersionOf(result.flowVersion.trigger)).toBe(UPGRADED_WEBHOOK_VERSION) + expect(mockSendUserEvent).toHaveBeenCalledWith( + { platformId: 'platform-1', projectId: 'project-1' }, + expect.objectContaining({ data: expect.objectContaining({ flowId: 'flow-1', flowVersionId: 'fv-1' }) }), + ) + }) +}) + +function webhookTrigger(): FlowTrigger { + return { + name: 'trigger', + valid: true, + displayName: 'Catch Webhook', + lastUpdatedDate: '2026-01-01T00:00:00Z', + type: FlowTriggerType.PIECE, + settings: { + pieceName: WEBHOOK_PIECE_NAME, + pieceVersion: OLD_WEBHOOK_VERSION, + triggerName: 'catch_webhook', + input: {}, + propertySettings: {}, + }, + } +} + +function storedFlowVersion(): FlowVersion { + return { + id: 'fv-1', + created: '2026-01-01T00:00:00Z', + updated: '2026-01-01T00:00:00Z', + flowId: 'flow-1', + displayName: 'Handler flow', + trigger: webhookTrigger(), + updatedBy: null, + valid: true, + schemaVersion: '23', + agentIds: [], + state: FlowVersionState.DRAFT, + connectionIds: [], + backupFiles: null, + notes: [], + } +} + +function pieceVersionOf(trigger: FlowTrigger): string | undefined { + return trigger.type === FlowTriggerType.PIECE ? trigger.settings.pieceVersion : undefined +} + +const WEBHOOK_PIECE_NAME = '@activepieces/piece-webhook' +const OLD_WEBHOOK_VERSION = '0.1.33' +const UPGRADED_WEBHOOK_VERSION = '0.1.36' From 9891daf5f25d4aaf7049d4e53c2d14ae13b90e03 Mon Sep 17 00:00:00 2001 From: moaaz <145723871+moaaz-ae@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:15:31 +0300 Subject: [PATCH 2/6] fix(mcp): unblock the OAuth consent flow for new and returning users (#15520) --- brain/knowledge/ai-intelligence/mcp-server.md | 3 + .../code/mcp-oauth-approve.controller.ts | 58 +++++-- .../integration/ce/mcp/mcp-oauth-deny.test.ts | 113 ++++++++++++++ .../web/public/locales/en/translation.json | 3 + .../src/app/routes/mcp-authorize/index.tsx | 145 +++++++++++++++--- .../components/sign-in-form.tsx | 10 +- .../components/sign-up-form.tsx | 14 +- .../components/verify-email.tsx | 7 +- packages/web/src/lib/navigation-utils.tsx | 21 +++ 9 files changed, 327 insertions(+), 47 deletions(-) create mode 100644 packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index e130b21e7836..d2cd7b043eea 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -38,6 +38,9 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, ### Gotchas +- **The consent page (`/mcp-authorize`) must treat an `ONBOARDING` token as signed out.** `isLoggedIn()` is only a JWT-expiry check; `GET /v1/projects` answers 403 for that principal, so gating on it alone rendered an empty project select with Authorize disabled forever. It now also checks `isOnboarding()` and sends the visitor to `/sign-in?from=…`, where the drawer resumes at the name step and returns to consent. +- **`/verify-email` cannot see the OAuth `from=`** (the link is server-generated), so both `SignUpForm` and `SignInForm` store it via `pendingRedirect` in `navigation-utils` when they show the check-your-email note, and `/verify-email` hands it back as `/sign-in?from=…`. Only matters when passwordless is off — the code flow never leaves the page. +- **Deny goes through `POST /v1/mcp-oauth/deny`**, which verifies the `authRequestId` JWT and returns the client's redirect with `error=access_denied` (it used to be `history.back()`, a no-op in the tab the client opened, so the client never heard back). The consent page only *decodes* the JWT for display: `clientName`, the platform/project scope, and `exp` so an expired request says "go back to {client}" instead of inviting a retry. The browser clock only drives that notice, never the buttons (a clock 10 minutes fast would otherwise lock the user out of a request the server still accepts); Authorize and Deny are disabled only after the server itself answers 400 `invalid_request`. Switch account is a full page load, not a `navigate`, because the query cache would otherwise hand the previous account's projects to the next one. It also preselects a single project and names the signed-in account with a Switch account link, because a browser already signed into another workspace would otherwise authorize it silently. - **`mcp_server.token` is dead — nothing reads it.** It is written by the `getOrCreate` defaults and by both `/rotate` routes (`mcpServerService.rotateToken` / `rotatePlatformToken`), and consulted by **no authenticator**, so "rotating" it rotates a secret that grants nothing. It is still on the public `McpServer` zod schema, so the API keeps shipping a secret-shaped 72-char string that authenticates nothing — do not reach for it as a credential, and do not tell a self-hoster to. The settings panel is consistent with reality already (`mcp-credentials.tsx` renders the URL and *"Authentication is handled via OAuth"*, never a token). Deleting the column, the two routes, and the schema field is a breaking API-response change and has not been done. - **`mcp_oauth_token.clientKey` is decided once, at sign-in.** `exchangeCode` derives it from the registration's redirect URIs via `mcpOAuthClientIdentity`, so the grants list can filter and group in SQL instead of loading diff --git a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts index 8e9d553c9407..373441305836 100644 --- a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts +++ b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts @@ -1,4 +1,4 @@ -import { isNil } from '@activepieces/core-utils' +import { isNil, tryCatch } from '@activepieces/core-utils' import { PlatformRole, PrincipalType } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' @@ -33,23 +33,11 @@ export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => { } } - const key = await jwtUtils.getJwtSecret() - let authRequest: AuthRequestPayload - try { - authRequest = await jwtUtils.decodeAndVerify({ - jwt: authRequestId, - key, - audience: JwtAudience.MCP_OAUTH_AUTH_REQUEST, - }) - } - catch { + const { data: authRequest, error } = await tryCatch(() => verifyAuthRequest(authRequestId)) + if (error) { return reply.status(400).send({ error: 'invalid_request', error_description: 'Invalid or expired authorization request' }) } - if (authRequest.type !== 'mcp_auth_request') { - return reply.status(400).send({ error: 'invalid_request', error_description: 'Invalid authorization request type' }) - } - const code = await mcpOAuthCodeService.create({ clientId: authRequest.clientId, userId, @@ -71,6 +59,34 @@ export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => { return reply.send({ redirectUrl: redirectUrl.toString() }) }) + + app.post('/v1/mcp-oauth/deny', DenyRequest, async (req, reply) => { + const { data: authRequest, error } = await tryCatch(() => verifyAuthRequest(req.body.authRequestId)) + if (error) { + return reply.status(400).send({ error: 'invalid_request', error_description: 'Invalid or expired authorization request' }) + } + + const redirectUrl = new URL(authRequest.redirectUri) + redirectUrl.searchParams.set('error', 'access_denied') + if (authRequest.state) { + redirectUrl.searchParams.set('state', authRequest.state) + } + + return reply.send({ redirectUrl: redirectUrl.toString() }) + }) +} + +async function verifyAuthRequest(authRequestId: string): Promise { + const key = await jwtUtils.getJwtSecret() + const authRequest = await jwtUtils.decodeAndVerify({ + jwt: authRequestId, + key, + audience: JwtAudience.MCP_OAUTH_AUTH_REQUEST, + }) + if (authRequest.type !== 'mcp_auth_request') { + throw new Error('Invalid authorization request type') + } + return authRequest } const ApproveRequest = { @@ -86,6 +102,18 @@ const ApproveRequest = { }, } +const DenyRequest = { + config: { + security: securityAccess.publicPlatform([PrincipalType.USER]), + }, + schema: { + tags: ['mcp-oauth'], + body: z.object({ + authRequestId: z.string(), + }), + }, +} + type AuthRequestPayload = { clientId: string redirectUri: string diff --git a/packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts new file mode 100644 index 000000000000..1ed6972674e4 --- /dev/null +++ b/packages/server/api/test/integration/ce/mcp/mcp-oauth-deny.test.ts @@ -0,0 +1,113 @@ +import { FastifyInstance } from 'fastify' +import { beforeAll, describe, expect, it } from 'vitest' +import { JwtAudience, jwtUtils } from '../../../../src/app/helper/jwt-utils' +import { MCP_OAUTH_REDIRECT_URI, mcpOAuthTestHelpers } from '../../../helpers/mcp-oauth' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance +let ctx: TestContext + +const STATE = 'client-state-123' + +async function startAuthorization({ state }: { state: string | null } = { state: STATE }): Promise { + const client = await mcpOAuthTestHelpers.registerClient({ app, tokenEndpointAuthMethod: 'none' }) + const { challenge } = mcpOAuthTestHelpers.generatePkce() + + const consent = await app.inject({ + method: 'GET', + url: '/authorize?' + new URLSearchParams({ + client_id: client.client_id, + redirect_uri: MCP_OAUTH_REDIRECT_URI, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + scope: 'mcp', + ...(state ? { state } : {}), + }).toString(), + }) + expect(consent.statusCode).toBe(302) + + return new URL(String(consent.headers.location), MCP_OAUTH_REDIRECT_URI).searchParams.get('authRequestId') ?? '' +} + +async function signAuthRequest({ expiresInSeconds, type }: { expiresInSeconds: number, type: string }): Promise { + return jwtUtils.sign({ + payload: { + clientId: 'irrelevant', + clientName: 'Irrelevant', + redirectUri: MCP_OAUTH_REDIRECT_URI, + codeChallenge: 'x'.repeat(43), + codeChallengeMethod: 'S256', + state: STATE, + scopes: ['mcp'], + resource: null, + type, + }, + key: await jwtUtils.getJwtSecret(), + expiresInSeconds, + audience: JwtAudience.MCP_OAUTH_AUTH_REQUEST, + }) +} + +describe('MCP OAuth deny', () => { + beforeAll(async () => { + app = await setupTestEnvironment() + ctx = await createTestContext(app) + }) + + it('sends the client back with access_denied and its state, and no code', async () => { + const authRequestId = await startAuthorization() + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(200) + const redirect = new URL(denied.json().redirectUrl) + expect(redirect.origin + redirect.pathname).toBe(MCP_OAUTH_REDIRECT_URI) + expect(redirect.searchParams.get('error')).toBe('access_denied') + expect(redirect.searchParams.get('state')).toBe(STATE) + expect(redirect.searchParams.get('code')).toBeNull() + }) + + it('omits state when the client sent none', async () => { + const authRequestId = await startAuthorization({ state: null }) + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(200) + const redirect = new URL(denied.json().redirectUrl) + expect(redirect.searchParams.get('error')).toBe('access_denied') + expect(redirect.searchParams.has('state')).toBe(false) + }) + + it('refuses a tampered request', async () => { + const authRequestId = await startAuthorization() + const [header, payload, signature] = authRequestId.split('.') + const forged = JSON.parse(Buffer.from(payload, 'base64url').toString()) + forged.redirectUri = 'https://attacker.example/callback' + const tampered = [header, Buffer.from(JSON.stringify(forged)).toString('base64url'), signature].join('.') + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId: tampered }) + + expect(denied.statusCode).toBe(400) + expect(denied.json().error).toBe('invalid_request') + }) + + it('refuses an expired request', async () => { + const authRequestId = await signAuthRequest({ expiresInSeconds: -1, type: 'mcp_auth_request' }) + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(400) + expect(denied.json().error).toBe('invalid_request') + }) + + it('refuses a signed token that is not an authorization request', async () => { + const authRequestId = await signAuthRequest({ expiresInSeconds: 600, type: 'something_else' }) + + const denied = await ctx.post('/v1/mcp-oauth/deny', { authRequestId }) + + expect(denied.statusCode).toBe(400) + expect(denied.json().error).toBe('invalid_request') + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 71bafc1268dd..0a5a8d089e24 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -949,6 +949,9 @@ "Build, test, and manage automations": "Build, test, and manage automations", "Use connections and execute flows": "Use connections and execute flows", "Authorization failed. Please try again.": "Authorization failed. Please try again.", + "This request has expired. Go back to {client} and start the connection again.": "This request has expired. Go back to {client} and start the connection again.", + "Signed in as {email}": "Signed in as {email}", + "Switch account": "Switch account", "Deny": "Deny", "Authorize": "Authorize", "Unknown app": "Unknown app", diff --git a/packages/web/src/app/routes/mcp-authorize/index.tsx b/packages/web/src/app/routes/mcp-authorize/index.tsx index 8f05217170f7..b10c12f7d0c0 100644 --- a/packages/web/src/app/routes/mcp-authorize/index.tsx +++ b/packages/web/src/app/routes/mcp-authorize/index.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { t } from 'i18next'; import { jwtDecode } from 'jwt-decode'; import { CheckCircle, FolderKanban, Lock, Plug, Workflow } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; @@ -21,23 +21,45 @@ import { } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { MultiSelectFilter } from '@/features/automations/components/multi-select-filter'; +import { userHooks } from '@/hooks/user-hooks'; import { api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; +import { FROM_QUERY_PARAM } from '@/lib/navigation-utils'; import { PermissionItem } from './permission-item'; function McpAuthorizePage() { const [searchParams] = useSearchParams(); const authRequestId = searchParams.get('authRequestId'); - const { clientName, isPlatformScoped } = decodeJwtPayload(authRequestId); + const { clientName, isPlatformScoped, expiresAt } = + decodeJwtPayload(authRequestId); const [selectedProjectId, setSelectedProjectId] = useState< string | undefined >(undefined); const [searchValue, setSearchValue] = useState(''); const [selectedTypes, setSelectedTypes] = useState([]); const [authorized, setAuthorized] = useState(false); + const [requestExpired, setRequestExpired] = useState( + expiresAt !== null && expiresAt * 1000 <= Date.now(), + ); + const [requestRejected, setRequestRejected] = useState(false); const debouncedSetSearchValue = useDebouncedCallback(setSearchValue, 300); - const isLoggedIn = authenticationSession.isLoggedIn(); + const markRejectedOnInvalidRequest = (error: unknown) => { + if ( + api.isError(error) && + error.response?.status === 400 && + (error.response.data as { error?: string } | undefined)?.error === + 'invalid_request' + ) { + setRequestRejected(true); + } + }; + const isLoggedIn = + authenticationSession.isLoggedIn() && !authenticationSession.isOnboarding(); + const currentUserId = authenticationSession.getCurrentUserId(); + const signInPath = `/sign-in?${new URLSearchParams({ + [FROM_QUERY_PARAM]: `/mcp-authorize?${searchParams.toString()}`, + }).toString()}`; const projectTypeOptions = [ { value: ProjectType.TEAM, label: t('Team') }, { value: ProjectType.PERSONAL, label: t('Personal') }, @@ -54,6 +76,28 @@ function McpAuthorizePage() { enabled: isLoggedIn && !!authRequestId && !isPlatformScoped, }); + const { data: currentUser } = userHooks.useUserById( + isLoggedIn ? currentUserId : null, + ); + + useEffect(() => { + if (expiresAt === null || requestExpired) { + return; + } + const timer = setTimeout( + () => setRequestExpired(true), + Math.max(expiresAt * 1000 - Date.now(), 0), + ); + return () => clearTimeout(timer); + }, [expiresAt, requestExpired]); + + useEffect(() => { + const projects = projectsPage?.data ?? []; + if (!selectedProjectId && projects.length === 1 && !searchValue) { + setSelectedProjectId(projects[0].id); + } + }, [projectsPage?.data, selectedProjectId, searchValue]); + const approveMutation = useMutation({ mutationFn: (body: { authRequestId: string; projectId?: string }) => api.post<{ redirectUrl: string }>('/v1/mcp-oauth/approve', body), @@ -61,6 +105,16 @@ function McpAuthorizePage() { window.location.href = data.redirectUrl; setAuthorized(true); }, + onError: markRejectedOnInvalidRequest, + }); + + const denyMutation = useMutation({ + mutationFn: (body: { authRequestId: string }) => + api.post<{ redirectUrl: string }>('/v1/mcp-oauth/deny', body), + onSuccess: (data) => { + window.location.href = data.redirectUrl; + }, + onError: markRejectedOnInvalidRequest, }); const { projectsMap, options } = useMemo(() => { @@ -76,11 +130,14 @@ function McpAuthorizePage() { } if (!isLoggedIn) { - const returnUrl = `/mcp-authorize?${searchParams.toString()}`; - const loginParams = new URLSearchParams({ from: returnUrl }); - return ; + return ; } + const switchAccount = () => { + authenticationSession.clearSession(); + window.location.href = signInPath; + }; + const handleAuthorize = () => { if (!isPlatformScoped && !selectedProjectId) return; approveMutation.mutate({ @@ -134,6 +191,19 @@ function McpAuthorizePage() { {clientName}{' '} {t('wants to connect to your Activepieces account')} + {currentUser && ( +

+ {t('Signed in as {email}', { email: currentUser.email })} + · + +

+ )} @@ -190,9 +260,20 @@ function McpAuthorizePage() { )} - {approveMutation.isError && ( + {(requestExpired || + requestRejected || + approveMutation.isError || + denyMutation.isError) && (
- {t('Authorization failed. Please try again.')} + {requestRejected || + (requestExpired && + !approveMutation.isError && + !denyMutation.isError) + ? t( + 'This request has expired. Go back to {client} and start the connection again.', + { client: clientName }, + ) + : t('Authorization failed. Please try again.')}
)} @@ -201,7 +282,9 @@ function McpAuthorizePage() { type="button" variant="outline" className="flex-1" - onClick={() => window.history.back()} + loading={denyMutation.isPending} + disabled={requestRejected} + onClick={() => denyMutation.mutate({ authRequestId })} > {t('Deny')} @@ -209,7 +292,9 @@ function McpAuthorizePage() { type="button" className="flex-1" loading={approveMutation.isPending} - disabled={!isPlatformScoped && !selectedProjectId} + disabled={ + requestRejected || (!isPlatformScoped && !selectedProjectId) + } onClick={handleAuthorize} > {t('Authorize')} @@ -221,23 +306,35 @@ function McpAuthorizePage() { ); } -function decodeJwtPayload(token: string | null): { - clientName: string; - isPlatformScoped: boolean; -} { +function decodeJwtPayload(token: string | null): AuthRequestSummary { + const fallback: AuthRequestSummary = { + clientName: t('Unknown app'), + isPlatformScoped: false, + expiresAt: null, + }; try { - if (!token) - return { clientName: t('Unknown app'), isPlatformScoped: false }; - const payload = jwtDecode<{ clientName?: string; resource?: string }>( - token, - ); - const clientName = payload.clientName ?? t('Unknown app'); - const isPlatformScoped = - payload.resource?.endsWith('/mcp/platform') ?? false; - return { clientName, isPlatformScoped }; + if (!token) { + return fallback; + } + const payload = jwtDecode<{ + clientName?: string; + resource?: string; + exp?: number; + }>(token); + return { + clientName: payload.clientName ?? t('Unknown app'), + isPlatformScoped: payload.resource?.endsWith('/mcp/platform') ?? false, + expiresAt: payload.exp ?? null, + }; } catch { - return { clientName: t('Unknown app'), isPlatformScoped: false }; + return fallback; } } +type AuthRequestSummary = { + clientName: string; + isPlatformScoped: boolean; + expiresAt: number | null; +}; + export { McpAuthorizePage }; diff --git a/packages/web/src/features/authentication/components/sign-in-form.tsx b/packages/web/src/features/authentication/components/sign-in-form.tsx index 5d0fa5aecec6..2d22e89433e6 100644 --- a/packages/web/src/features/authentication/components/sign-in-form.tsx +++ b/packages/web/src/features/authentication/components/sign-in-form.tsx @@ -13,7 +13,7 @@ import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { z } from 'zod'; import { authenticationApi } from '@/api/authentication-api'; @@ -27,7 +27,11 @@ import { acquisitionUtils } from '@/lib/acquisition-utils'; import { HttpError, api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; import { formatUtils } from '@/lib/format-utils'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; +import { + FROM_QUERY_PARAM, + pendingRedirect, + useRedirectAfterLogin, +} from '@/lib/navigation-utils'; import { CheckEmailNote } from './check-email-note'; @@ -53,6 +57,7 @@ const SignInForm = ({ onForgotPassword }: SignInFormProps) => { const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); const redirectAfterLogin = useRedirectAfterLogin(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { capture } = useTelemetry(); @@ -100,6 +105,7 @@ const SignInForm = ({ onForgotPassword }: SignInFormProps) => { break; } case ErrorCode.EMAIL_IS_NOT_VERIFIED: { + pendingRedirect.remember(searchParams.get(FROM_QUERY_PARAM)); setShowCheckYourEmailNote(true); break; } diff --git a/packages/web/src/features/authentication/components/sign-up-form.tsx b/packages/web/src/features/authentication/components/sign-up-form.tsx index 4a3150e390ca..177edf4078dc 100644 --- a/packages/web/src/features/authentication/components/sign-up-form.tsx +++ b/packages/web/src/features/authentication/components/sign-up-form.tsx @@ -40,7 +40,11 @@ import { acquisitionUtils } from '@/lib/acquisition-utils'; import { api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; import { formatUtils } from '@/lib/format-utils'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; +import { + FROM_QUERY_PARAM, + pendingRedirect, + useRedirectAfterLogin, +} from '@/lib/navigation-utils'; import { authMutations } from '../hooks/auth-hooks'; import { captchaUtils } from '../utils/captcha-utils'; @@ -98,6 +102,10 @@ const SignUpForm = ({ const redirectAfterLogin = useRedirectAfterLogin(); const navigate = useNavigate(); const { capture } = useTelemetry(); + const askToCheckEmail = () => { + pendingRedirect.remember(searchParams.get(FROM_QUERY_PARAM)); + setShowCheckYourEmailNote(true); + }; const { mutate, isPending } = authMutations.useSignUp({ onSuccess: (data) => { @@ -110,7 +118,7 @@ const SignUpForm = ({ } redirectAfterLogin(); } else { - setShowCheckYourEmailNote(true); + askToCheckEmail(); } }, onError: (error) => { @@ -139,7 +147,7 @@ const SignUpForm = ({ } switch (errorCode) { case ErrorCode.EMAIL_IS_NOT_VERIFIED: { - setShowCheckYourEmailNote(true); + askToCheckEmail(); break; } case ErrorCode.INVITATION_ONLY_SIGN_UP: { diff --git a/packages/web/src/features/authentication/components/verify-email.tsx b/packages/web/src/features/authentication/components/verify-email.tsx index 36fd77ca88c3..5e67d3dd1819 100644 --- a/packages/web/src/features/authentication/components/verify-email.tsx +++ b/packages/web/src/features/authentication/components/verify-email.tsx @@ -12,6 +12,7 @@ import { Card } from '@/components/ui/card'; import { internalErrorToast } from '@/components/ui/sonner'; import { usePartnerStack } from '@/hooks/use-partner-stack'; import { api } from '@/lib/api'; +import { pendingRedirect } from '@/lib/navigation-utils'; import { authMutations } from '../hooks/auth-hooks'; @@ -32,7 +33,7 @@ const VerifyEmail = () => { payload: {}, }); reportSignup(email, firstName); - setTimeout(() => navigate('/sign-in'), 5000); + setTimeout(() => navigate(pendingRedirect.takeSignInPath()), 5000); }, onError: (error) => { if ( @@ -40,11 +41,11 @@ const VerifyEmail = () => { error.response?.status === HttpStatusCode.Gone ) { setIsExpired(true); - setTimeout(() => navigate('/sign-in'), 5000); + setTimeout(() => navigate(pendingRedirect.takeSignInPath()), 5000); } else { console.error(error); internalErrorToast(); - setTimeout(() => navigate('/sign-in'), 5000); + setTimeout(() => navigate(pendingRedirect.takeSignInPath()), 5000); } }, }); diff --git a/packages/web/src/lib/navigation-utils.tsx b/packages/web/src/lib/navigation-utils.tsx index c6113b666b69..0c2482060bc6 100644 --- a/packages/web/src/lib/navigation-utils.tsx +++ b/packages/web/src/lib/navigation-utils.tsx @@ -2,6 +2,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useEmbedding } from '@/components/providers/embed-provider'; +import { ApStorage } from './ap-browser-storage'; + export const useNewWindow = () => { const { embedState } = useEmbedding(); const navigate = useNavigate(); @@ -31,6 +33,23 @@ export const useDefaultRedirectPath = () => { return '/'; }; +export const pendingRedirect = { + remember(from: string | null) { + if (from) { + ApStorage.getInstance().setItem(PENDING_REDIRECT_KEY, from); + } else { + ApStorage.getInstance().removeItem(PENDING_REDIRECT_KEY); + } + }, + takeSignInPath(): string { + const from = ApStorage.getInstance().getItem(PENDING_REDIRECT_KEY); + ApStorage.getInstance().removeItem(PENDING_REDIRECT_KEY); + return from + ? `/sign-in?${FROM_QUERY_PARAM}=${encodeURIComponent(from)}` + : '/sign-in'; + }, +}; + export const useRedirectAfterLogin = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -38,3 +57,5 @@ export const useRedirectAfterLogin = () => { const from = searchParams.get(FROM_QUERY_PARAM) ?? defaultRedirectPath; return () => navigate(from); }; + +const PENDING_REDIRECT_KEY = 'redirectAfterEmailVerification'; From 1c950a94cc8ecdefbae48c5a2f45a4438ca78986 Mon Sep 17 00:00:00 2001 From: moaaz <145723871+moaaz-ae@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:16:48 +0300 Subject: [PATCH 3/6] feat(analytics): track onboarding, invitations and billing lifecycle in PostHog (#15535) --- .../data-storage-observability/index.md | 13 ++ bun.lock | 2 +- packages/core/shared/package.json | 2 +- .../shared/src/lib/core/common/telemetry.ts | 70 ++++++++++ .../authentication.controller.ts | 11 ++ .../passwordless-auth.service.ts | 26 +++- .../federated-authn/federated-authn-module.ts | 15 +- .../saml-authn/authn-sso-saml-controller.ts | 15 +- .../flow-approval-request.service.ts | 11 +- .../billing-providers/autumn-utils.ts | 11 +- .../platform-plan/platform-plan-telemetry.ts | 122 ++++++++++++++++ .../platform-plan/platform-plan.controller.ts | 5 + .../platform-plan/platform-plan.service.ts | 5 +- .../api/src/app/flows/flow/flow.service.ts | 7 + .../api/src/app/helper/telemetry.utils.ts | 63 ++++++++- .../api/src/app/platform/platform.service.ts | 18 ++- .../user-invitation.module.ts | 32 ++++- .../user-invitation.service.ts | 17 ++- .../first-platform-provisioning.test.ts | 2 + .../passwordless-auth-telemetry.test.ts | 125 +++++++++++++++++ .../ee/platform-plan/free-legacy-comp.test.ts | 6 + .../telemetry-cloud-only-events.test.ts | 130 ++++++++++++++++-- .../web/public/locales/en/translation.json | 9 ++ .../widgets/publish-flow-reminder-widget.tsx | 1 - .../configurations/tracked-events-catalog.ts | 53 ++++++- .../providers/telemetry-provider.tsx | 48 ++++++- .../billing/components/plan-selector-utils.ts | 2 +- .../billing/components/plan-selector.tsx | 10 +- .../billing/components/request-trial.tsx | 14 +- .../src/features/flows/hooks/flow-hooks.tsx | 11 -- .../tracked-events-catalog.test.ts | 24 +++- 31 files changed, 825 insertions(+), 55 deletions(-) create mode 100644 packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts create mode 100644 packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts diff --git a/brain/knowledge/data-storage-observability/index.md b/brain/knowledge/data-storage-observability/index.md index a2a84c972058..75a895c7ef6c 100644 --- a/brain/knowledge/data-storage-observability/index.md +++ b/brain/knowledge/data-storage-observability/index.md @@ -65,6 +65,19 @@ Gotchas: - The Google `USER_SIGNED_UP` audit event is gated on `signedUp` too; before that it fired on every login. - SAML users are never stamped: they are provisioned by their platform admin, not by a campaign link. +### Product Lifecycle Telemetry + +Server-side PostHog events for the moments between sign-up and payment: `onboarding.completed` (name step done), `invite.sent` / `invite.accepted`, `checkout.started`, `plan.changed`, `plan.cancelled`, `plan.reactivated`, `trial.started`, plus `flow.published` from the service so API and approval publishes count too. Billing events are emitted by `platform-plan-telemetry.ts`; the entitlement refresh in `autumn-utils.ts` compares the plan before and after and emits `trial.started` or `plan.changed` when it changed, skipping free plans (`FREE`, `FREE_LEGACY`, `APPSUMO`). Platforms are PostHog groups (`groupIdentify`, type `platform`) set on creation and refreshed on plan change. Every event, person and group carries `deployment` (`DeploymentKind`: cloud, self_hosted, dev) because self-hosted instances report into the same project. + +Gotchas: +- Pre-login email-code events are keyed by the identity id; `aliasIdentity` merges that person into the user once the user exists, and `trackForIdentity` in `passwordless-auth.service.ts` switches to the user id as soon as one exists for identity+platform. +- Billing and onboarding events are in `CLOUD_ONLY_TELEMETRY_EVENTS`; the tracked-events catalog on the platform settings page hides groups whose events are all cloud-only, so `billing` never shows on self-hosted. +- In a development environment the web sends nothing to PostHog unless `localStorage.ap_posthog_dev` is `'1'`. +- **The event is `plan.changed`, not `plan.upgraded`** — the entitlement refresh only knows the plan differs, not which way, so a downgrade reaches the same code path. Direction is derived in PostHog from the `previousPlan` and `plan` properties. A trial converting to paid emits **nothing**: it is the same `planId` with the trial simply ended, so the plan-equality check returns early, and no previous trialing state is persisted anywhere to compare against (`trialEndsAt` comes from the live Autumn subscription, `platform_plan` has no trial column, and the customer state cache holds only balances). +- **`flow.published` is emitted once, by `flowService`** — the browser capture in `flow-hooks.tsx` was removed when the service emit landed, because the two fired on exactly the same UI publishes. The service emit also counts publishes no user pressed Publish for: the connection-swap republish in `app-connection.handler.ts` and the git-sync project-release import both dispatch `LOCK_AND_PUBLISH`. +- **A platform group's `name` only leaves Cloud** (`pickPlatformGroupPii`): a self-hosted platform name is usually the customer's company name, the same reason `pickTelemetryPii` exists. The group itself is still created on every edition, so `plan`, `createdAt` and `deployment` stay groupable off Cloud. +- **Google and SAML emit `signed.in` only for a returning user**, in the `else` branch of the `signedUp` check, mirroring the password path where sign-up emits `signed.up` and only a later sign-in emits `signed.in`. Emitting it on every claim made SSO sign-in counts run high against password. + ### Flow Failure Alerts (EE) Email on flow-run failure. First failure per flowVersion per 24h window sends; rest suppressed via Redis counter `flow_fail_count:` (1-day TTL). Personal projects: single owner-only receiver toggle; team projects: any number of receivers. Platform admins can bulk sub/unsub across projects (max 5 concurrent). Receivers stored/compared lowercase. Edition check (`paidEditions`) in service, no plan flag. No Issues feature — email links straight to the run page. EE/Cloud only. diff --git a/bun.lock b/bun.lock index 2466e08667f3..1f058068c039 100644 --- a/bun.lock +++ b/bun.lock @@ -165,7 +165,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.177.0", + "version": "0.178.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 303ade598a50..83ebd6e24154 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.177.0", + "version": "0.178.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/core/common/telemetry.ts b/packages/core/shared/src/lib/core/common/telemetry.ts index 7f0ca282d4ea..de495f92ef65 100644 --- a/packages/core/shared/src/lib/core/common/telemetry.ts +++ b/packages/core/shared/src/lib/core/common/telemetry.ts @@ -28,6 +28,45 @@ type SignInSubmitted = { method: 'email' } +type OnboardingCompleted = { + userId: UserId + platformId: string +} + +type PlanChange = { + platformId: string + plan: string + previousPlan?: string +} + +type CheckoutStarted = { + platformId: string + plan: string +} + +type TrialStarted = { + platformId: string + plan: string + trialEndsAt: string +} + +type InviteSent = { + platformId: string + type: 'platform' | 'project' + role?: string +} + +type InviteAccepted = { + platformId: string + type: 'platform' | 'project' +} + +type SalesHandoffClicked = { + featureKey?: string + plan?: string + surface: string +} + type SignInFailed = { errorCode: string } @@ -119,6 +158,15 @@ export enum TelemetryEventName { PIECE_SELECTOR_SEARCH = 'piece.selector.search', MCP_TOOL_CALLED = 'mcp.tool.called', MCP_SERVER_CONNECTED = 'mcp.server.connected', + ONBOARDING_COMPLETED = 'onboarding.completed', + CHECKOUT_STARTED = 'checkout.started', + PLAN_CHANGED = 'plan.changed', + PLAN_CANCELLED = 'plan.cancelled', + PLAN_REACTIVATED = 'plan.reactivated', + TRIAL_STARTED = 'trial.started', + INVITE_SENT = 'invite.sent', + INVITE_ACCEPTED = 'invite.accepted', + SALES_HANDOFF_CLICKED = 'sales.handoff.clicked', } export type TelemetryEvent = @@ -142,8 +190,24 @@ export type TelemetryEvent = | BaseTelemetryEvent | BaseTelemetryEvent | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent + | BaseTelemetryEvent export const CLOUD_ONLY_TELEMETRY_EVENTS: ReadonlySet = new Set([ + TelemetryEventName.ONBOARDING_COMPLETED, + TelemetryEventName.CHECKOUT_STARTED, + TelemetryEventName.PLAN_CHANGED, + TelemetryEventName.PLAN_CANCELLED, + TelemetryEventName.PLAN_REACTIVATED, + TelemetryEventName.TRIAL_STARTED, + TelemetryEventName.SALES_HANDOFF_CLICKED, TelemetryEventName.SIGNED_UP, TelemetryEventName.SIGNED_IN, TelemetryEventName.SIGN_UP_SUBMITTED, @@ -160,3 +224,9 @@ export const CLOUD_ONLY_TELEMETRY_EVENTS: ReadonlySet = new ]) export const isCloudOnlyTelemetryEvent = (name: TelemetryEventName): boolean => CLOUD_ONLY_TELEMETRY_EVENTS.has(name) + +export enum DeploymentKind { + CLOUD = 'cloud', + SELF_HOSTED = 'self_hosted', + DEV = 'dev', +} diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index 6d2d4340e78b..72f3d54c81b9 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -123,6 +123,17 @@ export const authenticationController: FastifyPluginAsyncZod = async ( method: attributionUtils.signUpMethodFromProvider({ provider }), attribution: request.body.attribution, }), request.log) + rejectedPromiseHandler(telemetry(request.log).trackUser({ + userId: response.id, + platformId: response.platformId, + event: { + name: TelemetryEventName.ONBOARDING_COMPLETED, + payload: { + userId: response.id, + platformId: response.platformId, + }, + }, + }), request.log) } return response diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts index 18b536ea8031..bdf0fdb6f030 100644 --- a/packages/server/api/src/app/authentication/passwordless-auth.service.ts +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -1,6 +1,6 @@ import { ActivepiecesError, ErrorCode, isNil } from '@activepieces/core-utils' import { cryptoUtils } from '@activepieces/server-utils' -import { ApFlagId, OtpType, TelemetryEventName, UserIdentity, UserIdentityProvider } from '@activepieces/shared' +import { ApFlagId, OtpType, TelemetryEvent, TelemetryEventName, UserIdentity, UserIdentityProvider } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { flagService } from '../flags/flag.service' import { rejectedPromiseHandler } from '../helper/promise-handler' @@ -85,13 +85,14 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ const preferredPlatformId = isNil(platformId) ? await authenticationService(log).selectCloudSignInPlatformId({ identityId: verifiedIdentity.id }) : platformId - rejectedPromiseHandler(telemetry(log).trackIdentity({ + rejectedPromiseHandler(trackForIdentity({ identityId: verifiedIdentity.id, platformId: preferredPlatformId, event: { name: TelemetryEventName.EMAIL_CODE_VERIFIED, payload: { needsNameStep: isNil(preferredPlatformId) }, }, + log, }), log) if (!isNil(platformId)) { @@ -106,6 +107,7 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ identity: verifiedIdentity, platformId, }) + rejectedPromiseHandler(telemetry(log).aliasIdentity({ identityId: verifiedIdentity.id, userId: user.id, platformId }), log) await userInvitationsService(log).provisionUserInvitation({ email }) const response = await authenticationUtils(log).getProjectAndToken({ userId: user.id, @@ -121,6 +123,7 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ identity: verifiedIdentity, platformId: preferredPlatformId, }) + rejectedPromiseHandler(telemetry(log).aliasIdentity({ identityId: verifiedIdentity.id, userId: user.id, platformId: preferredPlatformId }), log) const response = await authenticationUtils(log).getProjectAndToken({ userId: user.id, platformId: preferredPlatformId, @@ -153,16 +156,26 @@ async function trackEmailCodeRequested({ identityId, platformId, isNewIdentity, const preferredPlatformId = isNil(platformId) ? await authenticationService(log).selectCloudSignInPlatformId({ identityId }) : platformId - await telemetry(log).trackIdentity({ + await trackForIdentity({ identityId, platformId: preferredPlatformId, event: { name: TelemetryEventName.EMAIL_CODE_REQUESTED, payload: { isNewIdentity }, }, + log, }) } +async function trackForIdentity({ identityId, platformId, event, log }: TrackForIdentityParams): Promise { + const user = isNil(platformId) ? null : await userService(log).getOneByIdentityAndPlatform({ identityId, platformId }) + if (!isNil(user) && !isNil(platformId)) { + await telemetry(log).trackUser({ userId: user.id, platformId, event }) + return + } + await telemetry(log).trackIdentity({ identityId, platformId, event }) +} + async function assertPlatformAuthIsOpenTo({ email, platformId, log }: PlatformGateParams): Promise { await authenticationUtils(log).assertEmailAuthIsEnabled({ platformId, @@ -190,6 +203,13 @@ type TrackEmailCodeRequestedParams = { log: FastifyBaseLogger } +type TrackForIdentityParams = { + identityId: string + platformId: string | null + event: TelemetryEvent + log: FastifyBaseLogger +} + type RequestCodeParams = { email: string platformId: string | null diff --git a/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts b/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts index 40471087639b..85670ea5c611 100644 --- a/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts +++ b/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts @@ -1,5 +1,5 @@ import { isNil } from '@activepieces/core-utils' -import { ApplicationEventName, ClaimTokenRequest, SignUpMethod, ThirdPartyAuthnProviderEnum } from '@activepieces/shared' +import { ApplicationEventName, ClaimTokenRequest, SignUpMethod, TelemetryEventName, ThirdPartyAuthnProviderEnum } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' @@ -51,6 +51,19 @@ const federatedAuthnController: FastifyPluginAsyncZod = async (app) => { attribution: req.body.attribution, }), req.log) } + else if (!isNil(response.platformId)) { + rejectedPromiseHandler(telemetry(req.log).trackUser({ + userId: response.id, + platformId: response.platformId, + event: { + name: TelemetryEventName.SIGNED_IN, + payload: { + userId: response.id, + platformId: response.platformId, + }, + }, + }), req.log) + } return response }) } diff --git a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts index 97a6321cfae6..8cc16f2f2d34 100644 --- a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts +++ b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts @@ -1,12 +1,14 @@ import { assertNotNullOrUndefined } from '@activepieces/core-utils' -import { ApplicationEventName, PrincipalType } from '@activepieces/shared' +import { ApplicationEventName, PrincipalType, TelemetryEventName } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' import { applicationEvents } from '../../../helper/application-events' import { networkUtils } from '../../../helper/network-utils' +import { rejectedPromiseHandler } from '../../../helper/promise-handler' import { system } from '../../../helper/system/system' import { AppSystemProp } from '../../../helper/system/system-props' +import { telemetry } from '../../../helper/telemetry.utils' import { platformUtils } from '../../../platform/platform.utils' import { platformMustHaveFeatureEnabled } from '../ee-authorization' import { authnSsoSamlService } from './authn-sso-saml-service' @@ -43,6 +45,17 @@ export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => { source: 'sso', }, }) + rejectedPromiseHandler(telemetry(req.log).trackUser({ + userId: response.id, + platformId, + event: { + name: TelemetryEventName.SIGNED_IN, + payload: { + userId: response.id, + platformId, + }, + }, + }), req.log) return res.redirect(url.toString()) }) diff --git a/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts b/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts index e67ac8f9f6b4..c368b5064572 100644 --- a/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts +++ b/packages/server/api/src/app/ee/flows/flow-approval/flow-approval-request.service.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, apId, ApId, ApplicationEventName, Cursor, ErrorCode, Flow, FlowApprovalRequest, FlowApprovalRequestState, FlowOperationType, FlowStatus, FlowVersionState, isNil, PlatformId, PopulatedFlowApprovalRequest, Principal, PrincipalType, ProjectId, SeekPage, UserId } from '@activepieces/shared' +import { ActivepiecesError, apId, ApId, ApplicationEventName, Cursor, ErrorCode, Flow, FlowApprovalRequest, FlowApprovalRequestState, FlowOperationType, FlowStatus, FlowVersionState, isNil, PlatformId, PopulatedFlowApprovalRequest, Principal, PrincipalType, ProjectId, SeekPage, TelemetryEventName, UserId } from '@activepieces/shared' import { FastifyBaseLogger, FastifyRequest } from 'fastify' import { repoFactory } from '../../../core/db/repo-factory' import { transaction } from '../../../core/db/transaction' @@ -9,6 +9,8 @@ import { applicationEvents } from '../../../helper/application-events' import { buildPaginator } from '../../../helper/pagination/build-paginator' import { paginationHelper } from '../../../helper/pagination/pagination-utils' import { Order } from '../../../helper/pagination/paginator' +import { rejectedPromiseHandler } from '../../../helper/promise-handler' +import { telemetry } from '../../../helper/telemetry.utils' import { triggerSourceService } from '../../../trigger/trigger-source/trigger-source-service' import { FlowApprovalRequestEntity } from './flow-approval-request.entity' @@ -124,6 +126,13 @@ export const flowApprovalRequestService = (log: FastifyBaseLogger) => ({ projectId: approval.projectId, newStatus: approval.requestedStatus, }) + rejectedPromiseHandler(telemetry(log).trackProject({ + projectId: approval.projectId, + event: { + name: TelemetryEventName.FLOW_PUBLISHED, + payload: { flowId: flow.id }, + }, + }), log) applicationEvents(log).sendUserEvent(request, { action: ApplicationEventName.FLOW_APPROVAL_GRANTED, data: { diff --git a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts index 6dc59a97d440..9e5005a7ff3a 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts @@ -23,7 +23,8 @@ import { AppSystemProp } from '../../../../helper/system/system-props' import { AppSumoAction, CancellationFeedback, CreditUsage, CreditUsageSource } from '../../../../platform/billing-provider' import { platformService } from '../../../../platform/platform.service' import { userService } from '../../../../user/user-service' -import { platformPlanService } from '../platform-plan.service' +import { platformPlanTelemetry } from '../platform-plan-telemetry' +import { platformPlanRepo, platformPlanService } from '../platform-plan.service' const AUTUMN_CONSOLE_URL = system.getOrThrow(AppSystemProp.AUTUMN_CONSOLE_URL).replace(/\/+$/, '') const edition = system.getEdition() @@ -162,10 +163,18 @@ export const autumnUtils = { } const customer = await client.getCustomer({ expand: ['subscriptions.plan', 'purchases.plan'] }) const entitlements = toAutumnEntitlements(customer) + const previousPlan = await platformPlanRepo().findOneBy({ platformId }) await platformPlanService(log).update({ platformId, ...autumnUtils.mapAutumnFeaturesToPlatformPlan(entitlements) }) await autumnUtils.writeCustomerStateCaches({ platformId, customer, grantedFeatureIds: entitlements.grantedFeatureIds }) await autumnUtils.invalidateBillingOverview(platformId) await autumnUtils.provisionLicenseKeyIfPaid(log, platformId, entitlements.planId) + const currentSubscription = autumnUtils.selectCurrentBaseSubscription(autumnUtils.toBaseSubscriptions(customer)) + rejectedPromiseHandler(platformPlanTelemetry(log).onEntitlementsRefreshed({ + platformId, + previousPlan: previousPlan?.plan ?? null, + plan: entitlements.planId, + trialEndsAt: isNil(currentSubscription?.trialEndsAt) ? null : new Date(currentSubscription.trialEndsAt).toISOString(), + }), log) }, async provisionLicenseKeyIfPaid(log: FastifyBaseLogger, platformId: string, planId: string | null): Promise { if (isNil(planId) || planId === PlanName.FREE || planId === PlanName.APPSUMO || planId === PlanName.FREE_LEGACY) { diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts new file mode 100644 index 000000000000..65de4577258e --- /dev/null +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan-telemetry.ts @@ -0,0 +1,122 @@ +import { isNil } from '@activepieces/core-utils' +import { PlanName, TelemetryEventName } from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { distributedStore } from '../../../database/redis-connections' +import { telemetry } from '../../../helper/telemetry.utils' +import { platformService } from '../../../platform/platform.service' +import { platformPlanRepo } from './platform-plan.service' + +export const platformPlanTelemetry = (log: FastifyBaseLogger) => ({ + async onCheckoutStarted({ platformId, planId }: CheckoutStartedParams): Promise { + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.CHECKOUT_STARTED, + payload: { platformId, plan: planId }, + }, + }) + }, + + async onCancelled({ platformId }: PlanActionParams): Promise { + const plan = await currentPlanName({ platformId }) + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.PLAN_CANCELLED, + payload: { platformId, plan }, + }, + }) + }, + + async onReactivated({ platformId }: PlanActionParams): Promise { + const plan = await currentPlanName({ platformId }) + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.PLAN_REACTIVATED, + payload: { platformId, plan }, + }, + }) + }, + + async onEntitlementsRefreshed({ platformId, previousPlan, plan, trialEndsAt }: EntitlementsRefreshedParams): Promise { + if (plan === previousPlan) { + return + } + const platform = await platformService(log).getOneOrThrow(platformId) + await telemetry(log).identifyPlatformGroup({ + platformId, + properties: { name: platform.name, plan, createdAt: platform.created }, + }) + if (isNil(plan) || FREE_PLANS.has(plan)) { + return + } + await distributedStore.runOnceWithin( + planChangeKey({ platformId, previousPlan, plan }), + PLAN_CHANGE_ONCE_SECONDS, + () => trackPlanChange({ platformId, previousPlan, plan, trialEndsAt, log }), + ) + }, +}) + +async function trackPlanChange({ platformId, previousPlan, plan, trialEndsAt, log }: TrackPlanChangeParams): Promise { + const trialing = !isNil(trialEndsAt) && new Date(trialEndsAt).getTime() > Date.now() + if (trialing) { + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.TRIAL_STARTED, + payload: { platformId, plan, trialEndsAt }, + }, + }) + return + } + await telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.PLAN_CHANGED, + payload: { platformId, plan, previousPlan: previousPlan ?? undefined }, + }, + }) +} + +function planChangeKey({ platformId, previousPlan, plan }: PlanChangeKeyParams): string { + return `telemetry:plan-change:${platformId}:${previousPlan ?? 'none'}:${plan}` +} + +async function currentPlanName({ platformId }: PlanActionParams): Promise { + const platformPlan = await platformPlanRepo().findOneBy({ platformId }) + return platformPlan?.plan ?? UNKNOWN_PLAN +} + +const UNKNOWN_PLAN = 'unknown' + +const PLAN_CHANGE_ONCE_SECONDS = 24 * 60 * 60 + +const FREE_PLANS: ReadonlySet = new Set([PlanName.FREE, PlanName.FREE_LEGACY, PlanName.APPSUMO]) + +type PlanActionParams = { + platformId: string +} + +type CheckoutStartedParams = PlanActionParams & { + planId: string +} + +type EntitlementsRefreshedParams = { + platformId: string + previousPlan: string | null + plan: string | null + trialEndsAt: string | null +} + +type PlanChangeKeyParams = { + platformId: string + previousPlan: string | null + plan: string +} + +type TrackPlanChangeParams = PlanChangeKeyParams & { + trialEndsAt: string | null + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts index f3feaabf649e..689dbc8b67a0 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts @@ -7,9 +7,11 @@ import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' import { getEntitlementsForceRefreshKey } from '../../../database/redis/keys' import { distributedStore } from '../../../database/redis-connections' +import { rejectedPromiseHandler } from '../../../helper/promise-handler' import { billingProvider } from '../../../platform/billing-provider' import { platformService } from '../../../platform/platform.service' import { userService } from '../../../user/user-service' +import { platformPlanTelemetry } from './platform-plan-telemetry' import { platformPlanService } from './platform-plan.service' const FORCE_REFRESH_DEDUP_SECONDS = 60 @@ -54,6 +56,7 @@ export const platformPlanController: FastifyPluginAsyncZod = async (app) => { planId: request.body.planId, successUrl: request.body.successUrl, }) + rejectedPromiseHandler(platformPlanTelemetry(request.log).onCheckoutStarted({ platformId, planId: request.body.planId }), request.log) await refreshWhenAppliedImmediately({ log: request.log, platformId, checkoutUrl: result.checkoutUrl }) return result }) @@ -69,6 +72,7 @@ export const platformPlanController: FastifyPluginAsyncZod = async (app) => { canceledByEmail: await resolveActorEmail(request.log, request.principal.id), }, }) + rejectedPromiseHandler(platformPlanTelemetry(request.log).onCancelled({ platformId }), request.log) await provider.refreshEntitlements(platformId) }) @@ -76,6 +80,7 @@ export const platformPlanController: FastifyPluginAsyncZod = async (app) => { const platformId = request.principal.platform.id const provider = billingProvider.get(request.log) await provider.reactivateSubscription({ platformId }) + rejectedPromiseHandler(platformPlanTelemetry(request.log).onReactivated({ platformId }), request.log) await provider.refreshEntitlements(platformId) }) diff --git a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts index e46562642764..bc341fe6a12c 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/platform-plan.service.ts @@ -55,9 +55,10 @@ export const platformPlanService = (log: FastifyBaseLogger) => ({ return platformPlan }, - async onPlatformCreated(platformId: string): Promise { - await createInitialBilling(platformId) + async onPlatformCreated(platformId: string): Promise { + const platformPlan = await createInitialBilling(platformId) await enrollBillingProviderOnCreate(platformId, log) + return platformPlan }, async update(params: UpdatePlatformBillingParams): Promise { diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index 8a5af5b4700a..9a4dc3416856 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -524,6 +524,13 @@ export const flowService = (log: FastifyBaseLogger) => ({ }) const { websocketService } = await import('../../core/websockets.service') websocketService.notifyWorkers().flowPublished({ flowId: publishedFlow.id, flowVersionId: publishedFlow.version.id, projectId: publishedFlow.projectId }) + rejectedPromiseHandler(telemetry(log).trackProject({ + projectId, + event: { + name: TelemetryEventName.FLOW_PUBLISHED, + payload: { flowId: id }, + }, + }), log) return publishedFlow }, diff --git a/packages/server/api/src/app/helper/telemetry.utils.ts b/packages/server/api/src/app/helper/telemetry.utils.ts index b491aa3a6e7d..3dfd1bc999f4 100644 --- a/packages/server/api/src/app/helper/telemetry.utils.ts +++ b/packages/server/api/src/app/helper/telemetry.utils.ts @@ -1,6 +1,6 @@ import { AIProviderName, isNil, ProjectId, spreadIfDefined, UserId } from '@activepieces/core-utils' import { apVersionUtil } from '@activepieces/server-utils' -import { ApEdition, AppInstance, AttributionParams, attributionUtils, DeploymentConfig, FlowRunStatus, GetDiagnosticsResponse, GetSystemHealthChecksResponse, isCloudOnlyTelemetryEvent, MachineInformation, pickTelemetryPii, RunEnvironment, SignUpMethod, TelemetryEvent, User, UserIdentity } from '@activepieces/shared' +import { ApEdition, ApEnvironment, AppInstance, AttributionParams, attributionUtils, DeploymentConfig, DeploymentKind, FlowRunStatus, GetDiagnosticsResponse, GetSystemHealthChecksResponse, isCloudOnlyTelemetryEvent, MachineInformation, pickTelemetryPii, RunEnvironment, SignUpMethod, TelemetryEvent, User, UserIdentity } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { PostHog } from 'posthog-node' import { platformConfigurationService } from '../platform/platform-configuration.service' @@ -57,6 +57,30 @@ export const telemetry = (log: FastifyBaseLogger) => ({ }, }) }, + async aliasIdentity({ identityId, userId, platformId }: AliasIdentityParams): Promise { + if (system.getEdition() !== ApEdition.CLOUD) { + return + } + if (!await platformConfigurationService(log).isProductTelemetryEnabled({ platformId })) { + return + } + getPostHog().alias({ distinctId: userId, alias: identityId }) + }, + async identifyPlatformGroup({ platformId, properties }: IdentifyPlatformGroupParams): Promise { + if (!await platformConfigurationService(log).isProductTelemetryEnabled({ platformId })) { + return + } + getPostHog().groupIdentify({ + groupType: PLATFORM_GROUP_TYPE, + groupKey: platformId, + properties: { + ...pickPlatformGroupPii(properties), + plan: properties.plan, + createdAt: properties.createdAt, + deployment: getDeploymentKind(), + }, + }) + }, async trackPlatform({ platformId, event }: TrackPlatformParams): Promise { if (!await platformConfigurationService(log).isProductTelemetryEnabled({ platformId })) { return @@ -149,10 +173,30 @@ async function getMetadata() { activepiecesVersion: currentVersion, activepiecesEnvironment: system.get(AppSystemProp.ENVIRONMENT), activepiecesEdition: edition, + deployment: getDeploymentKind(), source_site: 'product', } } +function pickPlatformGroupPii(properties: PlatformGroupProperties): { name?: string } { + if (system.getEdition() !== ApEdition.CLOUD) { + return {} + } + return { name: properties.name } +} + +function getDeploymentKind(): DeploymentKind { + if (system.getEdition() === ApEdition.CLOUD) { + return DeploymentKind.CLOUD + } + if (system.get(AppSystemProp.ENVIRONMENT) === ApEnvironment.DEVELOPMENT) { + return DeploymentKind.DEV + } + return DeploymentKind.SELF_HOSTED +} + +const PLATFORM_GROUP_TYPE = 'platform' + export enum LicenseKeyPostHogEvents { AI_USAGE_PER_RUN = 'ai_usage_per_run', CHAT_MESSAGE = 'chat_message', @@ -242,6 +286,23 @@ type IdentifySignUpParams = { attribution: AttributionParams | undefined } +type AliasIdentityParams = { + identityId: string + userId: UserId + platformId: string +} + +export type PlatformGroupProperties = { + name: string + plan: string | null + createdAt: string +} + +type IdentifyPlatformGroupParams = { + platformId: string + properties: PlatformGroupProperties +} + type TrackPlatformParams = { platformId: string event: TelemetryEvent diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 8c9cfaa686b7..4fa1c71567f9 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -11,6 +11,7 @@ import { platformPlanService } from '../ee/platform/platform-plan/platform-plan. import { defaultTheme } from '../flags/theme' import { rejectedPromiseHandler } from '../helper/promise-handler' import { system } from '../helper/system/system' +import { telemetry } from '../helper/telemetry.utils' import { projectService } from '../project/project-service' import { userService } from '../user/user-service' import { billingProvider } from './billing-provider' @@ -73,7 +74,14 @@ export const platformService = (log: FastifyBaseLogger) => ({ platformId: savedPlatform.id, }) - await platformPlanService(log).onPlatformCreated(savedPlatform.id) + const platformPlan = await platformPlanService(log).onPlatformCreated(savedPlatform.id) + const { error: telemetryError } = await tryCatch(() => telemetry(log).identifyPlatformGroup({ + platformId: savedPlatform.id, + properties: { name: savedPlatform.name, plan: platformPlan.plan ?? null, createdAt: savedPlatform.created }, + })) + if (!isNil(telemetryError)) { + log.warn({ error: telemetryError, platform: { id: savedPlatform.id } }, 'Failed to identify the platform group') + } log.info({ platform: { id: savedPlatform.id }, ownerId }, 'Platform created') return stripFederatedAuth(savedPlatform) @@ -113,7 +121,7 @@ export const platformService = (log: FastifyBaseLogger) => ({ if (invalidatePreviousTokens) { await rotateTokenVersion(identityId) } - await reportSignup({ identityId, user: owner, projectId: personalProject.id, log }) + await reportSignup({ identityId, user: owner, platformId: platform.id, projectId: personalProject.id, log }) const response = await authenticationUtils(log).getProjectAndToken({ userId: owner.id, platformId: platform.id, @@ -301,17 +309,18 @@ async function linkOwnerToPlatform({ ownerId, platformId, identityId, name, inva log, }) if (!isNil(response.projectId)) { - rejectedPromiseHandler(reportSignup({ identityId, user: owner, projectId: response.projectId, log }), log) + rejectedPromiseHandler(reportSignup({ identityId, user: owner, platformId, projectId: response.projectId, log }), log) } return { response, provisioned: true } } -async function reportSignup({ identityId, user, projectId, log }: ReportSignupParams): Promise { +async function reportSignup({ identityId, user, platformId, projectId, log }: ReportSignupParams): Promise { await authenticationUtils(log).sendTelemetry({ identity: await userIdentityService(log).getOneOrFail({ id: identityId }), user, projectId, }) + rejectedPromiseHandler(telemetry(log).aliasIdentity({ identityId, userId: user.id, platformId }), log) } function isSameTokenVersion(current: string | undefined, caller: string | undefined): boolean { @@ -461,6 +470,7 @@ type FinishExistingPlatformParams = { type ReportSignupParams = { identityId: string user: User + platformId: string projectId: string log: FastifyBaseLogger } diff --git a/packages/server/api/src/app/user-invitations/user-invitation.module.ts b/packages/server/api/src/app/user-invitations/user-invitation.module.ts index 05648a7956a7..fec93c87f7ed 100644 --- a/packages/server/api/src/app/user-invitations/user-invitation.module.ts +++ b/packages/server/api/src/app/user-invitations/user-invitation.module.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, assertNotNullOrUndefined, ErrorCode, isNil, Permission, ProjectRole, SeekPage } from '@activepieces/core-utils' -import { InvitationStatus, InvitationType, ListUserInvitationsRequest, Principal, PrincipalType, SendUserInvitationRequest, SERVICE_KEY_SECURITY_OPENAPI, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' +import { InvitationStatus, InvitationType, ListUserInvitationsRequest, Principal, PrincipalType, SendUserInvitationRequest, SERVICE_KEY_SECURITY_OPENAPI, TelemetryEvent, TelemetryEventName, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' import { FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -12,6 +12,8 @@ import { platformMustBeOwnedByCurrentUser, platformMustHaveFeatureEnabled, proje import { assertRoleHasPermission } from '../ee/authentication/project-role/rbac-middleware' import { platformPlanService } from '../ee/platform/platform-plan/platform-plan.service' import { projectRoleService } from '../ee/projects/project-role/project-role.service' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { telemetry } from '../helper/telemetry.utils' import { projectService } from '../project/project-service' import { userService } from '../user/user-service' import { INVITATION_EXPIRY_SECONDS, userInvitationsService } from './user-invitation.service' @@ -64,6 +66,19 @@ const invitationController: FastifyPluginAsyncZod = async (app) => { userInvitation: userInvitationRecord, invitationExpirySeconds: INVITATION_EXPIRY_SECONDS, }) + rejectedPromiseHandler(trackInviteSent({ + principal: request.principal, + platformId, + event: { + name: TelemetryEventName.INVITE_SENT, + payload: { + platformId, + type: type === InvitationType.PLATFORM ? 'platform' : 'project', + role: type === InvitationType.PLATFORM ? request.body.platformRole ?? undefined : projectRole?.name, + }, + }, + log: request.log, + }), request.log) await reply.status(StatusCodes.CREATED).send(invitation) }) @@ -185,6 +200,14 @@ async function assertPrincipalHasPermissionToProject { + if (principal.type === PrincipalType.USER) { + await telemetry(log).trackUser({ userId: principal.id, platformId, event }) + return + } + await telemetry(log).trackPlatform({ platformId, event }) +} + const ListUserInvitationsRequestParams = { config: { security: securityAccess.publicPlatform([PrincipalType.USER, PrincipalType.SERVICE], { @@ -245,3 +268,10 @@ const UpsertUserInvitationRequestParams = { }, }, } + +type TrackInviteSentParams = { + principal: Principal + platformId: string + event: TelemetryEvent + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/user-invitations/user-invitation.service.ts b/packages/server/api/src/app/user-invitations/user-invitation.service.ts index 71e345be23b1..77ac9d4b3854 100644 --- a/packages/server/api/src/app/user-invitations/user-invitation.service.ts +++ b/packages/server/api/src/app/user-invitations/user-invitation.service.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, apId, assertEqual, assertNotNullOrUndefined, ErrorCode, isNil, SeekPage, spreadIfDefined } from '@activepieces/core-utils' -import { InvitationStatus, InvitationType, PlatformRole, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' +import { InvitationStatus, InvitationType, PlatformRole, TelemetryEventName, UserInvitation, UserInvitationWithLink } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' import { EntityManager, IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm' @@ -13,6 +13,8 @@ import { domainHelper } from '../helper/domain-helper' import { JwtAudience, jwtUtils } from '../helper/jwt-utils' import { buildPaginator } from '../helper/pagination/build-paginator' import { paginationHelper } from '../helper/pagination/pagination-utils' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { telemetry } from '../helper/telemetry.utils' import { platformService } from '../platform/platform.service' import { projectService } from '../project/project-service' import { userService } from '../user/user-service' @@ -235,9 +237,22 @@ export const userInvitationsService = (log: FastifyBaseLogger) => ({ }, async accept({ invitationId, platformId }: AcceptParams): Promise { const invitation = await this.getOneOrThrow({ id: invitationId, platformId }) + const firstAcceptance = invitation.status !== InvitationStatus.ACCEPTED await repo().update(invitation.id, { status: InvitationStatus.ACCEPTED, }) + if (firstAcceptance) { + rejectedPromiseHandler(telemetry(log).trackPlatform({ + platformId, + event: { + name: TelemetryEventName.INVITE_ACCEPTED, + payload: { + platformId, + type: invitation.type === InvitationType.PLATFORM ? 'platform' : 'project', + }, + }, + }), log) + } const identity = await userIdentityService(log).getIdentityByEmail(invitation.email) if (isNil(identity)) { return { registered: false } diff --git a/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts index 62bb065e7982..439b556767d8 100644 --- a/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts +++ b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts @@ -18,6 +18,8 @@ vi.mock('../../../../src/app/helper/telemetry.utils', async (importOriginal) => trackProject: vi.fn().mockResolvedValue(undefined), trackIdentity: vi.fn().mockResolvedValue(undefined), trackUser: vi.fn().mockResolvedValue(undefined), + aliasIdentity: vi.fn().mockResolvedValue(undefined), + identifyPlatformGroup: vi.fn().mockResolvedValue(undefined), }), } }) diff --git a/packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts b/packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts new file mode 100644 index 000000000000..c9214add7bff --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/passwordless-auth-telemetry.test.ts @@ -0,0 +1,125 @@ +import { TelemetryEventName } from '@activepieces/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockTrackUser, + mockTrackIdentity, + mockGetIdentityByEmail, + mockGetOneByIdentityAndPlatform, + mockSelectCloudSignInPlatformId, +} = vi.hoisted(() => ({ + mockTrackUser: vi.fn().mockResolvedValue(undefined), + mockTrackIdentity: vi.fn().mockResolvedValue(undefined), + mockGetIdentityByEmail: vi.fn(), + mockGetOneByIdentityAndPlatform: vi.fn(), + mockSelectCloudSignInPlatformId: vi.fn(), +})) + +vi.mock('../../../../src/app/authentication/lib/turnstile', () => ({ + turnstile: { assertSolved: vi.fn().mockResolvedValue(undefined) }, +})) + +vi.mock('../../../../src/app/authentication/lib/zerobounce', () => ({ + zerobounce: { maySignUp: vi.fn().mockResolvedValue(true) }, +})) + +vi.mock('../../../../src/app/authentication/otp/otp-service', () => ({ + otpService: vi.fn(() => ({ createAndSend: vi.fn().mockResolvedValue(undefined) })), +})) + +vi.mock('../../../../src/app/authentication/user-identity/user-identity-service', () => ({ + userIdentityService: vi.fn(() => ({ + getIdentityByEmail: mockGetIdentityByEmail, + create: vi.fn(), + })), +})) + +vi.mock('../../../../src/app/user/user-service', () => ({ + userService: vi.fn(() => ({ getOneByIdentityAndPlatform: mockGetOneByIdentityAndPlatform })), +})) + +vi.mock('../../../../src/app/authentication/authentication.service', () => ({ + authenticationService: vi.fn(() => ({ selectCloudSignInPlatformId: mockSelectCloudSignInPlatformId })), +})) + +vi.mock('../../../../src/app/helper/telemetry.utils', () => ({ + telemetry: vi.fn(() => ({ trackUser: mockTrackUser, trackIdentity: mockTrackIdentity })), +})) + +import { passwordlessAuthService } from '../../../../src/app/authentication/passwordless-auth.service' + +const mockLog = { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), + child: vi.fn(), fatal: vi.fn(), trace: vi.fn(), silent: vi.fn(), level: 'info', +} as unknown as Parameters[0] + +const EMAIL = 'someone@example.com' +const IDENTITY = { id: 'identity-1', email: EMAIL } + +async function requestCodeAndFlush(): Promise { + await passwordlessAuthService(mockLog).requestCode({ + email: EMAIL, + platformId: null, + captchaToken: 'token', + remoteIp: '127.0.0.1', + }) + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('passwordless auth telemetry distinct id', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIdentityByEmail.mockResolvedValue(IDENTITY) + mockSelectCloudSignInPlatformId.mockResolvedValue('platform-1') + }) + + it('keys the event on the identity id while no user exists yet', async () => { + mockGetOneByIdentityAndPlatform.mockResolvedValue(null) + + await requestCodeAndFlush() + + expect(mockTrackUser).not.toHaveBeenCalled() + expect(mockTrackIdentity).toHaveBeenCalledWith({ + identityId: 'identity-1', + platformId: 'platform-1', + event: { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: false }, + }, + }) + }) + + it('switches to the user id as soon as a user exists for identity and platform', async () => { + mockGetOneByIdentityAndPlatform.mockResolvedValue({ id: 'user-1' }) + + await requestCodeAndFlush() + + expect(mockTrackIdentity).not.toHaveBeenCalled() + expect(mockTrackUser).toHaveBeenCalledWith({ + userId: 'user-1', + platformId: 'platform-1', + event: { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: false }, + }, + }) + }) + + it('keys the event on the identity id when no platform can be resolved', async () => { + mockSelectCloudSignInPlatformId.mockResolvedValue(null) + mockGetOneByIdentityAndPlatform.mockResolvedValue({ id: 'user-1' }) + + await requestCodeAndFlush() + + expect(mockGetOneByIdentityAndPlatform).not.toHaveBeenCalled() + expect(mockTrackUser).not.toHaveBeenCalled() + expect(mockTrackIdentity).toHaveBeenCalledWith({ + identityId: 'identity-1', + platformId: null, + event: { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: false }, + }, + }) + }) +}) diff --git a/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts b/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts index 4517f0d0431f..0d1c0bcd4017 100644 --- a/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts +++ b/packages/server/api/test/unit/app/ee/platform-plan/free-legacy-comp.test.ts @@ -30,6 +30,12 @@ vi.mock('../../../../../src/app/ee/platform/platform-plan/platform-plan.service' }), })) +vi.mock('../../../../../src/app/ee/platform/platform-plan/platform-plan-telemetry', () => ({ + platformPlanTelemetry: () => ({ + onEntitlementsRefreshed: vi.fn(), + }), +})) + vi.mock('../../../../../src/app/database/redis-connections', () => ({ distributedLock: () => ({ runExclusive: async ({ fn }: { fn: () => Promise }) => fn(), diff --git a/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts b/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts index d97f963169ae..594eebb3f5f9 100644 --- a/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts +++ b/packages/server/api/test/unit/app/helper/telemetry-cloud-only-events.test.ts @@ -1,14 +1,15 @@ -import { ApEdition, TelemetryEventName } from '@activepieces/shared' +import { ApEdition, TelemetryEvent, TelemetryEventName } from '@activepieces/shared' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCapture, mockGetEdition, mockIsProductTelemetryEnabled } = vi.hoisted(() => ({ - mockCapture: vi.fn(), +const { mockCapture, mockAlias, mockGetEdition, mockIsProductTelemetryEnabled } = vi.hoisted(() => ({ + mockCapture: vi.fn<(message: CapturedMessage) => void>(), + mockAlias: vi.fn(), mockGetEdition: vi.fn(), mockIsProductTelemetryEnabled: vi.fn().mockResolvedValue(true), })) vi.mock('posthog-node', () => ({ - PostHog: vi.fn(() => ({ capture: mockCapture, identify: vi.fn(), flush: vi.fn(), shutdown: vi.fn() })), + PostHog: vi.fn(() => ({ capture: mockCapture, alias: mockAlias, identify: vi.fn(), groupIdentify: vi.fn(), flush: vi.fn(), shutdown: vi.fn() })), })) vi.mock('../../../../src/app/platform/platform-configuration.service', () => ({ @@ -18,11 +19,11 @@ vi.mock('../../../../src/app/platform/platform-configuration.service', () => ({ })) vi.mock('../../../../src/app/platform/platform.service', () => ({ - platformService: vi.fn(() => ({ getOneOrThrow: vi.fn() })), + platformService: vi.fn(() => ({ getOneOrThrow: vi.fn().mockResolvedValue({ ownerId: 'owner-1' }) })), })) vi.mock('../../../../src/app/project/project-service', () => ({ - projectService: vi.fn(() => ({ getOne: vi.fn() })), + projectService: vi.fn(() => ({ getOne: vi.fn().mockResolvedValue({ id: 'project-1', ownerId: 'owner-1', platformId: 'platform-1' }) })), })) vi.mock('../../../../src/app/helper/system/system', () => ({ @@ -60,9 +61,35 @@ const trackFlowCreated = (edition: ApEdition) => { }) } +const trackPlanUpgraded = (edition: ApEdition) => { + mockGetEdition.mockReturnValue(edition) + return telemetry(mockLog).trackPlatform({ + platformId: 'platform-1', + event: { + name: TelemetryEventName.PLAN_CHANGED, + payload: { platformId: 'platform-1', plan: 'plus', previousPlan: 'free' }, + }, + }) +} + +const CLOUD_ONLY_BILLING_AND_ONBOARDING_EVENTS: TelemetryEvent[] = [ + { name: TelemetryEventName.ONBOARDING_COMPLETED, payload: { userId: 'user-1', platformId: 'platform-1' } }, + { name: TelemetryEventName.CHECKOUT_STARTED, payload: { platformId: 'platform-1', plan: 'plus' } }, + { name: TelemetryEventName.PLAN_CHANGED, payload: { platformId: 'platform-1', plan: 'plus', previousPlan: 'free' } }, + { name: TelemetryEventName.PLAN_CANCELLED, payload: { platformId: 'platform-1', plan: 'plus' } }, + { name: TelemetryEventName.PLAN_REACTIVATED, payload: { platformId: 'platform-1', plan: 'plus' } }, + { name: TelemetryEventName.TRIAL_STARTED, payload: { platformId: 'platform-1', plan: 'plus', trialEndsAt: '2030-01-01T00:00:00.000Z' } }, + { name: TelemetryEventName.SALES_HANDOFF_CLICKED, payload: { surface: 'plan-selector' } }, +] + +const capturedEventNames = (): string[] => mockCapture.mock.calls.map(([message]) => message.event) + +const capturedDistinctIds = (): string[] => mockCapture.mock.calls.map(([message]) => message.distinctId) + describe('cloud-only telemetry events', () => { beforeEach(() => { mockCapture.mockClear() + mockAlias.mockClear() mockIsProductTelemetryEnabled.mockResolvedValue(true) }) @@ -70,7 +97,7 @@ describe('cloud-only telemetry events', () => { await trackSignedIn(ApEdition.CLOUD) expect(mockCapture).toHaveBeenCalledTimes(1) - expect(mockCapture.mock.calls[0][0].event).toBe(TelemetryEventName.SIGNED_IN) + expect(capturedEventNames()).toEqual([TelemetryEventName.SIGNED_IN]) }) it.each([ApEdition.COMMUNITY, ApEdition.ENTERPRISE])( @@ -82,13 +109,28 @@ describe('cloud-only telemetry events', () => { }, ) + it('should send a billing event on cloud attributed to the platform owner', async () => { + await trackPlanUpgraded(ApEdition.CLOUD) + + expect(capturedEventNames()).toEqual([TelemetryEventName.PLAN_CHANGED]) + expect(capturedDistinctIds()).toEqual(['owner-1']) + }) + + it.each(CLOUD_ONLY_BILLING_AND_ONBOARDING_EVENTS)('should treat $name as cloud-only', async (event) => { + mockGetEdition.mockReturnValue(ApEdition.ENTERPRISE) + + await telemetry(mockLog).trackUser({ userId: 'user-1', platformId: 'platform-1', event }) + + expect(mockCapture).not.toHaveBeenCalled() + }) + it.each([ApEdition.CLOUD, ApEdition.COMMUNITY, ApEdition.ENTERPRISE])( 'should still send a product event on %s', async (edition) => { await trackFlowCreated(edition) expect(mockCapture).toHaveBeenCalledTimes(1) - expect(mockCapture.mock.calls[0][0].event).toBe(TelemetryEventName.CREATED_FLOW) + expect(capturedEventNames()).toEqual([TelemetryEventName.CREATED_FLOW]) }, ) @@ -99,4 +141,76 @@ describe('cloud-only telemetry events', () => { expect(mockCapture).not.toHaveBeenCalled() }) + + it('should stamp every event with the deployment kind', async () => { + await trackFlowCreated(ApEdition.CLOUD) + + expect(mockCapture.mock.calls[0][0].properties.deployment).toBe('cloud') + }) +}) + +describe('identity-level tracking', () => { + beforeEach(() => { + mockCapture.mockClear() + mockAlias.mockClear() + mockIsProductTelemetryEnabled.mockResolvedValue(true) + }) + + it('should send platform-less identity events with the identity id as distinct_id', async () => { + mockGetEdition.mockReturnValue(ApEdition.CLOUD) + + await telemetry(mockLog).trackIdentity({ + identityId: 'identity-1', + platformId: null, + event: { name: TelemetryEventName.EMAIL_CODE_REQUESTED, payload: { isNewIdentity: true } }, + }) + + expect(capturedDistinctIds()).toEqual(['identity-1']) + }) + + it('should keep the identity id as distinct_id when a platform is known', async () => { + mockGetEdition.mockReturnValue(ApEdition.CLOUD) + + await telemetry(mockLog).trackIdentity({ + identityId: 'identity-1', + platformId: 'platform-1', + event: { name: TelemetryEventName.EMAIL_CODE_VERIFIED, payload: { needsNameStep: false } }, + }) + + expect(capturedDistinctIds()).toEqual(['identity-1']) + }) + + it('should drop platform-less identity events outside cloud', async () => { + mockGetEdition.mockReturnValue(ApEdition.ENTERPRISE) + + await telemetry(mockLog).trackIdentity({ + identityId: 'identity-1', + platformId: null, + event: { name: TelemetryEventName.EMAIL_CODE_REQUESTED, payload: { isNewIdentity: true } }, + }) + + expect(mockCapture).not.toHaveBeenCalled() + }) + + it('should alias the identity onto the user on cloud', async () => { + mockGetEdition.mockReturnValue(ApEdition.CLOUD) + + await telemetry(mockLog).aliasIdentity({ identityId: 'identity-1', userId: 'user-1', platformId: 'platform-1' }) + + expect(mockAlias).toHaveBeenCalledWith({ distinctId: 'user-1', alias: 'identity-1' }) + }) + + it('should not alias outside cloud', async () => { + mockGetEdition.mockReturnValue(ApEdition.ENTERPRISE) + + await telemetry(mockLog).aliasIdentity({ identityId: 'identity-1', userId: 'user-1', platformId: 'platform-1' }) + + expect(mockAlias).not.toHaveBeenCalled() + }) }) + +type CapturedMessage = { + event: string + distinctId: string + properties: Record +} diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 0a5a8d089e24..49b6e8b3c3d0 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2881,6 +2881,15 @@ "Searched the step picker": "Searched the step picker", "MCP server connected": "MCP server connected", "MCP tool called": "MCP tool called", + "Onboarding completed": "Onboarding completed", + "Invitation sent": "Invitation sent", + "Invitation accepted": "Invitation accepted", + "Checkout started": "Checkout started", + "Plan changed": "Plan changed", + "Plan cancelled": "Plan cancelled", + "Plan reactivated": "Plan reactivated", + "Trial started": "Trial started", + "Contacted sales": "Contacted sales", "Accounts and sign-in": "Accounts and sign-in", "Emailed sign-in codes": "Emailed sign-in codes", "Flows and the builder": "Flows and the builder", diff --git a/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx b/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx index 45b2bf49d19c..f829eff9906b 100644 --- a/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx +++ b/packages/web/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx @@ -82,7 +82,6 @@ const PublishFlowReminderWidget = () => { const { mutateAsync: publish } = flowHooks.useChangeFlowStatus({ flowId: flow.id, change: 'publish', - requiresApproval, onSuccess: (updatedFlow: PopulatedFlow) => { setFlow(updatedFlow); setVersion(updatedFlow.version); diff --git a/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts b/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts index cae959f9b881..92fce54d31da 100644 --- a/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts +++ b/packages/web/src/app/routes/platform/infra/configurations/tracked-events-catalog.ts @@ -3,7 +3,14 @@ import { TelemetryEventName, } from '@activepieces/shared'; import { t } from 'i18next'; -import { Layers, LucideIcon, Mail, User, Workflow } from 'lucide-react'; +import { + CreditCard, + Layers, + LucideIcon, + Mail, + User, + Workflow, +} from 'lucide-react'; const buildEventLabels = (): Record => ({ [TelemetryEventName.SIGNED_UP]: { @@ -86,6 +93,42 @@ const buildEventLabels = (): Record => ({ group: 'mcp', label: t('MCP tool called'), }, + [TelemetryEventName.ONBOARDING_COMPLETED]: { + group: 'accounts', + label: t('Onboarding completed'), + }, + [TelemetryEventName.INVITE_SENT]: { + group: 'accounts', + label: t('Invitation sent'), + }, + [TelemetryEventName.INVITE_ACCEPTED]: { + group: 'accounts', + label: t('Invitation accepted'), + }, + [TelemetryEventName.CHECKOUT_STARTED]: { + group: 'billing', + label: t('Checkout started'), + }, + [TelemetryEventName.PLAN_CHANGED]: { + group: 'billing', + label: t('Plan changed'), + }, + [TelemetryEventName.PLAN_CANCELLED]: { + group: 'billing', + label: t('Plan cancelled'), + }, + [TelemetryEventName.PLAN_REACTIVATED]: { + group: 'billing', + label: t('Plan reactivated'), + }, + [TelemetryEventName.TRIAL_STARTED]: { + group: 'billing', + label: t('Trial started'), + }, + [TelemetryEventName.SALES_HANDOFF_CLICKED]: { + group: 'billing', + label: t('Contacted sales'), + }, }); const buildGroups = (): TrackedEventGroup[] => { @@ -95,6 +138,7 @@ const buildGroups = (): TrackedEventGroup[] => { { id: 'emailCodes', title: t('Emailed sign-in codes'), icon: Mail }, { id: 'flows', title: t('Flows and the builder'), icon: Workflow }, { id: 'mcp', title: t('MCP'), icon: Layers }, + { id: 'billing', title: t('Billing'), icon: CreditCard }, ]; return definitions .map((definition) => ({ @@ -112,7 +156,12 @@ const buildGroups = (): TrackedEventGroup[] => { export const trackedEventsCatalog = { buildEventLabels, buildGroups }; -export type TrackedEventGroupId = 'accounts' | 'emailCodes' | 'flows' | 'mcp'; +export type TrackedEventGroupId = + | 'accounts' + | 'emailCodes' + | 'flows' + | 'mcp' + | 'billing'; export type TrackedEvent = { group: TrackedEventGroupId; diff --git a/packages/web/src/components/providers/telemetry-provider.tsx b/packages/web/src/components/providers/telemetry-provider.tsx index e7416f675bd4..763581d17605 100644 --- a/packages/web/src/components/providers/telemetry-provider.tsx +++ b/packages/web/src/components/providers/telemetry-provider.tsx @@ -1,7 +1,9 @@ -import { isNil } from '@activepieces/core-utils'; +import { isNil, tryCatchSync } from '@activepieces/core-utils'; import { ApEdition, + ApEnvironment, ApFlagId, + DeploymentKind, isCloudOnlyTelemetryEvent, pickTelemetryPii, TelemetryEvent, @@ -30,13 +32,14 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { const { data: flagCurrentVersion } = flagsHooks.useFlag( ApFlagId.CURRENT_VERSION, ); - const { data: flagEnvironment } = flagsHooks.useFlag( + const { data: flagEnvironment } = flagsHooks.useFlag( ApFlagId.ENVIRONMENT, ); const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); const { embedState } = useEmbedding(); const isCloud = edition === ApEdition.CLOUD; + const isDevEnvironment = flagEnvironment === ApEnvironment.DEVELOPMENT; const isPreLoginCloudFunnel = isNil(currentUser) && isCloud && @@ -44,9 +47,14 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { const isSignedInWithAnalyticsOn = !isNil(currentUser) && (isCloud || configuration?.isProductTelemetryEnabled === true); + const devTelemetryAllowed = + isPosthogDevOptIn() || (!isRunningCloudInDevMode && !isDevEnvironment); const telemetryEnabled = - (isPreLoginCloudFunnel || isSignedInWithAnalyticsOn) && - !isRunningCloudInDevMode; + (isPreLoginCloudFunnel || isSignedInWithAnalyticsOn) && devTelemetryAllowed; + const deployment = resolveDeploymentKind({ + edition, + environment: flagEnvironment, + }); const posthogInitialized = useRef(false); @@ -96,12 +104,12 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { }); // Tag events so the shared project separates product from marketing traffic. - posthog.register({ source_site: 'product' }); + posthog.register({ source_site: 'product', deployment }); if (isCloud && isInRecordingSample(posthog.get_distinct_id())) { posthog.startSessionRecording(); } - }, [telemetryEnabled, embedState.isEmbedded, edition, isCloud]); + }, [telemetryEnabled, embedState.isEmbedded, edition, isCloud, deployment]); useEffect(() => { if (!posthogInitialized.current) { @@ -111,6 +119,7 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { activepiecesEdition: edition ?? ApEdition.COMMUNITY, activepiecesVersion: flagCurrentVersion ?? UNKNOWN_FLAG_VALUE, activepiecesEnvironment: flagEnvironment ?? UNKNOWN_FLAG_VALUE, + deployment, }); }, [ telemetryEnabled, @@ -118,6 +127,7 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { embedState.isEmbedded, flagCurrentVersion, flagEnvironment, + deployment, ]); useEffect(() => { @@ -153,6 +163,7 @@ const TelemetryProvider = ({ children }: TelemetryProviderProps) => { }), activepiecesVersion: currentVersion, activepiecesEnvironment: environment, + deployment, }); if (currentUser.platformId) { @@ -181,6 +192,8 @@ const UNKNOWN_FLAG_VALUE = '0.0.0'; const RECORDING_SAMPLE_RATE = 0.1; +const POSTHOG_DEV_OPT_IN_KEY = 'ap_posthog_dev'; + function isInRecordingSample(distinctId: string): boolean { let hash = 5381; for (let i = 0; i < distinctId.length; i++) { @@ -189,6 +202,29 @@ function isInRecordingSample(distinctId: string): boolean { return (hash >>> 0) / 0xffffffff < RECORDING_SAMPLE_RATE; } +function isPosthogDevOptIn(): boolean { + const { data } = tryCatchSync( + () => localStorage.getItem(POSTHOG_DEV_OPT_IN_KEY) === '1', + ); + return data === true; +} + +function resolveDeploymentKind({ + edition, + environment, +}: { + edition: ApEdition | null | undefined; + environment: ApEnvironment | null | undefined; +}): DeploymentKind { + if (edition === ApEdition.CLOUD) { + return DeploymentKind.CLOUD; + } + if (environment === ApEnvironment.DEVELOPMENT) { + return DeploymentKind.DEV; + } + return DeploymentKind.SELF_HOSTED; +} + interface TelemetryContextType { capture: (event: TelemetryEvent) => void; } diff --git a/packages/web/src/features/billing/components/plan-selector-utils.ts b/packages/web/src/features/billing/components/plan-selector-utils.ts index b78022472fcd..500147584a4a 100644 --- a/packages/web/src/features/billing/components/plan-selector-utils.ts +++ b/packages/web/src/features/billing/components/plan-selector-utils.ts @@ -4,7 +4,7 @@ import { t } from 'i18next'; const FREE_PLAN_ID = 'free'; const ANNUAL_INTERVAL = 'year'; -const SALES_URL = 'https://activepieces.com/sales'; +const SALES_URL = 'https://www.activepieces.com/sales?ap_cta=app_plan_selector'; function stripPlanInterval(name: string): string { return name.replace(/\s*\((annual|monthly|yearly)\)\s*/i, '').trim(); diff --git a/packages/web/src/features/billing/components/plan-selector.tsx b/packages/web/src/features/billing/components/plan-selector.tsx index 5dd6b6bcdc99..a41bd6e1771d 100644 --- a/packages/web/src/features/billing/components/plan-selector.tsx +++ b/packages/web/src/features/billing/components/plan-selector.tsx @@ -1,9 +1,10 @@ import { isNil } from '@activepieces/core-utils'; -import { PurchasablePlan } from '@activepieces/shared'; +import { PurchasablePlan, TelemetryEventName } from '@activepieces/shared'; import { t } from 'i18next'; import { Check, Info } from 'lucide-react'; import { useState } from 'react'; +import { useTelemetry } from '@/components/providers/telemetry-provider'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -350,6 +351,7 @@ function PlanCta({ onKeepPlan, onDowngrade, }: PlanCtaProps) { + const { capture } = useTelemetry(); if (isEnterprise) { return (