feat: Add events to enable onboarding checklist (#5536)

* feat: Add new event hooks

* fix: update event

* feat: Add more functionality for webhooks

*  Not sending onboarding checklist event from templates page

* 🔥 Removing quotes added by mistake

*  Added rest of events needed for onboarding checklist

* 💄 Hiding appcues checklist inside iframes

* 💄 Updating appcues selector

* fix: remove unnessary fix

* fix: fix schedule node

* refactor: bake events into segment store

* refactor: rename store

* refactor: use node keys

* refactor: remove unnessary

* chore: clean up store

* refactor: add key for event

* fix: allow tracking on template pages

* chore: remove comment

* fix: buidl

* refactor: block event if in iframe

* fix: fix tracking nodes

* refactor: track experiments once

* fix: ensure tracking works

* chore: remove comment

* fix: lint

* fix: lint

---------

Co-authored-by: Milorad Filipovic <milorad@n8n.io>
This commit is contained in:
Mutasem Aldmour 2023-02-28 13:44:37 +03:00 committed by GitHub
parent ae634407a4
commit 20c4919513
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 192 additions and 54 deletions

View file

@ -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;
};
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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);
},

View file

@ -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<FeatureFlags | null> = ref(null);
const initialized: Ref<boolean> = ref(false);
const trackedDemoExp: Ref<FeatureFlags> = 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,

View file

@ -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<string> = new Set<string>();
const multipleOutputNodes: Set<string> = new Set<string>();
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,
};
});

View file

@ -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<void> {
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<void> {
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<void> {
const rootStore = useRootStore();

View file

@ -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<void> {
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

View file

@ -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 [