mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
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:
parent
ee21b7a1cf
commit
26a20ed47e
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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('/')
|
||||
|
|
57
packages/cli/src/posthog/index.ts
Normal file
57
packages/cli/src/posthog/index.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>`;
|
|
@ -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 }));
|
||||
|
|
90
packages/cli/test/unit/PostHog.test.ts
Normal file
90
packages/cli/test/unit/PostHog.test.ts
Normal 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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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: () => {},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
124
packages/editor-ui/src/stores/posthog.ts
Normal file
124
packages/editor-ui/src/stores/posthog.ts
Normal 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,
|
||||
};
|
||||
});
|
21
packages/editor-ui/src/stores/telemetry.ts
Normal file
21
packages/editor-ui/src/stores/telemetry.ts
Normal 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,
|
||||
};
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -1812,6 +1812,10 @@ export interface ITelemetrySettings {
|
|||
config?: ITelemetryClientConfig;
|
||||
}
|
||||
|
||||
export interface FeatureFlags {
|
||||
[featureFlag: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface IConnectedNode {
|
||||
name: string;
|
||||
indicies: number[];
|
||||
|
|
Loading…
Reference in a new issue