mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 20:00:02 +00:00
feat(editor, core): Integrate PostHog (#3865)
* Integrate PostHog - Part 1: Groundwork (#3753) * Integrate PostHog - Part 2: Event capture (#3779) * Integrate PostHog - Part 3: Session recordings (#3789) * Integrate PostHog - Part 4: Experiments (#3825) * Finalize PostHog integration (#3866) * 📦 Update `package-lock.json` * 🐛 Account for absent PH hooks file * ✨ Create new env `EXTERNAL_FRONTEND_HOOKS_FILES` * ⚡ Adjust env used for injecting PostHog * 🐛 Switch to semicolon delimiter * ⚡ Simplify to `externalFrontendHookPath` * Refactor FE hooks flow (#3884) * Add env var for session recordings * inject frontend hooks even when telemetry is off * allow multiple hooks files * cr * 🐛 Handle missing ref errors * 🔥 Remove outdated `continue` * 🎨 Change one-liners to blocks * 📦 Update `package-lock.json` Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>
This commit is contained in:
@@ -681,6 +681,13 @@ export const schema = {
|
||||
},
|
||||
},
|
||||
|
||||
externalFrontendHooksUrls: {
|
||||
doc: 'URLs to external frontend hooks files, ; separated',
|
||||
format: String,
|
||||
default: 'https://public-stage.n8n.cloud/posthog-hooks.js',
|
||||
env: 'EXTERNAL_FRONTEND_HOOKS_URLS',
|
||||
},
|
||||
|
||||
externalHookFiles: {
|
||||
doc: 'Files containing external hooks. Multiple files can be separated by colon (":")',
|
||||
format: String,
|
||||
@@ -888,6 +895,26 @@ export const schema = {
|
||||
env: 'N8N_DIAGNOSTICS_ENABLED',
|
||||
},
|
||||
config: {
|
||||
posthog: {
|
||||
apiKey: {
|
||||
doc: 'API key for PostHog',
|
||||
format: String,
|
||||
default: 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo',
|
||||
env: 'N8N_DIAGNOSTICS_POSTHOG_API_KEY',
|
||||
},
|
||||
apiHost: {
|
||||
doc: 'API host for PostHog',
|
||||
format: String,
|
||||
default: 'https://app.posthog.com',
|
||||
env: 'N8N_DIAGNOSTICS_POSTHOG_API_HOST',
|
||||
},
|
||||
disableSessionRecording: {
|
||||
doc: 'Disable posthog session recording',
|
||||
format: Boolean,
|
||||
default: true,
|
||||
env: 'N8N_DIAGNOSTICS_POSTHOG_DISABLE_RECORDING',
|
||||
},
|
||||
},
|
||||
frontend: {
|
||||
doc: 'Diagnostics config for frontend.',
|
||||
format: String,
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"passport-cookie": "^1.0.9",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pg": "^8.3.0",
|
||||
"posthog-node": "^1.3.0",
|
||||
"prom-client": "^13.1.0",
|
||||
"psl": "^1.8.0",
|
||||
"shelljs": "^0.8.5",
|
||||
|
||||
@@ -516,6 +516,9 @@ export interface IN8nUISettings {
|
||||
missingPackages?: boolean;
|
||||
executionMode: 'regular' | 'queue';
|
||||
communityNodesEnabled: boolean;
|
||||
deployment: {
|
||||
type: string;
|
||||
};
|
||||
isNpmAvailable: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,16 +106,20 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||
(note) => note.overlapping,
|
||||
).length;
|
||||
|
||||
return this.telemetry.track('User saved workflow', {
|
||||
user_id: userId,
|
||||
workflow_id: workflow.id,
|
||||
node_graph_string: JSON.stringify(nodeGraph),
|
||||
notes_count_overlapping: overlappingCount,
|
||||
notes_count_non_overlapping: notesCount - overlappingCount,
|
||||
version_cli: this.versionCli,
|
||||
num_tags: workflow.tags?.length ?? 0,
|
||||
public_api: publicApi,
|
||||
});
|
||||
return this.telemetry.track(
|
||||
'User saved workflow',
|
||||
{
|
||||
user_id: userId,
|
||||
workflow_id: workflow.id,
|
||||
node_graph_string: JSON.stringify(nodeGraph),
|
||||
notes_count_overlapping: overlappingCount,
|
||||
notes_count_non_overlapping: notesCount - overlappingCount,
|
||||
version_cli: this.versionCli,
|
||||
num_tags: workflow.tags?.length ?? 0,
|
||||
public_api: publicApi,
|
||||
},
|
||||
{ withPostHog: true },
|
||||
);
|
||||
}
|
||||
|
||||
async onWorkflowPostExecute(
|
||||
@@ -197,14 +201,18 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||
}
|
||||
|
||||
if (runData.data.startData?.destinationNode) {
|
||||
const telemetryPayload = {
|
||||
...manualExecEventProperties,
|
||||
node_type: TelemetryHelpers.getNodeTypeForName(
|
||||
workflow,
|
||||
runData.data.startData?.destinationNode,
|
||||
)?.type,
|
||||
node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode],
|
||||
};
|
||||
|
||||
promises.push(
|
||||
this.telemetry.track('Manual node exec finished', {
|
||||
...manualExecEventProperties,
|
||||
node_type: TelemetryHelpers.getNodeTypeForName(
|
||||
workflow,
|
||||
runData.data.startData?.destinationNode,
|
||||
)?.type,
|
||||
node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode],
|
||||
this.telemetry.track('Manual node exec finished', telemetryPayload, {
|
||||
withPostHog: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
@@ -219,7 +227,9 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||
});
|
||||
|
||||
promises.push(
|
||||
this.telemetry.track('Manual workflow exec finished', manualExecEventProperties),
|
||||
this.telemetry.track('Manual workflow exec finished', manualExecEventProperties, {
|
||||
withPostHog: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,8 @@ import {
|
||||
isUserManagementEnabled,
|
||||
} from './UserManagement/UserManagementHelper';
|
||||
import { loadPublicApiVersions } from './PublicApi';
|
||||
import { SharedWorkflow } from './databases/entities/SharedWorkflow';
|
||||
import * as telemetryScripts from './telemetry/scripts';
|
||||
|
||||
require('body-parser-xml')(bodyParser);
|
||||
|
||||
@@ -334,6 +336,9 @@ class App {
|
||||
onboardingCallPromptEnabled: config.getEnv('onboardingCallPrompt.enabled'),
|
||||
executionMode: config.getEnv('executions.mode'),
|
||||
communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'),
|
||||
deployment: {
|
||||
type: config.getEnv('deployment.type'),
|
||||
},
|
||||
isNpmAvailable: false,
|
||||
};
|
||||
}
|
||||
@@ -2611,6 +2616,36 @@ class App {
|
||||
readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath);
|
||||
readIndexFile = readIndexFile.replace(/\/favicon.ico/g, `${n8nPath}favicon.ico`);
|
||||
|
||||
const hooksUrls = config.getEnv('externalFrontendHooksUrls');
|
||||
|
||||
let scriptsString = '';
|
||||
|
||||
if (hooksUrls) {
|
||||
scriptsString = hooksUrls.split(';').reduce((acc, curr) => {
|
||||
return `${acc}<script src="${curr}"></script>`;
|
||||
}, '');
|
||||
}
|
||||
|
||||
if (this.frontendSettings.telemetry.enabled) {
|
||||
const phLoadingScript = telemetryScripts.createPostHogLoadingScript({
|
||||
apiKey: config.getEnv('diagnostics.config.posthog.apiKey'),
|
||||
apiHost: config.getEnv('diagnostics.config.posthog.apiHost'),
|
||||
autocapture: false,
|
||||
disableSessionRecording: config.getEnv(
|
||||
'diagnostics.config.posthog.disableSessionRecording',
|
||||
),
|
||||
debug: config.getEnv('logs.level') === 'debug',
|
||||
});
|
||||
|
||||
scriptsString += phLoadingScript;
|
||||
}
|
||||
|
||||
const firstLinkedScriptSegment = '<link href="/js/';
|
||||
readIndexFile = readIndexFile.replace(
|
||||
firstLinkedScriptSegment,
|
||||
scriptsString + firstLinkedScriptSegment,
|
||||
);
|
||||
|
||||
// Serve the altered index.html file separately
|
||||
this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => {
|
||||
res.send(readIndexFile);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import TelemetryClient from '@rudderstack/rudder-sdk-node';
|
||||
import RudderStack from '@rudderstack/rudder-sdk-node';
|
||||
import PostHog from 'posthog-node';
|
||||
import { ITelemetryTrackProperties, LoggerProxy } from 'n8n-workflow';
|
||||
import * as config from '../../config';
|
||||
import config from '../../config';
|
||||
import { IExecutionTrackProperties } from '../Interfaces';
|
||||
import { getLogger } from '../Logger';
|
||||
|
||||
@@ -24,7 +25,9 @@ interface IExecutionsBuffer {
|
||||
}
|
||||
|
||||
export class Telemetry {
|
||||
private client?: TelemetryClient;
|
||||
private rudderStack?: RudderStack;
|
||||
|
||||
private postHog?: PostHog;
|
||||
|
||||
private instanceId: string;
|
||||
|
||||
@@ -51,18 +54,19 @@ export class Telemetry {
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = this.createTelemetryClient(key, url, logLevel);
|
||||
this.rudderStack = this.initRudderStack(key, url, logLevel);
|
||||
this.postHog = this.initPostHog();
|
||||
|
||||
this.startPulse();
|
||||
}
|
||||
}
|
||||
|
||||
private createTelemetryClient(
|
||||
key: string,
|
||||
url: string,
|
||||
logLevel: string,
|
||||
): TelemetryClient | undefined {
|
||||
return new TelemetryClient(key, url, { logLevel });
|
||||
private initRudderStack(key: string, url: string, logLevel: string): RudderStack {
|
||||
return new RudderStack(key, url, { logLevel });
|
||||
}
|
||||
|
||||
private initPostHog(): PostHog {
|
||||
return new PostHog(config.getEnv('diagnostics.config.posthog.apiKey'));
|
||||
}
|
||||
|
||||
private startPulse() {
|
||||
@@ -72,7 +76,7 @@ export class Telemetry {
|
||||
}
|
||||
|
||||
private async pulse(): Promise<unknown> {
|
||||
if (!this.client) {
|
||||
if (!this.rudderStack) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -92,7 +96,7 @@ export class Telemetry {
|
||||
}
|
||||
|
||||
async trackWorkflowExecution(properties: IExecutionTrackProperties): Promise<void> {
|
||||
if (this.client) {
|
||||
if (this.rudderStack) {
|
||||
const execTime = new Date();
|
||||
const workflowId = properties.workflow_id;
|
||||
|
||||
@@ -122,8 +126,12 @@ export class Telemetry {
|
||||
clearInterval(this.pulseIntervalReference);
|
||||
void this.track('User instance stopped');
|
||||
return new Promise<void>((resolve) => {
|
||||
if (this.client) {
|
||||
this.client.flush(resolve);
|
||||
if (this.postHog) {
|
||||
this.postHog.shutdown();
|
||||
}
|
||||
|
||||
if (this.rudderStack) {
|
||||
this.rudderStack.flush(resolve);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
@@ -134,8 +142,18 @@ export class Telemetry {
|
||||
[key: string]: string | number | boolean | object | undefined | null;
|
||||
}): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (this.client) {
|
||||
this.client.identify(
|
||||
if (this.postHog) {
|
||||
this.postHog.identify({
|
||||
distinctId: this.instanceId,
|
||||
properties: {
|
||||
...traits,
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.rudderStack) {
|
||||
this.rudderStack.identify(
|
||||
{
|
||||
userId: this.instanceId,
|
||||
anonymousId: '000000000000',
|
||||
@@ -152,9 +170,13 @@ export class Telemetry {
|
||||
});
|
||||
}
|
||||
|
||||
async track(eventName: string, properties: ITelemetryTrackProperties = {}): Promise<void> {
|
||||
async track(
|
||||
eventName: string,
|
||||
properties: ITelemetryTrackProperties = {},
|
||||
{ withPostHog } = { withPostHog: false }, // whether to additionally track with PostHog
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (this.client) {
|
||||
if (this.rudderStack) {
|
||||
const { user_id } = properties;
|
||||
const updatedProperties: ITelemetryTrackProperties = {
|
||||
...properties,
|
||||
@@ -162,21 +184,35 @@ export class Telemetry {
|
||||
version_cli: this.versionCli,
|
||||
};
|
||||
|
||||
this.client.track(
|
||||
{
|
||||
userId: `${this.instanceId}${user_id ? `#${user_id}` : ''}`,
|
||||
anonymousId: '000000000000',
|
||||
event: eventName,
|
||||
properties: updatedProperties,
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
const payload = {
|
||||
userId: `${this.instanceId}${user_id ? `#${user_id}` : ''}`,
|
||||
anonymousId: '000000000000',
|
||||
event: eventName,
|
||||
properties: updatedProperties,
|
||||
};
|
||||
|
||||
if (withPostHog && this.postHog) {
|
||||
this.postHog.capture({ ...payload, distinctId: payload.userId });
|
||||
}
|
||||
|
||||
this.rudderStack.track(payload, resolve);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async isFeatureFlagEnabled(
|
||||
featureFlagName: string,
|
||||
{ user_id: userId }: ITelemetryTrackProperties = {},
|
||||
): Promise<boolean> {
|
||||
if (!this.postHog) return Promise.resolve(false);
|
||||
|
||||
const fullId = [this.instanceId, userId].join('_'); // PostHog disallows # in ID
|
||||
|
||||
return this.postHog.isFeatureEnabled(featureFlagName, fullId);
|
||||
}
|
||||
|
||||
// test helpers
|
||||
|
||||
getCountsBuffer(): IExecutionsBuffer {
|
||||
|
||||
17
packages/cli/src/telemetry/scripts.ts
Normal file
17
packages/cli/src/telemetry/scripts.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Create a script to init PostHog, for embedding before the Vue bundle in `<head>` in `index.html`.
|
||||
*/
|
||||
export const createPostHogLoadingScript = ({
|
||||
apiKey,
|
||||
apiHost,
|
||||
autocapture,
|
||||
disableSessionRecording,
|
||||
debug,
|
||||
}: {
|
||||
apiKey: string;
|
||||
apiHost: string;
|
||||
autocapture: boolean;
|
||||
disableSessionRecording: boolean;
|
||||
debug: boolean;
|
||||
}) =>
|
||||
`<script>!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);posthog.init('${apiKey}',{api_host:'${apiHost}', autocapture: ${autocapture.toString()}, disable_session_recording: ${disableSessionRecording.toString()}, debug:${debug.toString()}})</script>`;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Telemetry } from '../../src/telemetry';
|
||||
import config from '../../config';
|
||||
|
||||
jest.spyOn(Telemetry.prototype as any, 'createTelemetryClient').mockImplementation(() => {
|
||||
jest.spyOn(Telemetry.prototype as any, 'initRudderStack').mockImplementation(() => {
|
||||
return {
|
||||
flush: () => {},
|
||||
identify: () => {},
|
||||
|
||||
Reference in New Issue
Block a user