diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 0a0b636998..ebd3bc2570 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -124,7 +124,6 @@ export class InternalHooks implements IInternalHooksClass { return this.telemetry.track( 'User responded to personalization questions', personalizationSurveyData, - { withPostHog: true }, ); } @@ -190,21 +189,17 @@ export class InternalHooks implements IInternalHooksClass { workflowName: workflow.name, }, }), - this.telemetry.track( - 'User saved workflow', - { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - notes_count_overlapping: overlappingCount, - notes_count_non_overlapping: notesCount - overlappingCount, - version_cli: N8N_VERSION, - num_tags: workflow.tags?.length ?? 0, - public_api: publicApi, - sharing_role: userRole, - }, - { withPostHog: true }, - ), + this.telemetry.track('User saved workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + notes_count_overlapping: overlappingCount, + notes_count_non_overlapping: notesCount - overlappingCount, + version_cli: N8N_VERSION, + num_tags: workflow.tags?.length ?? 0, + public_api: publicApi, + sharing_role: userRole, + }), ]); } @@ -415,11 +410,7 @@ export class InternalHooks implements IInternalHooksClass { node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], }; - promises.push( - this.telemetry.track('Manual node exec finished', telemetryPayload, { - withPostHog: true, - }), - ); + promises.push(this.telemetry.track('Manual node exec finished', telemetryPayload)); } else { nodeGraphResult.webhookNodeNames.forEach((name: string) => { const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] @@ -432,9 +423,7 @@ export class InternalHooks implements IInternalHooksClass { }); promises.push( - this.telemetry.track('Manual workflow exec finished', manualExecEventProperties, { - withPostHog: true, - }), + this.telemetry.track('Manual workflow exec finished', manualExecEventProperties), ); } } @@ -484,7 +473,7 @@ export class InternalHooks implements IInternalHooksClass { user_id_list: userList, }; - return this.telemetry.track('User updated workflow sharing', properties, { withPostHog: true }); + return this.telemetry.track('User updated workflow sharing', properties); } async onN8nStop(): Promise { @@ -1017,7 +1006,7 @@ export class InternalHooks implements IInternalHooksClass { user_id: string; workflow_id: string; }): Promise { - return this.telemetry.track('Workflow first prod success', data, { withPostHog: true }); + return this.telemetry.track('Workflow first prod success', data); } async onFirstWorkflowDataLoad(data: { @@ -1028,7 +1017,7 @@ export class InternalHooks implements IInternalHooksClass { credential_type?: string; credential_id?: string; }): Promise { - return this.telemetry.track('Workflow first data fetched', data, { withPostHog: true }); + return this.telemetry.track('Workflow first data fetched', data); } /** diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 28dd62a141..b682e1d8b5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -490,7 +490,7 @@ export class Server extends AbstractServer { const controllers: object[] = [ new EventBusController(), new AuthController({ config, internalHooks, repositories, logger, postHog }), - new OwnerController({ config, internalHooks, repositories, logger }), + new OwnerController({ config, internalHooks, repositories, logger, postHog }), new MeController({ externalHooks, internalHooks, repositories, logger }), new NodeTypesController({ config, nodeTypes }), new PasswordResetController({ diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index d24fd38c16..567777a39b 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -762,7 +762,7 @@ export const schema = { externalFrontendHooksUrls: { doc: 'URLs to external frontend hooks files, ; separated', format: String, - default: 'https://public.n8n.cloud/posthog-hooks.js', + default: '', env: 'EXTERNAL_FRONTEND_HOOKS_URLS', }, diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 4875e10ec3..c09b4c87a5 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -6,6 +6,7 @@ import { hashPassword, sanitizeUser, validatePassword, + withFeatureFlags, } from '@/UserManagement/UserManagementHelper'; import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; @@ -14,6 +15,7 @@ import type { Config } from '@/config'; import { OwnerRequest } from '@/requests'; import type { IDatabaseCollections, IInternalHooksClass } from '@/Interfaces'; import type { SettingsRepository, UserRepository } from '@db/repositories'; +import type { PostHogClient } from '@/posthog'; @Authorized(['global', 'owner']) @RestController('/owner') @@ -28,22 +30,27 @@ export class OwnerController { private readonly settingsRepository: SettingsRepository; + private readonly postHog?: PostHogClient; + constructor({ config, logger, internalHooks, repositories, + postHog, }: { config: Config; logger: ILogger; internalHooks: IInternalHooksClass; repositories: Pick; + postHog?: PostHogClient; }) { this.config = config; this.logger = logger; this.internalHooks = internalHooks; this.userRepository = repositories.User; this.settingsRepository = repositories.Settings; + this.postHog = postHog; } /** @@ -122,7 +129,7 @@ export class OwnerController { void this.internalHooks.onInstanceOwnerSetup({ user_id: userId }); - return sanitizeUser(owner); + return withFeatureFlags(this.postHog, sanitizeUser(owner)); } @Post('/dismiss-banner') diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 7e78ca996a..75b87a2c6b 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -95,15 +95,11 @@ export class Telemetry { return sum > 0; }) .map(async (workflowId) => { - const promise = this.track( - 'Workflow execution count', - { - event_version: '2', - workflow_id: workflowId, - ...this.executionCountsBuffer[workflowId], - }, - { withPostHog: true }, - ); + const promise = this.track('Workflow execution count', { + event_version: '2', + workflow_id: workflowId, + ...this.executionCountsBuffer[workflowId], + }); return promise; }); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 2705992a8a..02814e992f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -78,6 +78,12 @@ declare global { reset?(resetDeviceId?: boolean): void; onFeatureFlags?(callback: (keys: string[], map: FeatureFlags) => void): void; reloadFeatureFlags?(): void; + capture?(event: string, properties: IDataObject): void; + register?(metadata: IDataObject): void; + people?: { + set?(metadata: IDataObject): void; + }; + debug?(): void; }; analytics?: { track(event: string, proeprties?: ITelemetryTrackProperties): void; diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 74b717f280..6f6d9bd867 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -28,7 +28,7 @@ export async function logout(context: IRestApiContext): Promise { export async function setupOwner( context: IRestApiContext, params: { firstName: string; lastName: string; email: string; password: string }, -): Promise { +): Promise { return makeRestApiRequest(context, 'POST', '/owner/setup', params as unknown as IDataObject); } diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index c47e40b3b7..b3566d424c 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -138,6 +138,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useRootStore } from '@/stores/n8nRoot.store'; import { useUsersStore } from '@/stores/users.store'; import { createEventBus } from 'n8n-design-system/utils'; +import { usePostHog } from '@/stores'; export default defineComponent({ name: 'PersonalizationModal', @@ -166,7 +167,7 @@ export default defineComponent({ }; }, computed: { - ...mapStores(useRootStore, useSettingsStore, useUIStore, useUsersStore), + ...mapStores(useRootStore, useSettingsStore, useUIStore, useUsersStore, usePostHog), survey() { const survey: IFormInputs = [ { @@ -645,6 +646,8 @@ export default defineComponent({ await this.usersStore.submitPersonalizationSurvey(survey as IPersonalizationLatestVersion); + this.posthogStore.setMetadata(survey, 'user'); + if (Object.keys(values).length === 0) { this.closeDialog(); } diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index d25fe52dc4..054a8a7f2b 100644 --- a/packages/editor-ui/src/plugins/telemetry/index.ts +++ b/packages/editor-ui/src/plugins/telemetry/index.ts @@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useRootStore } from '@/stores/n8nRoot.store'; import { useTelemetryStore } from '@/stores/telemetry.store'; import { SLACK_NODE_TYPE } from '@/constants'; +import { usePostHog } from '@/stores/posthog.store'; export class Telemetry { constructor( @@ -72,7 +73,11 @@ export class Telemetry { } } - track(event: string, properties?: ITelemetryTrackProperties) { + track( + event: string, + properties?: ITelemetryTrackProperties, + { withPostHog } = { withPostHog: false }, + ) { if (!this.rudderStack) return; const updatedProperties = { @@ -81,6 +86,10 @@ export class Telemetry { }; this.rudderStack.track(event, updatedProperties); + + if (withPostHog) { + usePostHog().capture(event, updatedProperties); + } } page(route: Route) { @@ -119,7 +128,7 @@ export class Telemetry { properties.session_id = useRootStore().sessionId; switch (event) { case 'askAi.generationFinished': - this.track('Ai code generation finished', properties); + this.track('Ai code generation finished', properties, { withPostHog: true }); case 'ask.generationClicked': this.track('User clicked on generate code button', properties); default: @@ -189,7 +198,7 @@ export class Telemetry { this.track('User viewed node category', properties); break; case 'nodeView.addNodeButton': - this.track('User added node to workflow canvas', properties); + this.track('User added node to workflow canvas', properties, { withPostHog: true }); break; case 'nodeView.addSticky': this.track('User inserted workflow note', properties); diff --git a/packages/editor-ui/src/stores/posthog.store.ts b/packages/editor-ui/src/stores/posthog.store.ts index 5bc219f0f1..ffe95ed53a 100644 --- a/packages/editor-ui/src/stores/posthog.store.ts +++ b/packages/editor-ui/src/stores/posthog.store.ts @@ -4,7 +4,7 @@ import { defineStore } from 'pinia'; import { useUsersStore } from '@/stores/users.store'; import { useRootStore } from '@/stores/n8nRoot.store'; import { useSettingsStore } from '@/stores/settings.store'; -import type { FeatureFlags } from 'n8n-workflow'; +import type { FeatureFlags, IDataObject } from 'n8n-workflow'; import { EXPERIMENTS_TO_TRACK, LOCAL_STORAGE_EXPERIMENT_OVERRIDES } from '@/constants'; import { useTelemetryStore } from './telemetry.store'; import { debounce } from 'lodash-es'; @@ -161,10 +161,29 @@ export const usePostHog = defineStore('posthog', () => { trackedDemoExp.value[name] = variant; }; + const capture = (event: string, properties: IDataObject) => { + if (typeof window.posthog?.capture === 'function') { + window.posthog.capture(event, properties); + } + }; + + const setMetadata = (metadata: IDataObject, target: 'user' | 'events') => { + if (typeof window.posthog?.people?.set !== 'function') return; + if (typeof window.posthog?.register !== 'function') return; + + if (target === 'user') { + window.posthog?.people?.set(metadata); + } else if (target === 'events') { + window.posthog?.register(metadata); + } + }; + return { init, isVariantEnabled, getVariant, reset, + capture, + setMetadata, }; }); diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 3d49c649dd..6b9d551e79 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -200,6 +200,7 @@ export const useUsersStore = defineStore(STORES.USERS, { this.addUsers([user]); this.currentUserId = user.id; settingsStore.stopShowingSetupPage(); + usePostHog().init(user.featureFlags); } }, async validateSignupToken(params: { @@ -221,9 +222,8 @@ export const useUsersStore = defineStore(STORES.USERS, { if (user) { this.addUsers([user]); this.currentUserId = user.id; + usePostHog().init(user.featureFlags); } - - usePostHog().init(user.featureFlags); }, async sendForgotPasswordEmail(params: { email: string }): Promise { const rootStore = useRootStore(); diff --git a/packages/editor-ui/src/views/TemplatesCollectionView.vue b/packages/editor-ui/src/views/TemplatesCollectionView.vue index 45acfe2626..ab2b617cfe 100644 --- a/packages/editor-ui/src/views/TemplatesCollectionView.vue +++ b/packages/editor-ui/src/views/TemplatesCollectionView.vue @@ -68,6 +68,7 @@ import type { import { setPageTitle } from '@/utils'; import { VIEWS } from '@/constants'; import { useTemplatesStore } from '@/stores/templates.store'; +import { usePostHog } from '@/stores/posthog.store'; export default defineComponent({ name: 'TemplatesCollectionView', @@ -78,7 +79,7 @@ export default defineComponent({ TemplatesView, }, computed: { - ...mapStores(useTemplatesStore), + ...mapStores(useTemplatesStore, usePostHog), collection(): null | ITemplatesCollectionFull { return this.templatesStore.getCollectionById(this.collectionId); }, @@ -122,8 +123,9 @@ export default defineComponent({ source: 'collection', }; void this.$externalHooks().run('templatesCollectionView.onUseWorkflow', telemetryPayload); - this.$telemetry.track('User inserted workflow template', telemetryPayload); - + this.$telemetry.track('User inserted workflow template', telemetryPayload, { + withPostHog: true, + }); this.navigateTo(event, VIEWS.TEMPLATE_IMPORT, id); }, navigateTo(e: MouseEvent, page: string, id: string) { diff --git a/packages/editor-ui/src/views/TemplatesWorkflowView.vue b/packages/editor-ui/src/views/TemplatesWorkflowView.vue index cb45aa60e7..d2761f36e8 100644 --- a/packages/editor-ui/src/views/TemplatesWorkflowView.vue +++ b/packages/editor-ui/src/views/TemplatesWorkflowView.vue @@ -67,6 +67,7 @@ import { workflowHelpers } from '@/mixins/workflowHelpers'; import { setPageTitle } from '@/utils'; import { VIEWS } from '@/constants'; import { useTemplatesStore } from '@/stores/templates.store'; +import { usePostHog } from '@/stores/posthog.store'; export default defineComponent({ name: 'TemplatesWorkflowView', @@ -77,7 +78,7 @@ export default defineComponent({ WorkflowPreview, }, computed: { - ...mapStores(useTemplatesStore), + ...mapStores(useTemplatesStore, usePostHog), template(): ITemplatesWorkflow | ITemplatesWorkflowFull { return this.templatesStore.getTemplateById(this.templateId); }, @@ -101,8 +102,9 @@ export default defineComponent({ }; void this.$externalHooks().run('templatesWorkflowView.openWorkflow', telemetryPayload); - this.$telemetry.track('User inserted workflow template', telemetryPayload); - + this.$telemetry.track('User inserted workflow template', telemetryPayload, { + withPostHog: true, + }); if (e.metaKey || e.ctrlKey) { const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } }); window.open(route.href, '_blank');