✨ 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:
@@ -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 {
|
||||
|
||||
105
packages/cli/src/InternalHooks.ts
Normal file
105
packages/cli/src/InternalHooks.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
packages/cli/src/InternalHooksManager.ts
Normal file
23
packages/cli/src/InternalHooksManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
63
packages/cli/src/PersonalizationSurvey.ts
Normal file
63
packages/cli/src/PersonalizationSurvey.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
151
packages/cli/src/telemetry/index.ts
Normal file
151
packages/cli/src/telemetry/index.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user