From 26a20ed47e8f580504b80150d7550ecb9a49be0d Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Tue, 21 Feb 2023 11:35:35 +0300 Subject: [PATCH] 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 --- packages/cli/src/Interfaces.ts | 13 ++ packages/cli/src/InternalHooksManager.ts | 9 +- packages/cli/src/Server.ts | 19 ++- .../UserManagement/UserManagementHelper.ts | 28 +++- packages/cli/src/WorkflowRunnerProcess.ts | 7 +- packages/cli/src/commands/BaseCommand.ts | 7 +- packages/cli/src/commands/start.ts | 15 --- .../cli/src/controllers/auth.controller.ts | 23 +++- .../cli/src/controllers/users.controller.ts | 9 +- packages/cli/src/posthog/index.ts | 57 ++++++++ packages/cli/src/telemetry/index.ts | 36 +---- packages/cli/src/telemetry/scripts.ts | 17 --- packages/cli/test/integration/shared/utils.ts | 6 +- packages/cli/test/unit/PostHog.test.ts | 90 +++++++++++++ packages/cli/test/unit/Telemetry.test.ts | 8 +- packages/editor-ui/index.html | 2 + packages/editor-ui/src/App.vue | 17 +-- packages/editor-ui/src/Interface.ts | 46 ++++++- packages/editor-ui/src/api/users.ts | 7 +- .../editor-ui/src/components/Telemetry.vue | 9 -- packages/editor-ui/src/constants.ts | 16 ++- .../editor-ui/src/plugins/telemetry/index.ts | 2 + .../src/plugins/telemetry/telemetry.types.ts | 4 - packages/editor-ui/src/stores/posthog.ts | 124 ++++++++++++++++++ packages/editor-ui/src/stores/telemetry.ts | 21 +++ packages/editor-ui/src/stores/users.ts | 24 +++- packages/editor-ui/src/views/NodeView.vue | 7 +- .../editor-ui/src/views/WorkflowsView.vue | 8 +- packages/workflow/src/Interfaces.ts | 4 + 29 files changed, 513 insertions(+), 122 deletions(-) create mode 100644 packages/cli/src/posthog/index.ts delete mode 100644 packages/cli/src/telemetry/scripts.ts create mode 100644 packages/cli/test/unit/PostHog.test.ts create mode 100644 packages/editor-ui/src/stores/posthog.ts create mode 100644 packages/editor-ui/src/stores/telemetry.ts diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 8ec9b0ccbd..7910ef38e2 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -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; diff --git a/packages/cli/src/InternalHooksManager.ts b/packages/cli/src/InternalHooksManager.ts index a30a12c65e..e78564a3d7 100644 --- a/packages/cli/src/InternalHooksManager.ts +++ b/packages/cli/src/InternalHooksManager.ts @@ -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 { + static async init( + instanceId: string, + nodeTypes: INodeTypes, + postHog: PostHogClient, + ): Promise { if (!this.internalHooksInstance) { - const telemetry = new Telemetry(instanceId); + const telemetry = new Telemetry(instanceId, postHog); await telemetry.init(); this.internalHooksInstance = new InternalHooksClass(telemetry, instanceId, nodeTypes); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 87e9b7463e..8d2b740a20 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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'); diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index a38a6ed310..bd1c2d3881 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -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 { 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 { + 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((resolve) => { + setTimeout(() => { + resolve(user); + }, 1500); + }); + + const fetchPromise = new Promise(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); diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index b8fd32f773..fe9a5eb64a 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -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); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 65b6dd27fc..13037cbf6c 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -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), diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index c3761f02e7..0c0c37b537 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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 = ''; const compileFile = async (fileName: string) => { const filePath = path.join(EDITOR_UI_DIST_DIR, fileName); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 05edd7f663..0db2cbee35 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -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; + private readonly postHog?: PostHogClient; + constructor({ config, logger, internalHooks, repositories, + postHog, }: { config: Config; logger: ILogger; internalHooks: IInternalHooksClass; repositories: Pick; + 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 { + async currentUser(req: Request, res: Response): Promise { // 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)); } /** diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index a71682ffb4..ddb8a5c6cc 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -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('/') diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts new file mode 100644 index 0000000000..a84173be24 --- /dev/null +++ b/packages/cli/src/posthog/index.ts @@ -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 { + 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): Promise { + 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(), + }, + }); + } +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 66376b67f8..981798474d 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -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 { clearInterval(this.pulseIntervalReference); void this.track('User instance stopped'); - return new Promise((resolve) => { - if (this.postHog) { - this.postHog.shutdown(); - } + return new Promise(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 { - 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; } diff --git a/packages/cli/src/telemetry/scripts.ts b/packages/cli/src/telemetry/scripts.ts deleted file mode 100644 index 04c014ead0..0000000000 --- a/packages/cli/src/telemetry/scripts.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Create a script to init PostHog, for embedding before the Vue bundle in `` in `index.html`. - */ -export const createPostHogLoadingScript = ({ - apiKey, - apiHost, - autocapture, - disableSessionRecording, - debug, -}: { - apiKey: string; - apiHost: string; - autocapture: boolean; - disableSessionRecording: boolean; - debug: boolean; -}) => - ``; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index adbd5986cb..3407d1b82f 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -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 })); diff --git a/packages/cli/test/unit/PostHog.test.ts b/packages/cli/test/unit/PostHog.test.ts new file mode 100644 index 0000000000..47e736014e --- /dev/null +++ b/packages/cli/test/unit/PostHog.test.ts @@ -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(), + }, + } + ); + }); +}); \ No newline at end of file diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index 8c671eff08..67f4fec532 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -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: () => {}, diff --git a/packages/editor-ui/index.html b/packages/editor-ui/index.html index 0dc44757dc..35923d36b5 100644 --- a/packages/editor-ui/index.html +++ b/packages/editor-ui/index.html @@ -8,6 +8,8 @@ + + n8n.io - Workflow Automation diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 358717f13a..fa1d40a00e 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -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) { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 2a972f8ba1..dffc17b4ca 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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, + userPropertiesOnce?: Record, + ): 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; diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index bc14132d20..50b7b9043e 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -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 { +export function loginCurrentUser(context: IRestApiContext): Promise { return makeRestApiRequest(context, 'GET', '/login'); } export function login( context: IRestApiContext, params: { email: string; password: string }, -): Promise { +): Promise { return makeRestApiRequest(context, 'POST', '/login', params); } @@ -55,7 +56,7 @@ export function signup( lastName: string; password: string; }, -): Promise { +): Promise { const { inviteeId, ...props } = params; return makeRestApiRequest( context, diff --git a/packages/editor-ui/src/components/Telemetry.vue b/packages/editor-ui/src/components/Telemetry.vue index a2084af1bc..26b5279832 100644 --- a/packages/editor-ui/src/components/Telemetry.vue +++ b/packages/editor-ui/src/components/Telemetry.vue @@ -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) { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 0a11af64c5..b97ba14864 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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]; diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index 1958b591db..f4fcbdfb3c 100644 --- a/packages/editor-ui/src/plugins/telemetry/index.ts +++ b/packages/editor-ui/src/plugins/telemetry/index.ts @@ -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); diff --git a/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts b/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts index aedd823e6d..960875d920 100644 --- a/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts +++ b/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts @@ -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; - }; } } diff --git a/packages/editor-ui/src/stores/posthog.ts b/packages/editor-ui/src/stores/posthog.ts new file mode 100644 index 0000000000..d476bb64d7 --- /dev/null +++ b/packages/editor-ui/src/stores/posthog.ts @@ -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 = ref(null); + const initialized: Ref = ref(false); + const trackedDemoExp: Ref = 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 = { 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[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, + }; +}); diff --git a/packages/editor-ui/src/stores/telemetry.ts b/packages/editor-ui/src/stores/telemetry.ts new file mode 100644 index 0000000000..26338651a4 --- /dev/null +++ b/packages/editor-ui/src/stores/telemetry.ts @@ -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 = ref(); + + const init = (tel: Telemetry) => { + telemetry.value = tel; + }; + + const track = (event: string, properties?: ITelemetryTrackProperties) => { + telemetry.value?.track(event, properties); + }; + + return { + init, + track, + }; +}); diff --git a/packages/editor-ui/src/stores/users.ts b/packages/editor-ui/src/stores/users.ts index 9c7cf2b50c..8d3963c866 100644 --- a/packages/editor-ui/src/stores/users.ts +++ b/packages/editor-ui/src/stores/users.ts @@ -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 { 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 { 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 { 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 { const rootStore = useRootStore(); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 34fd4062b1..0dd1d2bea5 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -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 { 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 diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index abe2a082ad..ca7143202a 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -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 [ diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index b113d0d843..1f9372c2c3 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1812,6 +1812,10 @@ export interface ITelemetrySettings { config?: ITelemetryClientConfig; } +export interface FeatureFlags { + [featureFlag: string]: string | boolean | undefined; +} + export interface IConnectedNode { name: string; indicies: number[];