Skip to content

Commit

Permalink
Extend metrics for HTTP Request node (#3282)
Browse files Browse the repository at this point in the history
* ⚡ Extend metrics

* 🧪 Add tests

* ⚡ Update param names
  • Loading branch information
ivov authored May 19, 2022
1 parent 2ac022e commit 9389a32
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 8 deletions.
10 changes: 9 additions & 1 deletion packages/editor-ui/src/components/NodeCredentials.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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] : {};
Expand Down
27 changes: 24 additions & 3 deletions packages/editor-ui/src/components/mixins/showMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> {
Expand Down Expand Up @@ -203,5 +215,14 @@ export const showMessage = mixins(externalHooks).extend({
</details>
`;
},

/**
* 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');
},
},
});
6 changes: 5 additions & 1 deletion packages/workflow/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
91 changes: 88 additions & 3 deletions packages/workflow/src/TelemetryHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,74 @@ function areOverlapping(
);
}

const URL_PARTS_REGEX = /(?<protocolPlusDomain>.*?\..*?)(?<pathnamePlusQs>\/.*)/;

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,
Expand Down Expand Up @@ -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);

Expand Down
176 changes: 176 additions & 0 deletions packages/workflow/test/TelemetryHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -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('[email protected]');

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('');
}

0 comments on commit 9389a32

Please sign in to comment.