diff --git a/packages/editor-ui/src/components/NodeCredentials.vue b/packages/editor-ui/src/components/NodeCredentials.vue index 623580172bbaf..953a5dc082dc0 100644 --- a/packages/editor-ui/src/components/NodeCredentials.vue +++ b/packages/editor-ui/src/components/NodeCredentials.vue @@ -209,7 +209,15 @@ export default mixins( return; } - this.$telemetry.track('User selected credential from node modal', { credential_type: credentialType, workflow_id: this.$store.getters.workflowId }); + this.$telemetry.track( + 'User selected credential from node modal', + { + credential_type: credentialType, + node_type: this.node.type, + ...(this.isProxyAuth ? { is_service_specific: true } : {}), + workflow_id: this.$store.getters.workflowId, + }, + ); const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId); const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {}; diff --git a/packages/editor-ui/src/components/mixins/showMessage.ts b/packages/editor-ui/src/components/mixins/showMessage.ts index d5d50b4af0527..e949329d7cc99 100644 --- a/packages/editor-ui/src/components/mixins/showMessage.ts +++ b/packages/editor-ui/src/components/mixins/showMessage.ts @@ -27,8 +27,13 @@ export const showMessage = mixins(externalHooks).extend({ stickyNotificationQueue.push(notification); } - if(messageData.type === 'error' && track) { - this.$telemetry.track('Instance FE emitted error', { error_title: messageData.title, error_message: messageData.message, workflow_id: this.$store.getters.workflowId }); + if (messageData.type === 'error' && track) { + this.$telemetry.track('Instance FE emitted error', { + error_title: messageData.title, + error_message: messageData.message, + caused_by_credential: this.causedByCredential(messageData.message), + workflow_id: this.$store.getters.workflowId, + }); } return notification; @@ -135,7 +140,14 @@ export const showMessage = mixins(externalHooks).extend({ message, errorMessage: error.message, }); - this.$telemetry.track('Instance FE emitted error', { error_title: title, error_description: message, error_message: error.message, workflow_id: this.$store.getters.workflowId }); + + this.$telemetry.track('Instance FE emitted error', { + error_title: title, + error_description: message, + error_message: error.message, + caused_by_credential: this.causedByCredential(error.message), + workflow_id: this.$store.getters.workflowId, + }); }, async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string): Promise { @@ -203,5 +215,14 @@ export const showMessage = mixins(externalHooks).extend({ `; }, + + /** + * Whether a workflow execution error was caused by a credential issue, as reflected by the error message. + */ + causedByCredential(message: string | undefined) { + if (!message) return false; + + return message.includes('Credentials for') && message.includes('are not set'); + }, }, }); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index cf909f255252f..4a75a03c2a16e 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1437,9 +1437,13 @@ export interface INodeGraphItem { type: string; resource?: string; operation?: string; - domain?: string; + domain?: string; // HTTP Request node v1 + domain_base?: string; // HTTP Request node v2 + domain_path?: string; // HTTP Request node v2 position: [number, number]; mode?: string; + credential_type?: string; // HTTP Request node v2 + credential_set?: boolean; // HTTP Request node v2 } export interface INodeNameIndex { diff --git a/packages/workflow/src/TelemetryHelpers.ts b/packages/workflow/src/TelemetryHelpers.ts index 600e53bb86fce..50af3365b5b53 100644 --- a/packages/workflow/src/TelemetryHelpers.ts +++ b/packages/workflow/src/TelemetryHelpers.ts @@ -60,6 +60,74 @@ function areOverlapping( ); } +const URL_PARTS_REGEX = /(?.*?\..*?)(?\/.*)/; + +export function getDomainBase(raw: string, urlParts = URL_PARTS_REGEX): string { + try { + const url = new URL(raw); + + return [url.protocol, url.hostname].join('//'); + } catch (_) { + const match = urlParts.exec(raw); + + if (!match?.groups?.protocolPlusDomain) return ''; + + return match.groups.protocolPlusDomain; + } +} + +function isSensitive(segment: string) { + return /%40/.test(segment) || /^\d+$/.test(segment) || /^[0-9A-F]{8}/i.test(segment); +} + +export const ANONYMIZATION_CHARACTER = '*'; + +function sanitizeRoute(raw: string, check = isSensitive, char = ANONYMIZATION_CHARACTER) { + return raw + .split('/') + .map((segment) => (check(segment) ? char.repeat(segment.length) : segment)) + .join('/'); +} + +function sanitizeQuery(raw: string, check = isSensitive, char = ANONYMIZATION_CHARACTER) { + return raw + .split('&') + .map((segment) => { + const [key, value] = segment.split('='); + return [key, check(value) ? char.repeat(value.length) : value].join('='); + }) + .join('&'); +} + +function sanitizeUrl(raw: string) { + if (/\?/.test(raw)) { + const [route, query] = raw.split('?'); + + return [sanitizeRoute(route), sanitizeQuery(query)].join('?'); + } + + return sanitizeRoute(raw); +} + +/** + * Return pathname plus query string from URL, anonymizing IDs in route and query params. + */ +export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string { + try { + const url = new URL(raw); + + if (!url.hostname) throw new Error('Malformed URL'); + + return sanitizeUrl(url.pathname + url.search); + } catch (_) { + const match = urlParts.exec(raw); + + if (!match?.groups?.pathnamePlusQs) return ''; + + return sanitizeUrl(match.groups.pathnamePlusQs); + } +} + export function generateNodesGraph( workflow: IWorkflowBase, nodeTypes: INodeTypes, @@ -100,12 +168,29 @@ export function generateNodesGraph( position: node.position, }; - if (node.type === 'n8n-nodes-base.httpRequest') { + if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) { try { nodeItem.domain = new URL(node.parameters.url as string).hostname; - } catch (e) { - nodeItem.domain = node.parameters.url as string; + } catch (_) { + nodeItem.domain = getDomainBase(node.parameters.url as string); } + } else if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 2) { + const { authentication } = node.parameters as { authentication: string }; + + nodeItem.credential_type = { + none: 'none', + genericCredentialType: node.parameters.genericAuthType as string, + existingCredentialType: node.parameters.nodeCredentialType as string, + }[authentication]; + + nodeItem.credential_set = node.credentials + ? Object.keys(node.credentials).length > 0 + : false; + + const { url } = node.parameters as { url: string }; + + nodeItem.domain_base = getDomainBase(url); + nodeItem.domain_path = getDomainPath(url); } else { const nodeType = nodeTypes.getByNameAndVersion(node.type); diff --git a/packages/workflow/test/TelemetryHelpers.test.ts b/packages/workflow/test/TelemetryHelpers.test.ts new file mode 100644 index 0000000000000..4adc7d7cf4e90 --- /dev/null +++ b/packages/workflow/test/TelemetryHelpers.test.ts @@ -0,0 +1,176 @@ +import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid'; +import { + ANONYMIZATION_CHARACTER as CHAR, + getDomainBase, + getDomainPath, +} from '../src/TelemetryHelpers'; + +describe('getDomainBase should return protocol plus domain', () => { + test('in valid URLs', () => { + for (const url of validUrls(numericId)) { + const { full, protocolPlusDomain } = url; + expect(getDomainBase(full)).toBe(protocolPlusDomain); + } + }); + + test('in malformed URLs', () => { + for (const url of malformedUrls(numericId)) { + const { full, protocolPlusDomain } = url; + expect(getDomainBase(full)).toBe(protocolPlusDomain); + } + }); +}); + +describe('getDomainPath should return pathname plus query string', () => { + describe('anonymizing numeric IDs', () => { + test('in valid URLs', () => { + for (const url of validUrls(numericId)) { + const { full, pathnamePlusQs } = url; + expect(getDomainPath(full)).toBe(pathnamePlusQs); + } + }); + + test('in malformed URLs', () => { + for (const url of malformedUrls(numericId)) { + const { full, pathnamePlusQs } = url; + expect(getDomainPath(full)).toBe(pathnamePlusQs); + } + }); + }); + + describe('anonymizing UUIDs', () => { + test('in valid URLs', () => { + for (const url of uuidUrls(validUrls)) { + const { full, pathnamePlusQs } = url; + expect(getDomainPath(full)).toBe(pathnamePlusQs); + } + }); + + test('in malformed URLs', () => { + for (const url of uuidUrls(malformedUrls)) { + const { full, pathnamePlusQs } = url; + expect(getDomainPath(full)).toBe(pathnamePlusQs); + } + }); + }); + + describe('anonymizing emails', () => { + test('in valid URLs', () => { + for (const url of validUrls(email)) { + const { full, pathnamePlusQs } = url; + expect(getDomainPath(full)).toBe(pathnamePlusQs); + } + }); + + test('in malformed URLs', () => { + for (const url of malformedUrls(email)) { + const { full, pathnamePlusQs } = url; + expect(getDomainPath(full)).toBe(pathnamePlusQs); + } + }); + }); +}); + +function validUrls(idMaker: typeof numericId | typeof email, char = CHAR) { + const firstId = idMaker(); + const secondId = idMaker(); + const firstIdObscured = char.repeat(firstId.length); + const secondIdObscured = char.repeat(secondId.length); + + return [ + { + full: `https://test.com/api/v1/users/${firstId}`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}`, + }, + { + full: `https://test.com/api/v1/users/${firstId}/`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}/`, + }, + { + full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`, + }, + { + full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`, + }, + { + full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`, + }, + { + full: `https://test.com/api/v1/users?id=${firstId}`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users?id=${firstIdObscured}`, + }, + { + full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`, + protocolPlusDomain: 'https://test.com', + pathnamePlusQs: `/api/v1/users?id=${firstIdObscured}&post=${secondIdObscured}`, + }, + ]; +} + +function malformedUrls(idMaker: typeof numericId | typeof email, char = CHAR) { + const firstId = idMaker(); + const secondId = idMaker(); + const firstIdObscured = char.repeat(firstId.length); + const secondIdObscured = char.repeat(secondId.length); + + return [ + { + full: `test.com/api/v1/users/${firstId}/posts/${secondId}/`, + protocolPlusDomain: 'test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`, + }, + { + full: `htp://test.com/api/v1/users/${firstId}/posts/${secondId}/`, + protocolPlusDomain: 'htp://test.com', + pathnamePlusQs: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`, + }, + { + full: `test.com/api/v1/users?id=${firstId}`, + protocolPlusDomain: 'test.com', + pathnamePlusQs: `/api/v1/users?id=${firstIdObscured}`, + }, + { + full: `test.com/api/v1/users?id=${firstId}&post=${secondId}`, + protocolPlusDomain: 'test.com', + pathnamePlusQs: `/api/v1/users?id=${firstIdObscured}&post=${secondIdObscured}`, + }, + ]; +} + +const email = () => encodeURIComponent('test@test.com'); + +function uuidUrls( + urlsMaker: typeof validUrls | typeof malformedUrls, + baseName = 'test', + namespaceUuid = uuidv4(), +) { + return [ + ...urlsMaker(() => uuidv5(baseName, namespaceUuid)), + ...urlsMaker(uuidv4), + ...urlsMaker(() => uuidv3(baseName, namespaceUuid)), + ...urlsMaker(uuidv1), + ]; +} + +function digit() { + return Math.floor(Math.random() * 10); +} + +function positiveDigit(): number { + const d = digit(); + + return d === 0 ? positiveDigit() : d; +} + +function numericId(length = positiveDigit()) { + return Array.from({ length }, digit).join(''); +}