diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 58a38d3868..d799e82fdd 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -32,6 +32,7 @@ import { IAbstractEventMessage, FeatureFlags, ExecutionStatus, + ITelemetryTrackProperties, } from 'n8n-workflow'; import { SignInType } from './constants'; import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants'; @@ -67,6 +68,9 @@ declare global { onFeatureFlags?(callback: (keys: string[], map: FeatureFlags) => void): void; reloadFeatureFlags?(): void; }; + analytics?: { + track(event: string, proeprties?: ITelemetryTrackProperties): void; + }; } } diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index 7bb4d22f4a..d6d8e809bd 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -89,6 +89,7 @@ import { INodeParameters, INodeProperties, INodePropertyMode, IParameterLabel } import { BaseTextKey } from '@/plugins/i18n'; import { mapStores } from 'pinia'; import { useNDVStore } from '@/stores/ndv'; +import { useSegment } from '@/stores/segment'; import { externalHooks } from '@/mixins/externalHooks'; export default mixins(showMessage, externalHooks).extend({ @@ -328,7 +329,8 @@ export default mixins(showMessage, externalHooks).extend({ success: true, }); - this.$externalHooks().run('parameterInputFull.mappedData'); + const segment = useSegment(); + segment.track(segment.EVENTS.MAPPED_DATA); } this.forceShowExpression = false; }, 200); diff --git a/packages/editor-ui/src/mixins/pushConnection.ts b/packages/editor-ui/src/mixins/pushConnection.ts index 4511e025ff..d9478184bb 100644 --- a/packages/editor-ui/src/mixins/pushConnection.ts +++ b/packages/editor-ui/src/mixins/pushConnection.ts @@ -33,6 +33,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useCredentialsStore } from '@/stores/credentials'; import { useSettingsStore } from '@/stores/settings'; import { parse } from 'flatted'; +import { useSegment } from '@/stores/segment'; export const pushConnection = mixins( externalHooks, @@ -58,6 +59,7 @@ export const pushConnection = mixins( useUIStore, useWorkflowsStore, useSettingsStore, + useSegment, ), sessionId(): string { return this.rootStore.sessionId; @@ -515,6 +517,9 @@ export const pushConnection = mixins( runDataExecutedStartData: runDataExecuted.data.startData, resultDataError: runDataExecuted.data.resultData.error, }); + if (!runDataExecuted.data.resultData.error) { + this.segmentStore.trackSuccessfulWorkflowExecution(runDataExecuted); + } } else if (receivedData.type === 'executionStarted') { const pushData = receivedData.data; diff --git a/packages/editor-ui/src/stores/nodeTypes.ts b/packages/editor-ui/src/stores/nodeTypes.ts index e4ad1b3d63..d8d63e13d1 100644 --- a/packages/editor-ui/src/stores/nodeTypes.ts +++ b/packages/editor-ui/src/stores/nodeTypes.ts @@ -80,6 +80,11 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, { return !!(nodeType && nodeType.group.includes('trigger')); }; }, + isCoreNodeType() { + return (nodeType: INodeTypeDescription) => { + return nodeType.codex?.categories?.includes('Core Nodes'); + }; + }, visibleNodeTypes(): INodeTypeDescription[] { return this.allLatestNodeTypes.filter((nodeType: INodeTypeDescription) => !nodeType.hidden); }, diff --git a/packages/editor-ui/src/stores/posthog.ts b/packages/editor-ui/src/stores/posthog.ts index 0523a76c44..64be6e7157 100644 --- a/packages/editor-ui/src/stores/posthog.ts +++ b/packages/editor-ui/src/stores/posthog.ts @@ -2,21 +2,25 @@ import { ref, Ref, watch } from 'vue'; import { defineStore } from 'pinia'; import { useUsersStore } from '@/stores/users'; import { useRootStore } from '@/stores/n8nRootStore'; -import { useSettingsStore } from './settings'; +import { useSettingsStore } from '@/stores/settings'; import { FeatureFlags } from 'n8n-workflow'; -import { EXPERIMENTS_TO_TRACK } from '@/constants'; +import { EXPERIMENTS_TO_TRACK, ONBOARDING_EXPERIMENT } from '@/constants'; import { useTelemetryStore } from './telemetry'; -import { runExternalHook } from '@/mixins/externalHooks'; -import { useWebhooksStore } from './webhooks'; +import { useSegment } from './segment'; +import { debounce } from 'lodash-es'; -export const usePostHogStore = defineStore('posthog', () => { +const EVENTS = { + IS_PART_OF_EXPERIMENT: 'User is part of experiment', +}; + +export const usePostHog = defineStore('posthog', () => { const usersStore = useUsersStore(); const settingsStore = useSettingsStore(); const telemetryStore = useTelemetryStore(); const rootStore = useRootStore(); + const segmentStore = useSegment(); const featureFlags: Ref = ref(null); - const initialized: Ref = ref(false); const trackedDemoExp: Ref = ref({}); const reset = () => { @@ -72,54 +76,42 @@ export const usePostHogStore = defineStore('posthog', () => { debug: config.debug, }; - if (evaluatedFeatureFlags) { + window.posthog?.init(config.apiKey, options); + identify(); + + if (evaluatedFeatureFlags && Object.keys(evaluatedFeatureFlags).length) { featureFlags.value = evaluatedFeatureFlags; options.bootstrap = { distinctId, featureFlags: evaluatedFeatureFlags, }; + trackExperiments(evaluatedFeatureFlags); + } else { + // depend on client side evaluation if serverside evaluation fails + window.posthog?.onFeatureFlags?.((keys: string[], map: FeatureFlags) => { + featureFlags.value = map; + trackExperiments(map); + }); } - - window.posthog?.init(config.apiKey, options); - - identify(); - - initialized.value = true; }; - const trackExperiment = (name: string) => { - const curr = featureFlags.value; - const prev = trackedDemoExp.value; + const trackExperiments = debounce((featureFlags: FeatureFlags) => { + EXPERIMENTS_TO_TRACK.forEach((name) => trackExperiment(featureFlags, name)); + }, 2000); - if (!curr || curr[name] === undefined) { - return; - } - - if (curr[name] === prev[name]) { - return; - } - - const variant = curr[name]; - telemetryStore.track('User is part of experiment', { + const trackExperiment = (featureFlags: FeatureFlags, name: string) => { + const variant = featureFlags[name]; + telemetryStore.track(EVENTS.IS_PART_OF_EXPERIMENT, { name, variant, }); trackedDemoExp.value[name] = variant; - runExternalHook('posthog.featureFlagsUpdated', useWebhooksStore(), { - name, - variant, - }); - }; - watch( - () => featureFlags.value, - () => { - setTimeout(() => { - EXPERIMENTS_TO_TRACK.forEach(trackExperiment); - }, 0); - }, - ); + if (name === ONBOARDING_EXPERIMENT.name && variant === ONBOARDING_EXPERIMENT.variant) { + segmentStore.showAppCuesChecklist(); + } + }; return { init, diff --git a/packages/editor-ui/src/stores/segment.ts b/packages/editor-ui/src/stores/segment.ts new file mode 100644 index 0000000000..df90baead7 --- /dev/null +++ b/packages/editor-ui/src/stores/segment.ts @@ -0,0 +1,133 @@ +import { + CODE_NODE_TYPE, + HTTP_REQUEST_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + SCHEDULE_TRIGGER_NODE_TYPE, + SET_NODE_TYPE, + WEBHOOK_NODE_TYPE, +} from '@/constants'; +import { ITelemetryTrackProperties } from 'n8n-workflow'; +import { defineStore } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { INodeTypeDescription, IRun } from 'n8n-workflow'; +import { useWorkflowsStore } from '@/stores/workflows'; +import { useNodeTypesStore } from '@/stores/nodeTypes'; + +const EVENTS = { + SHOW_CHECKLIST: 'Show checklist', + ADDED_MANUAL_TRIGGER: 'User added manual trigger', + ADDED_SCHEDULE_TRIGGER: 'User added schedule trigger', + ADDED_DATA_TRIGGER: 'User added data trigger', + RECEIEVED_MULTIPLE_DATA_ITEMS: 'User received multiple data items', + EXECUTED_MANUAL_TRIGGER: 'User executed manual trigger successfully', + EXECUTED_SCHEDULE_TRIGGER: 'User executed schedule trigger successfully', + EXECUTED_DATA_NODE_TRIGGER: 'User executed data node successfully', + MAPPED_DATA: 'User mapped data', +}; + +export const useSegment = defineStore('segment', () => { + const nodeTypesStore = useNodeTypesStore(); + const workflowsStore = useWorkflowsStore(); + const settingsStore = useSettingsStore(); + + const track = (eventName: string, properties?: ITelemetryTrackProperties) => { + if (settingsStore.telemetry.enabled) { + window.analytics?.track(eventName, properties); + } + }; + + const showAppCuesChecklist = () => { + const isInIframe = window.location !== window.parent.location; + if (isInIframe) { + return; + } + + track(EVENTS.SHOW_CHECKLIST); + }; + + const trackAddedTrigger = (nodeTypeName: string) => { + if (!nodeTypesStore.isTriggerNode(nodeTypeName)) { + return; + } + + if (nodeTypeName === MANUAL_TRIGGER_NODE_TYPE) { + track(EVENTS.ADDED_MANUAL_TRIGGER); + } else if (nodeTypeName === SCHEDULE_TRIGGER_NODE_TYPE) { + track(EVENTS.ADDED_SCHEDULE_TRIGGER); + } else { + track(EVENTS.ADDED_DATA_TRIGGER); + } + }; + + const trackSuccessfulWorkflowExecution = (runData: IRun) => { + const dataNodeTypes: Set = new Set(); + const multipleOutputNodes: Set = new Set(); + let hasManualTrigger = false; + let hasScheduleTrigger = false; + for (const nodeName of Object.keys(runData.data.resultData.runData)) { + const nodeRunData = runData.data.resultData.runData[nodeName]; + const node = workflowsStore.getNodeByName(nodeName); + const nodeTypeName = node ? node.type : 'unknown'; + if (nodeRunData[0].data && nodeRunData[0].data.main.some((out) => out && out?.length > 1)) { + multipleOutputNodes.add(nodeTypeName); + } + if (node && !node.disabled) { + const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); + if (isDataNodeType(nodeType)) { + dataNodeTypes.add(nodeTypeName); + } + if (isManualTriggerNode(nodeType)) { + hasManualTrigger = true; + } + if (isScheduleTriggerNode(nodeType)) { + hasScheduleTrigger = true; + } + } + } + if (multipleOutputNodes.size > 0) { + track(EVENTS.RECEIEVED_MULTIPLE_DATA_ITEMS, { + nodeTypes: Array.from(multipleOutputNodes), + }); + } + if (dataNodeTypes.size > 0) { + track(EVENTS.EXECUTED_DATA_NODE_TRIGGER, { + nodeTypes: Array.from(dataNodeTypes), + }); + } + if (hasManualTrigger) { + track(EVENTS.EXECUTED_MANUAL_TRIGGER); + } + if (hasScheduleTrigger) { + track(EVENTS.EXECUTED_SCHEDULE_TRIGGER); + } + }; + + const isManualTriggerNode = (nodeType: INodeTypeDescription | null): boolean => { + return !!nodeType && nodeType.name === MANUAL_TRIGGER_NODE_TYPE; + }; + + const isScheduleTriggerNode = (nodeType: INodeTypeDescription | null): boolean => { + return !!nodeType && nodeType.name === SCHEDULE_TRIGGER_NODE_TYPE; + }; + + const isDataNodeType = (nodeType: INodeTypeDescription | null): boolean => { + if (!nodeType) { + return false; + } + const includeCoreNodes = [ + HTTP_REQUEST_NODE_TYPE, + CODE_NODE_TYPE, + SET_NODE_TYPE, + WEBHOOK_NODE_TYPE, + ]; + return !nodeTypesStore.isCoreNodeType(nodeType) || includeCoreNodes.includes(nodeType.name); + }; + + return { + showAppCuesChecklist, + track, + trackAddedTrigger, + trackSuccessfulWorkflowExecution, + EVENTS, + }; +}); diff --git a/packages/editor-ui/src/stores/users.ts b/packages/editor-ui/src/stores/users.ts index 8d3963c866..e8c98db905 100644 --- a/packages/editor-ui/src/stores/users.ts +++ b/packages/editor-ui/src/stores/users.ts @@ -34,7 +34,7 @@ import { getPersonalizedNodeTypes, isAuthorized, PERMISSIONS, ROLE } from '@/uti import { defineStore } from 'pinia'; import Vue from 'vue'; import { useRootStore } from './n8nRootStore'; -import { usePostHogStore } from './posthog'; +import { usePostHog } from './posthog'; import { useSettingsStore } from './settings'; import { useUIStore } from './ui'; @@ -149,7 +149,7 @@ export const useUsersStore = defineStore(STORES.USERS, { this.addUsers([user]); this.currentUserId = user.id; - usePostHogStore().init(user.featureFlags); + usePostHog().init(user.featureFlags); }, async loginWithCreds(params: { email: string; password: string }): Promise { const rootStore = useRootStore(); @@ -161,13 +161,13 @@ export const useUsersStore = defineStore(STORES.USERS, { this.addUsers([user]); this.currentUserId = user.id; - usePostHogStore().init(user.featureFlags); + usePostHog().init(user.featureFlags); }, async logout(): Promise { const rootStore = useRootStore(); await logout(rootStore.getRestApiContext); this.currentUserId = null; - usePostHogStore().reset(); + usePostHog().reset(); }, async preOwnerSetup() { return preOwnerSetup(useRootStore().getRestApiContext); @@ -208,7 +208,7 @@ export const useUsersStore = defineStore(STORES.USERS, { this.currentUserId = user.id; } - usePostHogStore().init(user.featureFlags); + usePostHog().init(user.featureFlags); }, async sendForgotPasswordEmail(params: { email: string }): Promise { const rootStore = useRootStore(); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 4980afa26b..f8b8dbb359 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -268,6 +268,7 @@ import { nodeViewEventBus } from '@/event-bus/node-view-event-bus'; import { useWorkflowsStore } from '@/stores/workflows'; import { useRootStore } from '@/stores/n8nRootStore'; import { useNDVStore } from '@/stores/ndv'; +import { useSegment } from '@/stores/segment'; import { useTemplatesStore } from '@/stores/templates'; import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useCredentialsStore } from '@/stores/credentials'; @@ -304,7 +305,7 @@ import { N8nPlusEndpointType, EVENT_PLUS_ENDPOINT_CLICK, } from '@/plugins/endpoints/N8nPlusEndpointType'; -import { usePostHogStore } from '@/stores/posthog'; +import { usePostHog } from '@/stores/posthog'; interface AddNodeOptions { position?: XYPosition; @@ -1925,6 +1926,7 @@ export default mixins( }); } else { this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName }); + useSegment().trackAddedTrigger(nodeTypeName); const trackProperties: ITelemetryTrackProperties = { node_type: nodeTypeName, is_auto_add: isAutoAdd, @@ -2498,9 +2500,7 @@ export default mixins( }, async tryToAddWelcomeSticky(): Promise { const newWorkflow = this.workflowData; - if ( - usePostHogStore().isVariantEnabled(ASSUMPTION_EXPERIMENT.name, ASSUMPTION_EXPERIMENT.video) - ) { + if (usePostHog().isVariantEnabled(ASSUMPTION_EXPERIMENT.name, ASSUMPTION_EXPERIMENT.video)) { // For novice users (onboardingFlowEnabled == true) // Inject welcome sticky note and zoom to fit diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index f27d2addec..cd7ac10133 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -138,7 +138,7 @@ import { useUIStore } from '@/stores/ui'; import { useSettingsStore } from '@/stores/settings'; import { useUsersStore } from '@/stores/users'; import { useWorkflowsStore } from '@/stores/workflows'; -import { usePostHogStore } from '@/stores/posthog'; +import { usePostHog } from '@/stores/posthog'; type IResourcesListLayoutInstance = Vue & { sendFiltersTelemetry: (source: string) => void }; @@ -185,10 +185,7 @@ export default mixins(showMessage, debounceHelper, newVersions).extend({ return !!this.workflowsStore.activeWorkflows.length; }, isDemoTest(): boolean { - return usePostHogStore().isVariantEnabled( - ASSUMPTION_EXPERIMENT.name, - ASSUMPTION_EXPERIMENT.demo, - ); + return usePostHog().isVariantEnabled(ASSUMPTION_EXPERIMENT.name, ASSUMPTION_EXPERIMENT.demo); }, statusFilterOptions(): Array<{ label: string; value: string | boolean }> { return [