feat: Support feature flag evaluation server side (#5511)

* feat(editor): roll out schema view

* feat(editor): add posthog tracking

* refactor: use composables

* refactor: clean up console log

* refactor: clean up impl

* chore: clean up impl

* fix: fix demo var

* chore: add comment

* refactor: clean up

* chore: wrap error func

* refactor: clean up import

* refactor: make store

* feat: enable rudderstack usebeacon, move event to unload

* chore: clean up alert

* refactor: move tracking from hooks

* fix: reload flags on login

* fix: add func to setup

* fix: clear duplicate import

* chore: add console to tesT

* chore: add console to tesT

* fix: try reload

* chore: randomize instnace id for testing

* chore: randomize instnace id for testing

* chore: add console logs for testing

* chore: move random id to fe

* chore: use query param for testing

* feat: update PostHog api endpoint

* feat: update rs host

* feat: update rs host

* feat: update rs endpoints

* refactor: use api host for BE events as well

* refactor: refactor out posthog client

* feat: add feature flags to login

* feat: add feature flags to login

* feat: get feature flags to work

* feat: add created at to be events

* chore: add todos

* chore: clean up store

* chore: add created at to identify

* feat: add posthog config to settings

* feat: add bootstrapping

* chore: clean up

* chore: fix build

* fix: get dates to work

* fix: get posthog to recognize dates

* chore: refactor

* fix: update back to number

* fix: update key

* fix: get experiment evals to work

* feat: add posthog to signup router

* feat: add feature flags on sign up

* chore: clean up

* fix: fix import

* chore: clean up loading script

* feat: add timeout, fix: script loader

* fix: test timeout and get working on 8080

* refactor: move out posthog

* feat: add experiment tracking

* fix: clear tracked on reset

* fix: fix signup bug

* fix: handle errors when telmetry is disabled

* refactor: remove redundant await

* fix: add back posthog to telemetry

* test: fix test

* test: fix test

* test: add tests for posthog client

* lint: fix

* fix: fix issue with slow decide endpoint

* lint: fix

* lint: fix

* lint: fix

* lint: fix

* chore: address PR feedback

* chore: address PR feedback

* feat: add onboarding experiment
This commit is contained in:
Mutasem Aldmour 2023-02-21 11:35:35 +03:00 committed by GitHub
parent ee21b7a1cf
commit 26a20ed47e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 513 additions and 122 deletions

View file

@ -22,6 +22,7 @@ import type {
WorkflowExecuteMode,
ExecutionStatus,
IExecutionsSummary,
FeatureFlags,
} from 'n8n-workflow';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -475,6 +476,14 @@ export interface IN8nUISettings {
versionNotifications: IVersionNotificationSettings;
instanceId: string;
telemetry: ITelemetrySettings;
posthog: {
enabled: boolean;
apiHost: string;
apiKey: string;
autocapture: boolean;
disableSessionRecording: boolean;
debug: boolean;
};
personalizationSurveyEnabled: boolean;
defaultLocale: string;
userManagement: IUserManagementSettings;
@ -836,6 +845,10 @@ export interface PublicUser {
inviteAcceptUrl?: string;
}
export interface CurrentUser extends PublicUser {
featureFlags?: FeatureFlags;
}
export interface N8nApp {
app: Application;
restEndpoint: string;

View file

@ -1,6 +1,7 @@
import type { INodeTypes } from 'n8n-workflow';
import { InternalHooksClass } from '@/InternalHooks';
import { Telemetry } from '@/telemetry';
import type { PostHogClient } from './posthog';
export class InternalHooksManager {
private static internalHooksInstance: InternalHooksClass;
@ -13,9 +14,13 @@ export class InternalHooksManager {
throw new Error('InternalHooks not initialized');
}
static async init(instanceId: string, nodeTypes: INodeTypes): Promise<InternalHooksClass> {
static async init(
instanceId: string,
nodeTypes: INodeTypes,
postHog: PostHogClient,
): Promise<InternalHooksClass> {
if (!this.internalHooksInstance) {
const telemetry = new Telemetry(instanceId);
const telemetry = new Telemetry(instanceId, postHog);
await telemetry.init();
this.internalHooksInstance = new InternalHooksClass(telemetry, instanceId, nodeTypes);
}

View file

@ -145,6 +145,7 @@ import { AbstractServer } from './AbstractServer';
import { configureMetrics } from './metrics';
import { setupBasicAuth } from './middlewares/basicAuth';
import { setupExternalJWTAuth } from './middlewares/externalJWTAuth';
import { PostHogClient } from './posthog';
import { eventBus } from './eventbus';
import { isSamlEnabled } from './Saml/helpers';
@ -167,6 +168,8 @@ class Server extends AbstractServer {
credentialTypes: ICredentialTypes;
postHog: PostHogClient;
push: Push;
constructor() {
@ -178,6 +181,7 @@ class Server extends AbstractServer {
this.activeExecutionsInstance = ActiveExecutions.getInstance();
this.waitTracker = WaitTracker();
this.postHog = new PostHogClient();
this.presetCredentialsLoaded = false;
this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint');
@ -232,6 +236,16 @@ class Server extends AbstractServer {
},
instanceId: '',
telemetry: telemetrySettings,
posthog: {
enabled: config.getEnv('diagnostics.enabled'),
apiHost: config.getEnv('diagnostics.config.posthog.apiHost'),
apiKey: config.getEnv('diagnostics.config.posthog.apiKey'),
autocapture: false,
disableSessionRecording: config.getEnv(
'diagnostics.config.posthog.disableSessionRecording',
),
debug: config.getEnv('logs.level') === 'debug',
},
personalizationSurveyEnabled:
config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'),
defaultLocale: config.getEnv('defaultLocale'),
@ -345,9 +359,10 @@ class Server extends AbstractServer {
const logger = LoggerProxy;
const internalHooks = InternalHooksManager.getInstance();
const mailer = getMailerInstance();
const postHog = this.postHog;
const controllers = [
new AuthController({ config, internalHooks, repositories, logger }),
new AuthController({ config, internalHooks, repositories, logger, postHog }),
new OwnerController({ config, internalHooks, repositories, logger }),
new MeController({ externalHooks, internalHooks, repositories, logger }),
new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }),
@ -359,6 +374,7 @@ class Server extends AbstractServer {
repositories,
activeWorkflowRunner,
logger,
postHog,
}),
];
controllers.forEach((controller) => registerController(app, config, controller));
@ -378,6 +394,7 @@ class Server extends AbstractServer {
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
await this.initLicense();
await this.postHog.init(this.frontendSettings.instanceId);
const publicApiEndpoint = config.getEnv('publicApi.path');
const excludeEndpoints = config.getEnv('security.excludeEndpoints');

View file

@ -6,7 +6,7 @@ import { compare, genSaltSync, hash } from 'bcryptjs';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import type { PublicUser, WhereClause } from '@/Interfaces';
import type { CurrentUser, PublicUser, WhereClause } from '@/Interfaces';
import type { User } from '@db/entities/User';
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User';
import type { Role } from '@db/entities/Role';
@ -15,6 +15,7 @@ import config from '@/config';
import { getWebhookBaseUrl } from '@/WebhookHelpers';
import { getLicense } from '@/License';
import { RoleService } from '@/role/role.service';
import type { PostHogClient } from '@/posthog';
export async function getWorkflowOwner(workflowId: string): Promise<User> {
const workflowOwnerRole = await RoleService.get({ name: 'owner', scope: 'workflow' });
@ -162,6 +163,31 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
return sanitizedUser;
}
export async function withFeatureFlags(
postHog: PostHogClient | undefined,
user: CurrentUser,
): Promise<CurrentUser> {
if (!postHog) {
return user;
}
// native PostHog implementation has default 10s timeout and 3 retries.. which cannot be updated without affecting other functionality
// https://github.com/PostHog/posthog-js-lite/blob/a182de80a433fb0ffa6859c10fb28084d0f825c2/posthog-core/src/index.ts#L67
const timeoutPromise = new Promise<CurrentUser>((resolve) => {
setTimeout(() => {
resolve(user);
}, 1500);
});
const fetchPromise = new Promise<CurrentUser>(async (resolve) => {
user.featureFlags = await postHog.getFeatureFlags(user);
resolve(user);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
export function addInviteLinkToUser(user: PublicUser, inviterId: string): PublicUser {
if (user.isPending) {
user.inviteAcceptUrl = generateUserInviteUrl(inviterId, user.id);

View file

@ -54,6 +54,7 @@ import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { initErrorHandling } from '@/ErrorReporting';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { getLicense } from './License';
import { PostHogClient } from './posthog';
class WorkflowRunnerProcess {
data: IWorkflowExecutionDataProcessWithExecution | undefined;
@ -115,7 +116,11 @@ class WorkflowRunnerProcess {
await externalHooks.init();
const instanceId = userSettings.instanceId ?? '';
await InternalHooksManager.init(instanceId, nodeTypes);
const postHog = new PostHogClient();
await postHog.init(instanceId);
await InternalHooksManager.init(instanceId, nodeTypes, postHog);
const binaryDataConfig = config.getEnv('binaryDataManager');
await BinaryDataManager.init(binaryDataConfig);

View file

@ -18,6 +18,7 @@ import { NodeTypes } from '@/NodeTypes';
import type { LoadNodesAndCredentialsClass } from '@/LoadNodesAndCredentials';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import type { IExternalHooksClass } from '@/Interfaces';
import { PostHogClient } from '@/posthog';
export const UM_FIX_INSTRUCTION =
'Please fix the database by running ./packages/cli/bin/n8n user-management:reset';
@ -48,7 +49,11 @@ export abstract class BaseCommand extends Command {
const credentialTypes = CredentialTypes(this.loadNodesAndCredentials);
CredentialsOverwrites(credentialTypes);
await InternalHooksManager.init(this.userSettings.instanceId ?? '', this.nodeTypes);
const instanceId = this.userSettings.instanceId ?? '';
const postHog = new PostHogClient();
await postHog.init(instanceId);
await InternalHooksManager.init(instanceId, this.nodeTypes, postHog);
await Db.init().catch(async (error: Error) =>
this.exitWithCrash('There was an error initializing DB', error),

View file

@ -26,7 +26,6 @@ import * as TestWebhooks from '@/TestWebhooks';
import { WaitTracker } from '@/WaitTracker';
import { getAllInstalledPackages } from '@/CommunityNodes/packageModel';
import { handleLdapInit } from '@/Ldap/helpers';
import { createPostHogLoadingScript } from '@/telemetry/scripts';
import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants';
import { eventBus } from '@/eventbus';
import { BaseCommand } from './BaseCommand';
@ -154,20 +153,6 @@ export class Start extends BaseCommand {
}, '');
}
if (config.getEnv('diagnostics.enabled')) {
const phLoadingScript = 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 closingTitleTag = '</title>';
const compileFile = async (fileName: string) => {
const filePath = path.join(EDITOR_UI_DIST_DIR, fileName);

View file

@ -1,7 +1,7 @@
import validator from 'validator';
import { Get, Post, RestController } from '@/decorators';
import { AuthError, BadRequestError, InternalServerError } from '@/ResponseHelper';
import { sanitizeUser } from '@/UserManagement/UserManagementHelper';
import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper';
import { issueCookie, resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME } from '@/constants';
import { Request, Response } from 'express';
@ -11,8 +11,14 @@ import { LoginRequest, UserRequest } from '@/requests';
import type { Repository } from 'typeorm';
import { In } from 'typeorm';
import type { Config } from '@/config';
import type { PublicUser, IDatabaseCollections, IInternalHooksClass } from '@/Interfaces';
import type {
PublicUser,
IDatabaseCollections,
IInternalHooksClass,
CurrentUser,
} from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import type { PostHogClient } from '@/posthog';
@RestController()
export class AuthController {
@ -24,21 +30,26 @@ export class AuthController {
private readonly userRepository: Repository<User>;
private readonly postHog?: PostHogClient;
constructor({
config,
logger,
internalHooks,
repositories,
postHog,
}: {
config: Config;
logger: ILogger;
internalHooks: IInternalHooksClass;
repositories: Pick<IDatabaseCollections, 'User'>;
postHog?: PostHogClient;
}) {
this.config = config;
this.logger = logger;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
this.postHog = postHog;
}
/**
@ -56,7 +67,7 @@ export class AuthController {
if (user) {
await issueCookie(res, user);
return sanitizeUser(user);
return withFeatureFlags(this.postHog, sanitizeUser(user));
}
throw new AuthError('Wrong username or password. Do you have caps lock on?');
@ -66,7 +77,7 @@ export class AuthController {
* Manually check the `n8n-auth` cookie.
*/
@Get('/login')
async currentUser(req: Request, res: Response): Promise<PublicUser> {
async currentUser(req: Request, res: Response): Promise<CurrentUser> {
// Manually check the existing cookie.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
@ -76,7 +87,7 @@ export class AuthController {
// If logged in, return user
try {
user = await resolveJwt(cookieContents);
return sanitizeUser(user);
return await withFeatureFlags(this.postHog, sanitizeUser(user));
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
@ -102,7 +113,7 @@ export class AuthController {
}
await issueCookie(res, user);
return sanitizeUser(user);
return withFeatureFlags(this.postHog, sanitizeUser(user));
}
/**

View file

@ -16,6 +16,7 @@ import {
isUserManagementEnabled,
sanitizeUser,
validatePassword,
withFeatureFlags,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper';
@ -33,6 +34,7 @@ import type {
} from '@/Interfaces';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import type { PostHogClient } from '@/posthog';
@RestController('/users')
export class UsersController {
@ -56,6 +58,8 @@ export class UsersController {
private mailer: UserManagementMailer;
private postHog?: PostHogClient;
constructor({
config,
logger,
@ -64,6 +68,7 @@ export class UsersController {
repositories,
activeWorkflowRunner,
mailer,
postHog,
}: {
config: Config;
logger: ILogger;
@ -75,6 +80,7 @@ export class UsersController {
>;
activeWorkflowRunner: ActiveWorkflowRunner;
mailer: UserManagementMailer;
postHog?: PostHogClient;
}) {
this.config = config;
this.logger = logger;
@ -86,6 +92,7 @@ export class UsersController {
this.sharedWorkflowRepository = repositories.SharedWorkflow;
this.activeWorkflowRunner = activeWorkflowRunner;
this.mailer = mailer;
this.postHog = postHog;
}
/**
@ -327,7 +334,7 @@ export class UsersController {
await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return sanitizeUser(updatedUser);
return withFeatureFlags(this.postHog, sanitizeUser(updatedUser));
}
@Get('/')

View file

@ -0,0 +1,57 @@
import type { PostHog } from 'posthog-node';
import type { FeatureFlags, ITelemetryTrackProperties } from 'n8n-workflow';
import config from '@/config';
import type { PublicUser } from '..';
export class PostHogClient {
private postHog?: PostHog;
private instanceId?: string;
async init(instanceId: string) {
this.instanceId = instanceId;
const enabled = config.getEnv('diagnostics.enabled');
if (!enabled) {
return;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const { PostHog } = await import('posthog-node');
this.postHog = new PostHog(config.getEnv('diagnostics.config.posthog.apiKey'), {
host: config.getEnv('diagnostics.config.posthog.apiHost'),
});
const logLevel = config.getEnv('logs.level');
if (logLevel === 'debug') {
this.postHog.debug(true);
}
}
async stop(): Promise<void> {
if (this.postHog) {
return this.postHog.shutdown();
}
}
track(payload: { userId: string; event: string; properties: ITelemetryTrackProperties }): void {
this.postHog?.capture({
distinctId: payload.userId,
sendFeatureFlags: true,
...payload,
});
}
async getFeatureFlags(user: Pick<PublicUser, 'id' | 'createdAt'>): Promise<FeatureFlags> {
if (!this.postHog) return Promise.resolve({});
const fullId = [this.instanceId, user.id].join('#');
// cannot use local evaluation because that requires PostHog personal api key with org-wide
// https://github.com/PostHog/posthog/issues/4849
return this.postHog.getAllFlags(fullId, {
personProperties: {
created_at_timestamp: user.createdAt.getTime().toString(),
},
});
}
}

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type RudderStack from '@rudderstack/rudder-sdk-node';
import type { PostHog } from 'posthog-node';
import type { PostHogClient } from '../posthog';
import type { ITelemetryTrackProperties } from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow';
import config from '@/config';
@ -31,13 +31,11 @@ interface IExecutionsBuffer {
export class Telemetry {
private rudderStack?: RudderStack;
private postHog?: PostHog;
private pulseIntervalReference: NodeJS.Timeout;
private executionCountsBuffer: IExecutionsBuffer = {};
constructor(private instanceId: string) {}
constructor(private instanceId: string, private postHog: PostHogClient) {}
async init() {
const enabled = config.getEnv('diagnostics.enabled');
@ -58,12 +56,6 @@ export class Telemetry {
const { default: RudderStack } = await import('@rudderstack/rudder-sdk-node');
this.rudderStack = new RudderStack(key, url, { logLevel });
// eslint-disable-next-line @typescript-eslint/naming-convention
const { PostHog } = await import('posthog-node');
this.postHog = new PostHog(config.getEnv('diagnostics.config.posthog.apiKey'), {
host: config.getEnv('diagnostics.config.posthog.apiHost'),
});
this.startPulse();
}
}
@ -137,10 +129,8 @@ export class Telemetry {
async trackN8nStop(): Promise<void> {
clearInterval(this.pulseIntervalReference);
void this.track('User instance stopped');
return new Promise<void>((resolve) => {
if (this.postHog) {
this.postHog.shutdown();
}
return new Promise<void>(async (resolve) => {
await this.postHog.stop();
if (this.rudderStack) {
this.rudderStack.flush(resolve);
@ -192,11 +182,7 @@ export class Telemetry {
};
if (withPostHog) {
this.postHog?.capture({
distinctId: payload.userId,
sendFeatureFlags: true,
...payload,
});
this.postHog?.track(payload);
}
return this.rudderStack.track(payload, resolve);
@ -206,19 +192,7 @@ export class Telemetry {
});
}
async isFeatureFlagEnabled(
featureFlagName: string,
{ user_id: userId }: ITelemetryTrackProperties = {},
): Promise<boolean | undefined> {
if (!this.postHog) return Promise.resolve(false);
const fullId = [this.instanceId, userId].join('#');
return this.postHog.isFeatureEnabled(featureFlagName, fullId);
}
// test helpers
getCountsBuffer(): IExecutionsBuffer {
return this.executionCountsBuffer;
}

View file

@ -1,17 +0,0 @@
/**
* 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>`;

View file

@ -74,6 +74,7 @@ import * as testDb from '../shared/testDb';
import { v4 as uuid } from 'uuid';
import { handleLdapInit } from '@/Ldap/helpers';
import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
import { PostHogClient } from '@/posthog';
const loadNodesAndCredentials: INodesAndCredentials = {
loaded: { nodes: {}, credentials: {} },
@ -107,8 +108,11 @@ export async function initTestServer({
const logger = getLogger();
LoggerProxy.init(logger);
const postHog = new PostHogClient();
postHog.init('test-instance-id');
// Pre-requisite: Mock the telemetry module before calling.
await InternalHooksManager.init('test-instance-id', mockNodeTypes);
await InternalHooksManager.init('test-instance-id', mockNodeTypes, postHog);
testServer.app.use(bodyParser.json());
testServer.app.use(bodyParser.urlencoded({ extended: true }));

View file

@ -0,0 +1,90 @@
import { PostHog } from 'posthog-node';
import { PostHogClient } from '@/posthog';
import config from '@/config';
jest.mock('posthog-node');
describe('PostHog', () => {
const instanceId = 'test-id';
const userId = 'distinct-id';
const apiKey = 'api-key';
const apiHost = 'api-host';
beforeAll(() => {
config.set('diagnostics.config.posthog.apiKey', apiKey);
config.set('diagnostics.config.posthog.apiHost', apiHost);
});
beforeEach(() => {
config.set('diagnostics.enabled', true);
jest.resetAllMocks();
});
it('inits PostHog correctly', async () => {
const ph = new PostHogClient();
await ph.init(instanceId);
expect(PostHog.prototype.constructor).toHaveBeenCalledWith(apiKey, {host: apiHost});
});
it('does not initialize or track if diagnostics are not enabled', async () => {
config.set('diagnostics.enabled', false);
const ph = new PostHogClient();
await ph.init(instanceId);
ph.track({
userId: 'test',
event: 'test',
properties: {},
});
expect(PostHog.prototype.constructor).not.toHaveBeenCalled();
expect(PostHog.prototype.capture).not.toHaveBeenCalled();
});
it('captures PostHog events', async () => {
const event = 'test event';
const properties = {
user_id: 'test',
test: true,
};
const ph = new PostHogClient();
await ph.init(instanceId);
ph.track({
userId,
event,
properties,
});
expect(PostHog.prototype.capture).toHaveBeenCalledWith({
distinctId: userId,
event,
userId,
properties,
sendFeatureFlags: true,
});
});
it('gets feature flags', async () => {
const createdAt = new Date();
const ph = new PostHogClient();
await ph.init(instanceId);
ph.getFeatureFlags({
id: userId,
createdAt,
});
expect(PostHog.prototype.getAllFlags).toHaveBeenCalledWith(
`${instanceId}#${userId}`,
{
personProperties: {
created_at_timestamp: createdAt.getTime().toString(),
},
}
);
});
});

View file

@ -1,6 +1,7 @@
import { Telemetry } from '@/telemetry';
import config from '@/config';
import { flushPromises } from './Helpers';
import { PostHogClient } from '@/posthog';
jest.unmock('@/telemetry');
jest.mock('@/license/License.service', () => {
@ -10,6 +11,7 @@ jest.mock('@/license/License.service', () => {
},
};
});
jest.mock('@/posthog');
describe('Telemetry', () => {
let startPulseSpy: jest.SpyInstance;
@ -39,7 +41,11 @@ describe('Telemetry', () => {
beforeEach(() => {
spyTrack.mockClear();
telemetry = new Telemetry(instanceId);
const postHog = new PostHogClient();
postHog.init(instanceId);
telemetry = new Telemetry(instanceId, postHog);
(telemetry as any).rudderStack = {
flush: () => {},
identify: () => {},

View file

@ -8,6 +8,8 @@
<script type="text/javascript">
window.BASE_PATH = '/{{BASE_PATH}}/';
</script>
<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 getFeatureFlag onFeatureFlags reloadFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[])</script>
<title>n8n.io - Workflow Automation</title>
</head>
<body>

View file

@ -30,7 +30,7 @@
import Modals from './components/Modals.vue';
import LoadingView from './views/LoadingView.vue';
import Telemetry from './components/Telemetry.vue';
import { HIRING_BANNER, LOCAL_STORAGE_THEME, POSTHOG_ASSUMPTION_TEST, VIEWS } from './constants';
import { HIRING_BANNER, LOCAL_STORAGE_THEME, VIEWS } from './constants';
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/mixins/showMessage';
@ -179,17 +179,6 @@ export default mixins(showMessage, userHelpers, restApi, historyHelper).extend({
window.document.body.classList.add(`theme-${theme}`);
}
},
trackExperiments() {
const assumption = window.posthog?.getFeatureFlag?.(POSTHOG_ASSUMPTION_TEST);
const isVideo = assumption === 'assumption-video';
const isDemo = assumption === 'assumption-demo';
if (isVideo) {
this.$telemetry.track('User is part of video experiment');
} else if (isDemo) {
this.$telemetry.track('User is part of demo experiment');
}
},
},
async mounted() {
this.setTheme();
@ -206,10 +195,6 @@ export default mixins(showMessage, userHelpers, restApi, historyHelper).extend({
if (this.defaultLocale !== 'en') {
await this.nodeTypesStore.getNodeTranslationHeaders();
}
setTimeout(() => {
this.trackExperiments();
}, 0);
},
watch: {
$route(route) {

View file

@ -30,6 +30,7 @@ import {
IDisplayOptions,
IExecutionsSummary,
IAbstractEventMessage,
FeatureFlags,
} from 'n8n-workflow';
import { SignInType } from './constants';
import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants';
@ -37,6 +38,37 @@ import { BulkCommand, Undoable } from '@/models/history';
export * from 'n8n-design-system/types';
declare global {
interface Window {
posthog?: {
init(
key: string,
options?: {
api_host?: string;
autocapture?: boolean;
disable_session_recording?: boolean;
debug?: boolean;
bootstrap?: {
distinctId?: string;
isIdentifiedID?: boolean;
featureFlags: FeatureFlags;
};
},
): void;
isFeatureEnabled?(flagName: string): boolean;
getFeatureFlag?(flagName: string): boolean | string;
identify?(
id: string,
userProperties?: Record<string, string | number>,
userPropertiesOnce?: Record<string, string>,
): void;
reset?(resetDeviceId?: boolean): void;
onFeatureFlags?(callback: (keys: string[], map: FeatureFlags) => void): void;
reloadFeatureFlags?(): void;
};
}
}
export type EndpointStyle = {
width?: number;
height?: number;
@ -551,13 +583,17 @@ export interface IUserResponse {
signInType?: SignInType;
}
export interface CurrentUserResponse extends IUserResponse {
featureFlags?: FeatureFlags;
}
export interface IUser extends IUserResponse {
isDefaultUser: boolean;
isPendingUser: boolean;
isOwner: boolean;
inviteAcceptUrl?: string;
fullName?: string;
createdAt?: Date;
createdAt?: string;
}
export interface IVersionNotificationSettings {
@ -704,6 +740,14 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
posthog: {
enabled: boolean;
apiHost: string;
apiKey: string;
autocapture: boolean;
disableSessionRecording: boolean;
debug: boolean;
};
executionMode: string;
pushBackend: 'sse' | 'websocket';
communityNodesEnabled: boolean;

View file

@ -1,4 +1,5 @@
import {
CurrentUserResponse,
IInviteResponse,
IPersonalizationLatestVersion,
IRestApiContext,
@ -7,14 +8,14 @@ import {
import { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils';
export function loginCurrentUser(context: IRestApiContext): Promise<IUserResponse | null> {
export function loginCurrentUser(context: IRestApiContext): Promise<CurrentUserResponse | null> {
return makeRestApiRequest(context, 'GET', '/login');
}
export function login(
context: IRestApiContext,
params: { email: string; password: string },
): Promise<IUserResponse> {
): Promise<CurrentUserResponse> {
return makeRestApiRequest(context, 'POST', '/login', params);
}
@ -55,7 +56,7 @@ export function signup(
lastName: string;
password: string;
},
): Promise<IUserResponse> {
): Promise<CurrentUserResponse> {
const { inviteeId, ...props } = params;
return makeRestApiRequest(
context,

View file

@ -53,11 +53,6 @@ export default mixins(externalHooks).extend({
versionCli: this.rootStore.versionCli,
});
this.$externalHooks().run('telemetry.currentUserIdChanged', {
instanceId: this.rootStore.instanceId,
userId: this.currentUserId,
});
this.isTelemetryInitialized = true;
},
},
@ -69,10 +64,6 @@ export default mixins(externalHooks).extend({
if (this.isTelemetryEnabled) {
this.$telemetry.identify(this.rootStore.instanceId, userId);
}
this.$externalHooks().run('telemetry.currentUserIdChanged', {
instanceId: this.rootStore.instanceId,
userId,
});
},
isTelemetryEnabledOnRoute(enabled) {
if (enabled) {

View file

@ -506,4 +506,18 @@ export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms
export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE];
export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource';
export const POSTHOG_ASSUMPTION_TEST = 'adore-assumption-tests';
export const ASSUMPTION_EXPERIMENT = {
name: 'adore-assumption-tests-1',
control: 'control',
demo: 'assumption-demo',
video: 'assumption-video',
};
export const ONBOARDING_EXPERIMENT = {
name: 'onboarding-checklist',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [ASSUMPTION_EXPERIMENT.name, ONBOARDING_EXPERIMENT.name];

View file

@ -6,6 +6,7 @@ import type { INodeCreateElement } from '@/Interface';
import type { IUserNodesPanelSession } from './telemetry.types';
import { useSettingsStore } from '@/stores/settings';
import { useRootStore } from '@/stores/n8nRootStore';
import { useTelemetryStore } from '@/stores/telemetry';
export class Telemetry {
private pageEventQueue: Array<{ route: Route }>;
@ -60,6 +61,7 @@ export class Telemetry {
configUrl: 'https://api-rs.n8n.io',
...logging,
});
useTelemetryStore().init(this);
this.identify(instanceId, userId, versionCli);

View file

@ -9,10 +9,6 @@ declare module 'vue/types/vue' {
declare global {
interface Window {
rudderanalytics: RudderStack;
posthog: {
isFeatureEnabled(flagName: string): boolean;
getFeatureFlag(flagName: string): boolean | string;
};
}
}

View file

@ -0,0 +1,124 @@
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 { FeatureFlags } from 'n8n-workflow';
import { EXPERIMENTS_TO_TRACK } from '@/constants';
import { useTelemetryStore } from './telemetry';
export const usePostHogStore = defineStore('posthog', () => {
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const telemetryStore = useTelemetryStore();
const rootStore = useRootStore();
const featureFlags: Ref<FeatureFlags | null> = ref(null);
const initialized: Ref<boolean> = ref(false);
const trackedDemoExp: Ref<FeatureFlags> = ref({});
const reset = () => {
window.posthog?.reset?.();
featureFlags.value = null;
trackedDemoExp.value = {};
};
const getVariant = (experiment: keyof FeatureFlags): FeatureFlags[keyof FeatureFlags] => {
return featureFlags.value?.[experiment];
};
const isVariantEnabled = (experiment: string, variant: string) => {
return getVariant(experiment) === variant;
};
const identify = () => {
const instanceId = rootStore.instanceId;
const user = usersStore.currentUser;
const traits: Record<string, string | number> = { instance_id: instanceId };
if (user && typeof user.createdAt === 'string') {
traits.created_at_timestamp = new Date(user.createdAt).getTime();
}
// For PostHog, main ID _cannot_ be `undefined` as done for RudderStack.
const id = user ? `${instanceId}#${user.id}` : instanceId;
window.posthog?.identify?.(id, traits);
};
const init = (evaluatedFeatureFlags?: FeatureFlags) => {
if (!window.posthog) {
return;
}
const config = settingsStore.settings.posthog;
if (!config.enabled) {
return;
}
const userId = usersStore.currentUserId;
if (!userId) {
return;
}
const instanceId = rootStore.instanceId;
const distinctId = `${instanceId}#${userId}`;
const options: Parameters<typeof window.posthog.init>[1] = {
api_host: config.apiHost,
autocapture: config.autocapture,
disable_session_recording: config.disableSessionRecording,
debug: config.debug,
};
if (evaluatedFeatureFlags) {
featureFlags.value = evaluatedFeatureFlags;
options.bootstrap = {
distinctId,
featureFlags: evaluatedFeatureFlags,
};
}
window.posthog?.init(config.apiKey, options);
identify();
initialized.value = true;
};
const trackExperiment = (name: string) => {
const curr = featureFlags.value;
const prev = trackedDemoExp.value;
if (!curr || curr[name] === undefined) {
return;
}
if (curr[name] === prev[name]) {
return;
}
const variant = curr[name];
telemetryStore.track('User is part of experiment', {
name,
variant,
});
trackedDemoExp.value[name] = variant;
};
watch(
() => featureFlags.value,
() => {
setTimeout(() => {
EXPERIMENTS_TO_TRACK.forEach(trackExperiment);
}, 0);
},
);
return {
init,
isVariantEnabled,
getVariant,
reset,
};
});

View file

@ -0,0 +1,21 @@
import type { Telemetry } from '@/plugins/telemetry';
import { ITelemetryTrackProperties } from 'n8n-workflow';
import { defineStore } from 'pinia';
import { ref, Ref } from 'vue';
export const useTelemetryStore = defineStore('telemetry', () => {
const telemetry: Ref<Telemetry | undefined> = ref();
const init = (tel: Telemetry) => {
telemetry.value = tel;
};
const track = (event: string, properties?: ITelemetryTrackProperties) => {
telemetry.value?.track(event, properties);
};
return {
init,
track,
};
});

View file

@ -34,6 +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 { useSettingsStore } from './settings';
import { useUIStore } from './ui';
@ -141,23 +142,32 @@ export const useUsersStore = defineStore(STORES.USERS, {
async loginWithCookie(): Promise<void> {
const rootStore = useRootStore();
const user = await loginCurrentUser(rootStore.getRestApiContext);
if (user) {
this.addUsers([user]);
this.currentUserId = user.id;
if (!user) {
return;
}
this.addUsers([user]);
this.currentUserId = user.id;
usePostHogStore().init(user.featureFlags);
},
async loginWithCreds(params: { email: string; password: string }): Promise<void> {
const rootStore = useRootStore();
const user = await login(rootStore.getRestApiContext, params);
if (user) {
this.addUsers([user]);
this.currentUserId = user.id;
if (!user) {
return;
}
this.addUsers([user]);
this.currentUserId = user.id;
usePostHogStore().init(user.featureFlags);
},
async logout(): Promise<void> {
const rootStore = useRootStore();
await logout(rootStore.getRestApiContext);
this.currentUserId = null;
usePostHogStore().reset();
},
async preOwnerSetup() {
return preOwnerSetup(useRootStore().getRestApiContext);
@ -197,6 +207,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
this.addUsers([user]);
this.currentUserId = user.id;
}
usePostHogStore().init(user.featureFlags);
},
async sendForgotPasswordEmail(params: { email: string }): Promise<void> {
const rootStore = useRootStore();

View file

@ -195,7 +195,7 @@ import {
WEBHOOK_NODE_TYPE,
TRIGGER_NODE_FILTER,
EnterpriseEditionFeature,
POSTHOG_ASSUMPTION_TEST,
ASSUMPTION_EXPERIMENT,
REGULAR_NODE_FILTER,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
@ -300,6 +300,7 @@ import {
ready,
} from '@jsplumb/browser-ui';
import { N8nPlusEndpoint } from '@/plugins/endpoints/N8nPlusEndpointType';
import { usePostHogStore } from '@/stores/posthog';
interface AddNodeOptions {
position?: XYPosition;
@ -2458,7 +2459,9 @@ export default mixins(
},
async tryToAddWelcomeSticky(): Promise<void> {
const newWorkflow = this.workflowData;
if (window.posthog?.getFeatureFlag?.(POSTHOG_ASSUMPTION_TEST) === 'assumption-video') {
if (
usePostHogStore().isVariantEnabled(ASSUMPTION_EXPERIMENT.name, ASSUMPTION_EXPERIMENT.video)
) {
// For novice users (onboardingFlowEnabled == true)
// Inject welcome sticky note and zoom to fit

View file

@ -127,7 +127,7 @@ import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
import TemplateCard from '@/components/TemplateCard.vue';
import { EnterpriseEditionFeature, POSTHOG_ASSUMPTION_TEST, VIEWS } from '@/constants';
import { EnterpriseEditionFeature, ASSUMPTION_EXPERIMENT, VIEWS } from '@/constants';
import { debounceHelper } from '@/mixins/debounce';
import Vue from 'vue';
import { ITag, IUser, IWorkflowDb } from '@/Interface';
@ -137,6 +137,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';
type IResourcesListLayoutInstance = Vue & { sendFiltersTelemetry: (source: string) => void };
@ -183,7 +184,10 @@ export default mixins(showMessage, debounceHelper).extend({
return !!this.workflowsStore.activeWorkflows.length;
},
isDemoTest(): boolean {
return window.posthog?.getFeatureFlag?.(POSTHOG_ASSUMPTION_TEST) === 'assumption-demo';
return usePostHogStore().isVariantEnabled(
ASSUMPTION_EXPERIMENT.name,
ASSUMPTION_EXPERIMENT.demo,
);
},
statusFilterOptions(): Array<{ label: string; value: string | boolean }> {
return [

View file

@ -1812,6 +1812,10 @@ export interface ITelemetrySettings {
config?: ITelemetryClientConfig;
}
export interface FeatureFlags {
[featureFlag: string]: string | boolean | undefined;
}
export interface IConnectedNode {
name: string;
indicies: number[];