Introduce telemetry (#2099)

* introduce analytics

* add user survey backend

* add user survey backend

* set answers on survey submit

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>

* change name to personalization

* lint

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>

* N8n 2495 add personalization modal (#2280)

* update modals

* add onboarding modal

* implement questions

* introduce analytics

* simplify impl

* implement survey handling

* add personalized cateogry

* update modal behavior

* add thank you view

* handle empty cases

* rename modal

* standarize modal names

* update image, add tags to headings

* remove unused file

* remove unused interfaces

* clean up footer spacing

* introduce analytics

* refactor to fix bug

* update endpoint

* set min height

* update stories

* update naming from questions to survey

* remove spacing after core categories

* fix bug in logic

* sort nodes

* rename types

* merge with be

* rename userSurvey

* clean up rest api

* use constants for keys

* use survey keys

* clean up types

* move personalization to its own file

Co-authored-by: ahsan-virani <ahsan.virani@gmail.com>

* Survey new options (#2300)

* split up options

* fix quotes

* remove unused import

* add user created workflow event (#2301)

* simplify env vars

* fix versionCli on FE

* update personalization env

* fix event User opened Credentials panel

* fix select modal spacing

* fix nodes panel event

* fix workflow id in workflow execute event

* improve telemetry error logging

* fix config and stop process events

* add flush call on n8n stop

* ready for release

* improve telemetry process exit

* fix merge

* improve n8n stop events

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ahsan Virani
2021-10-19 05:57:49 +02:00
committed by GitHub
parent 4b857b19ac
commit 421dd72224
100 changed files with 2223 additions and 550 deletions

View File

@@ -11,6 +11,7 @@ import {
IRunData,
IRunExecutionData,
ITaskData,
ITelemetrySettings,
IWorkflowBase as IWorkflowBaseWorkflow,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IWorkflowCredentials,
@@ -281,6 +282,40 @@ export interface IExternalHooksClass {
run(hookName: string, hookParameters?: any[]): Promise<void>;
}
export interface IDiagnosticInfo {
versionCli: string;
databaseType: DatabaseType;
notificationsEnabled: boolean;
disableProductionWebhooksOnMainProcess: boolean;
basicAuthActive: boolean;
systemInfo: {
os: {
type?: string;
version?: string;
};
memory?: number;
cpus: {
count?: number;
model?: string;
speed?: number;
};
};
executionVariables: {
[key: string]: string | number | undefined;
};
deploymentType: string;
}
export interface IInternalHooksClass {
onN8nStop(): Promise<void>;
onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<void>;
onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void>;
onWorkflowCreated(workflow: IWorkflowBase): Promise<void>;
onWorkflowDeleted(workflowId: string): Promise<void>;
onWorkflowSaved(workflow: IWorkflowBase): Promise<void>;
onWorkflowPostExecute(workflow: IWorkflowBase, runData?: IRun): Promise<void>;
}
export interface IN8nConfig {
database: IN8nConfigDatabase;
endpoints: IN8nConfigEndpoints;
@@ -357,6 +392,20 @@ export interface IN8nUISettings {
};
versionNotifications: IVersionNotificationSettings;
instanceId: string;
telemetry: ITelemetrySettings;
personalizationSurvey: IPersonalizationSurvey;
}
export interface IPersonalizationSurveyAnswers {
companySize: string | null;
codingSkill: string | null;
workArea: string | null;
otherWorkArea: string | null;
}
export interface IPersonalizationSurvey {
answers?: IPersonalizationSurveyAnswers;
shouldShow: boolean;
}
export interface IPackageVersions {

View File

@@ -0,0 +1,105 @@
/* eslint-disable import/no-cycle */
import { IDataObject, IRun, TelemetryHelpers } from 'n8n-workflow';
import {
IDiagnosticInfo,
IInternalHooksClass,
IPersonalizationSurveyAnswers,
IWorkflowBase,
} from '.';
import { Telemetry } from './telemetry';
export class InternalHooksClass implements IInternalHooksClass {
constructor(private telemetry: Telemetry) {}
async onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<void> {
const info = {
version_cli: diagnosticInfo.versionCli,
db_type: diagnosticInfo.databaseType,
n8n_version_notifications_enabled: diagnosticInfo.notificationsEnabled,
n8n_disable_production_main_process: diagnosticInfo.disableProductionWebhooksOnMainProcess,
n8n_basic_auth_active: diagnosticInfo.basicAuthActive,
system_info: diagnosticInfo.systemInfo,
execution_variables: diagnosticInfo.executionVariables,
n8n_deployment_type: diagnosticInfo.deploymentType,
};
await this.telemetry.identify(info);
await this.telemetry.track('Instance started', info);
}
async onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void> {
await this.telemetry.track('User responded to personalization questions', {
company_size: answers.companySize,
coding_skill: answers.codingSkill,
work_area: answers.workArea,
other_work_area: answers.otherWorkArea,
});
}
async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> {
await this.telemetry.track('User created workflow', {
workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph,
});
}
async onWorkflowDeleted(workflowId: string): Promise<void> {
await this.telemetry.track('User deleted workflow', {
workflow_id: workflowId,
});
}
async onWorkflowSaved(workflow: IWorkflowBase): Promise<void> {
await this.telemetry.track('User saved workflow', {
workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph,
});
}
async onWorkflowPostExecute(workflow: IWorkflowBase, runData?: IRun): Promise<void> {
const properties: IDataObject = {
workflow_id: workflow.id,
is_manual: false,
};
if (runData !== undefined) {
properties.execution_mode = runData.mode;
if (runData.mode === 'manual') {
properties.is_manual = true;
}
properties.success = !!runData.finished;
if (!properties.success && runData?.data.resultData.error) {
properties.error_message = runData?.data.resultData.error.message;
let errorNodeName = runData?.data.resultData.error.node?.name;
properties.error_node_type = runData?.data.resultData.error.node?.type;
if (runData.data.resultData.lastNodeExecuted) {
const lastNode = TelemetryHelpers.getNodeTypeForName(
workflow,
runData.data.resultData.lastNodeExecuted,
);
if (lastNode !== undefined) {
properties.error_node_type = lastNode.type;
errorNodeName = lastNode.name;
}
}
if (properties.is_manual) {
const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow);
properties.node_graph = nodeGraphResult.nodeGraph;
if (errorNodeName) {
properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName];
}
}
}
}
void this.telemetry.trackWorkflowExecution(properties);
}
async onN8nStop(): Promise<void> {
await this.telemetry.trackN8nStop();
}
}

View File

@@ -0,0 +1,23 @@
/* eslint-disable import/no-cycle */
import { InternalHooksClass } from './InternalHooks';
import { Telemetry } from './telemetry';
export class InternalHooksManager {
private static internalHooksInstance: InternalHooksClass;
static getInstance(): InternalHooksClass {
if (this.internalHooksInstance) {
return this.internalHooksInstance;
}
throw new Error('InternalHooks not initialized');
}
static init(instanceId: string): InternalHooksClass {
if (!this.internalHooksInstance) {
this.internalHooksInstance = new InternalHooksClass(new Telemetry(instanceId));
}
return this.internalHooksInstance;
}
}

View File

@@ -0,0 +1,63 @@
import { readFileSync, writeFile } from 'fs';
import { promisify } from 'util';
import { UserSettings } from 'n8n-core';
import * as config from '../config';
// eslint-disable-next-line import/no-cycle
import { Db, IPersonalizationSurvey, IPersonalizationSurveyAnswers } from '.';
const fsWriteFile = promisify(writeFile);
const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json';
function loadSurveyFromDisk(): IPersonalizationSurveyAnswers | undefined {
const userSettingsPath = UserSettings.getUserN8nFolderPath();
try {
const surveyFile = readFileSync(
`${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`,
'utf-8',
);
return JSON.parse(surveyFile) as IPersonalizationSurveyAnswers;
} catch (error) {
return undefined;
}
}
export async function writeSurveyToDisk(
surveyAnswers: IPersonalizationSurveyAnswers,
): Promise<void> {
const userSettingsPath = UserSettings.getUserN8nFolderPath();
await fsWriteFile(
`${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`,
JSON.stringify(surveyAnswers, null, '\t'),
);
}
export async function preparePersonalizationSurvey(): Promise<IPersonalizationSurvey> {
const survey: IPersonalizationSurvey = {
shouldShow: false,
};
survey.answers = loadSurveyFromDisk();
if (survey.answers) {
return survey;
}
const enabled =
(config.get('personalization.enabled') as boolean) &&
(config.get('diagnostics.enabled') as boolean);
if (!enabled) {
return survey;
}
const workflowsExist = !!(await Db.collections.Workflow?.findOne());
if (workflowsExist) {
return survey;
}
survey.shouldShow = true;
return survey;
}

View File

@@ -90,13 +90,13 @@ export function sendSuccessResponse(
}
}
export function sendErrorResponse(res: Response, error: ResponseError) {
export function sendErrorResponse(res: Response, error: ResponseError, shouldLog = true) {
let httpStatusCode = 500;
if (error.httpStatusCode) {
httpStatusCode = error.httpStatusCode;
}
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && shouldLog) {
console.error('ERROR RESPONSE');
console.error(error);
}

View File

@@ -28,17 +28,18 @@ import * as express from 'express';
import { readFileSync } from 'fs';
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path';
import {
getConnectionManager,
In,
Like,
FindManyOptions,
FindOneOptions,
getConnectionManager,
In,
IsNull,
LessThanOrEqual,
Like,
Not,
} from 'typeorm';
import * as bodyParser from 'body-parser';
import * as history from 'connect-history-api-fallback';
import * as os from 'os';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as _ from 'lodash';
import * as clientOAuth2 from 'client-oauth2';
@@ -74,6 +75,8 @@ import {
INodeTypeNameVersion,
IRunData,
INodeVersionedType,
ITelemetryClientConfig,
ITelemetrySettings,
IWorkflowBase,
IWorkflowCredentials,
LoggerProxy,
@@ -124,11 +127,13 @@ import {
IExecutionsStopData,
IExecutionsSummary,
IExternalHooksClass,
IDiagnosticInfo,
IN8nUISettings,
IPackageVersions,
ITagWithCountDb,
IWorkflowExecutionDataProcess,
IWorkflowResponse,
IPersonalizationSurveyAnswers,
LoadNodesAndCredentials,
NodeTypes,
Push,
@@ -142,9 +147,13 @@ import {
WorkflowHelpers,
WorkflowRunner,
} from '.';
import * as config from '../config';
import * as TagHelpers from './TagHelpers';
import * as PersonalizationSurvey from './PersonalizationSurvey';
import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { NameRequest } from './WorkflowHelpers';
@@ -243,6 +252,22 @@ class App {
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
const telemetrySettings: ITelemetrySettings = {
enabled: config.get('diagnostics.enabled') as boolean,
};
if (telemetrySettings.enabled) {
const conf = config.get('diagnostics.config.frontend') as string;
const [key, url] = conf.split(';');
if (!key || !url) {
LoggerProxy.warn('Diagnostics frontend config is invalid');
telemetrySettings.enabled = false;
}
telemetrySettings.config = { key, url };
}
this.frontendSettings = {
endpointWebhook: this.endpointWebhook,
endpointWebhookTest: this.endpointWebhookTest,
@@ -264,6 +289,10 @@ class App {
infoUrl: config.get('versionNotifications.infoUrl'),
},
instanceId: '',
telemetry: telemetrySettings,
personalizationSurvey: {
shouldShow: false,
},
};
}
@@ -290,7 +319,13 @@ class App {
this.versions = await GenericHelpers.getVersions();
this.frontendSettings.versionCli = this.versions.cli;
this.frontendSettings.instanceId = (await generateInstanceId()) as string;
this.frontendSettings.instanceId = await UserSettings.getInstanceId();
this.frontendSettings.personalizationSurvey =
await PersonalizationSurvey.preparePersonalizationSurvey();
InternalHooksManager.init(this.frontendSettings.instanceId);
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
@@ -458,10 +493,13 @@ class App {
};
jwt.verify(token, getKey, jwtVerifyOptions, (err: jwt.VerifyErrors, decoded: object) => {
if (err) ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token');
else if (!isTenantAllowed(decoded))
if (err) {
ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token');
} else if (!isTenantAllowed(decoded)) {
ResponseHelper.jwtAuthAuthorizationError(res, 'Tenant not allowed');
else next();
} else {
next();
}
});
});
}
@@ -656,6 +694,7 @@ class App {
// @ts-ignore
savedWorkflow.id = savedWorkflow.id.toString();
void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase);
return savedWorkflow;
},
),
@@ -858,12 +897,12 @@ class App {
}
await this.externalHooks.run('workflow.afterUpdate', [workflow]);
void InternalHooksManager.getInstance().onWorkflowSaved(workflow as IWorkflowBase);
if (workflow.active) {
// When the workflow is supposed to be active add it again
try {
await this.externalHooks.run('workflow.activate', [workflow]);
await this.activeWorkflowRunner.add(id, isActive ? 'update' : 'activate');
} catch (error) {
// If workflow could not be activated set it again to inactive
@@ -901,6 +940,7 @@ class App {
}
await Db.collections.Workflow!.delete(id);
void InternalHooksManager.getInstance().onWorkflowDeleted(id);
await this.externalHooks.run('workflow.afterDelete', [id]);
return true;
@@ -2601,6 +2641,31 @@ class App {
),
);
// ----------------------------------------
// User Survey
// ----------------------------------------
// Process personalization survey responses
this.app.post(
`/${this.restEndpoint}/user-survey`,
async (req: express.Request, res: express.Response) => {
if (!this.frontendSettings.personalizationSurvey.shouldShow) {
ResponseHelper.sendErrorResponse(
res,
new ResponseHelper.ResponseError('User survey already submitted', undefined, 400),
false,
);
}
const answers = req.body as IPersonalizationSurveyAnswers;
await PersonalizationSurvey.writeSurveyToDisk(answers);
this.frontendSettings.personalizationSurvey.shouldShow = false;
this.frontendSettings.personalizationSurvey.answers = answers;
ResponseHelper.sendSuccessResponse(res, undefined, true, 200);
void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted(answers);
},
);
// ----------------------------------------
// Webhooks
// ----------------------------------------
@@ -2810,6 +2875,43 @@ export async function start(): Promise<void> {
console.log(`Version: ${versions.cli}`);
await app.externalHooks.run('n8n.ready', [app]);
const cpus = os.cpus();
const diagnosticInfo: IDiagnosticInfo = {
basicAuthActive: config.get('security.basicAuth.active') as boolean,
databaseType: (await GenericHelpers.getConfigValue('database.type')) as DatabaseType,
disableProductionWebhooksOnMainProcess:
config.get('endpoints.disableProductionWebhooksOnMainProcess') === true,
notificationsEnabled: config.get('versionNotifications.enabled') === true,
versionCli: versions.cli,
systemInfo: {
os: {
type: os.type(),
version: os.version(),
},
memory: os.totalmem() / 1024,
cpus: {
count: cpus.length,
model: cpus[0].model,
speed: cpus[0].speed,
},
},
executionVariables: {
executions_process: config.get('executions.process'),
executions_mode: config.get('executions.mode'),
executions_timeout: config.get('executions.timeout'),
executions_timeout_max: config.get('executions.maxTimeout'),
executions_data_save_on_error: config.get('executions.saveDataOnError'),
executions_data_save_on_success: config.get('executions.saveDataOnSuccess'),
executions_data_save_on_progress: config.get('executions.saveExecutionProgress'),
executions_data_save_manual_executions: config.get('executions.saveDataManualExecutions'),
executions_data_prune: config.get('executions.pruneData'),
executions_data_max_age: config.get('executions.pruneDataMaxAge'),
executions_data_prune_timeout: config.get('executions.pruneDataTimeout'),
},
deploymentType: config.get('deployment.type'),
};
void InternalHooksManager.getInstance().onServerStarted(diagnosticInfo);
});
}
@@ -2848,14 +2950,3 @@ async function getExecutionsCount(
const count = await Db.collections.Execution!.count(countFilter);
return { count, estimate: false };
}
async function generateInstanceId() {
const encryptionKey = await UserSettings.getEncryptionKey();
const hash = encryptionKey
? createHash('sha256')
.update(encryptionKey.slice(Math.round(encryptionKey.length / 2)))
.digest('hex')
: undefined;
return hash;
}

View File

@@ -48,6 +48,7 @@ import {
IExecutionDb,
IExecutionFlattedDb,
IExecutionResponse,
InternalHooksManager,
IPushDataExecutionFinished,
IWorkflowBase,
IWorkflowExecuteProcess,
@@ -903,6 +904,7 @@ export async function executeWorkflow(
}
await externalHooks.run('workflow.postExecute', [data, workflowData]);
void InternalHooksManager.getInstance().onWorkflowPostExecute(workflowData, data);
if (data.finished === true) {
// Workflow did finish successfully

View File

@@ -16,7 +16,6 @@ import { IProcessMessage, WorkflowExecute } from 'n8n-core';
import {
ExecutionError,
IRun,
IWorkflowBase,
LoggerProxy as Logger,
Workflow,
WorkflowExecuteMode,
@@ -56,6 +55,7 @@ import {
WorkflowHelpers,
} from '.';
import * as Queue from './Queue';
import { InternalHooksManager } from './InternalHooksManager';
export class WorkflowRunner {
activeExecutions: ActiveExecutions.ActiveExecutions;
@@ -160,10 +160,22 @@ export class WorkflowRunner {
executionId = await this.runSubprocess(data, loadStaticData, executionId);
}
const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId);
const externalHooks = ExternalHooks();
postExecutePromise
.then(async (executionData) => {
void InternalHooksManager.getInstance().onWorkflowPostExecute(
data.workflowData,
executionData,
);
})
.catch((error) => {
console.error('There was a problem running internal hook "onWorkflowPostExecute"', error);
});
if (externalHooks.exists('workflow.postExecute')) {
this.activeExecutions
.getPostExecutePromise(executionId)
postExecutePromise
.then(async (executionData) => {
await externalHooks.run('workflow.postExecute', [executionData, data.workflowData]);
})

View File

@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/unbound-method */
import { IProcessMessage, WorkflowExecute } from 'n8n-core';
import { IProcessMessage, UserSettings, WorkflowExecute } from 'n8n-core';
import {
ExecutionError,
@@ -40,6 +40,7 @@ import {
import { getLogger } from './Logger';
import * as config from '../config';
import { InternalHooksManager } from './InternalHooksManager';
export class WorkflowRunnerProcess {
data: IWorkflowExecutionDataProcessWithExecution | undefined;
@@ -133,6 +134,9 @@ export class WorkflowRunnerProcess {
const externalHooks = ExternalHooks();
await externalHooks.init();
const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? '';
InternalHooksManager.init(instanceId);
// Credentials should now be loaded from database.
// We check if any node uses credentials. If it does, then
// init database.
@@ -243,6 +247,7 @@ export class WorkflowRunnerProcess {
const { workflow } = executeWorkflowFunctionOutput;
result = await workflowExecute.processRunExecutionData(workflow);
await externalHooks.run('workflow.postExecute', [result, workflowData]);
void InternalHooksManager.getInstance().onWorkflowPostExecute(workflowData, result);
await sendToParentProcess('finishExecution', { executionId, result });
delete this.childExecutions[executionId];
} catch (e) {

View File

@@ -5,6 +5,7 @@ export * from './CredentialTypes';
export * from './CredentialsOverwrites';
export * from './ExternalHooks';
export * from './Interfaces';
export * from './InternalHooksManager';
export * from './LoadNodesAndCredentials';
export * from './NodeTypes';
export * from './WaitTracker';

View File

@@ -0,0 +1,151 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import TelemetryClient = require('@rudderstack/rudder-sdk-node');
import { IDataObject, LoggerProxy } from 'n8n-workflow';
import config = require('../../config');
import { getLogger } from '../Logger';
interface IExecutionCountsBufferItem {
manual_success_count: number;
manual_error_count: number;
prod_success_count: number;
prod_error_count: number;
}
interface IExecutionCountsBuffer {
[workflowId: string]: IExecutionCountsBufferItem;
}
export class Telemetry {
private client?: TelemetryClient;
private instanceId: string;
private pulseIntervalReference: NodeJS.Timeout;
private executionCountsBuffer: IExecutionCountsBuffer = {};
constructor(instanceId: string) {
this.instanceId = instanceId;
const enabled = config.get('diagnostics.enabled') as boolean;
if (enabled) {
const conf = config.get('diagnostics.config.backend') as string;
const [key, url] = conf.split(';');
if (!key || !url) {
const logger = getLogger();
LoggerProxy.init(logger);
logger.warn('Diagnostics backend config is invalid');
return;
}
this.client = new TelemetryClient(key, url);
this.pulseIntervalReference = setInterval(async () => {
void this.pulse();
}, 6 * 60 * 60 * 1000); // every 6 hours
}
}
private async pulse(): Promise<unknown> {
if (!this.client) {
return Promise.resolve();
}
const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => {
const promise = this.track('Workflow execution count', {
workflow_id: workflowId,
...this.executionCountsBuffer[workflowId],
});
this.executionCountsBuffer[workflowId].manual_error_count = 0;
this.executionCountsBuffer[workflowId].manual_success_count = 0;
this.executionCountsBuffer[workflowId].prod_error_count = 0;
this.executionCountsBuffer[workflowId].prod_success_count = 0;
return promise;
});
allPromises.push(this.track('pulse'));
return Promise.all(allPromises);
}
async trackWorkflowExecution(properties: IDataObject): Promise<void> {
if (this.client) {
const workflowId = properties.workflow_id as string;
this.executionCountsBuffer[workflowId] = this.executionCountsBuffer[workflowId] ?? {
manual_error_count: 0,
manual_success_count: 0,
prod_error_count: 0,
prod_success_count: 0,
};
if (
properties.success === false &&
properties.error_node_type &&
(properties.error_node_type as string).startsWith('n8n-nodes-base')
) {
// errored exec
void this.track('Workflow execution errored', properties);
if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_error_count++;
} else {
this.executionCountsBuffer[workflowId].prod_error_count++;
}
} else if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_success_count++;
} else {
this.executionCountsBuffer[workflowId].prod_success_count++;
}
}
}
async trackN8nStop(): Promise<void> {
clearInterval(this.pulseIntervalReference);
void this.track('User instance stopped');
return new Promise<void>((resolve) => {
if (this.client) {
this.client.flush(resolve);
} else {
resolve();
}
});
}
async identify(traits?: IDataObject): Promise<void> {
return new Promise<void>((resolve) => {
if (this.client) {
this.client.identify(
{
userId: this.instanceId,
traits: {
...traits,
instanceId: this.instanceId,
},
},
resolve,
);
} else {
resolve();
}
});
}
async track(eventName: string, properties?: IDataObject): Promise<void> {
return new Promise<void>((resolve) => {
if (this.client) {
this.client.track(
{
userId: this.instanceId,
event: eventName,
properties,
},
resolve,
);
} else {
resolve();
}
});
}
}