Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add more telemetry to free AI credits feature (no-changelog) #12493

Merged
merged 3 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,3 @@ export const WsStatusCodes = {
} as const;

export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
3 changes: 2 additions & 1 deletion packages/cli/src/controllers/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
} from '@n8n/api-types';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web';

import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { Body, Post, RestController } from '@/decorators';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/events/__tests__/telemetry-event-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1622,5 +1622,74 @@ describe('TelemetryEventRelay', () => {
}),
);
});

it('should call telemetry.track when user ran out of free AI credits', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);

const runData = {
status: 'error',
mode: 'trigger',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;

jest
.spyOn(TelemetryHelpers, 'userInInstanceRanOutOfFreeAiCredits')
.mockImplementation(() => true);

const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};

eventService.emit('workflow-post-execute', event);

await flushPromises();

expect(telemetry.track).toHaveBeenCalledWith('User ran out of free AI credits');
});
});
});
4 changes: 4 additions & 0 deletions packages/cli/src/events/relays/telemetry.event-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,10 @@ export class TelemetryEventRelay extends EventRelay {
let nodeGraphResult: INodesGraphResult | null = null;

if (!telemetryProperties.success && runData?.data.resultData.error) {
if (TelemetryHelpers.userInInstanceRanOutOfFreeAiCredits(runData)) {
this.telemetry.track('User ran out of free AI credits');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will fire for every execution that fails after user ran out of credit.
The event name sounds like it should only fire once, because the user only runs out of credits once.
Maybe the event name should be different "Execution errored because user ran out of free AI credits"
That being said, it's not a showstopper. Happy to approve and merge this so it makes it into the release.

}

telemetryProperties.error_message = runData?.data.resultData.error.message;
let errorNodeName =
'node' in runData?.data.resultData.error
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/test/integration/ai/ai.api.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Container } from '@n8n/di';
import { randomUUID } from 'crypto';
import { mock } from 'jest-mock-extended';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';

import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
Expand Down
3 changes: 1 addition & 2 deletions packages/editor-ui/src/components/FreeAiCreditsCallout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, ref } from 'vue';

const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';

const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';

Expand Down
4 changes: 4 additions & 0 deletions packages/workflow/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,7 @@ export const AI_TRANSFORM_JS_CODE = 'jsCode';
* in `cli` package.
*/
export const TRIMMED_TASK_DATA_CONNECTIONS_KEY = '__isTrimmedManualExecutionDataItem';

export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
export const FREE_AI_CREDITS_ERROR_TYPE = 'free_ai_credits_request_error';
export const FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE = 400;
36 changes: 36 additions & 0 deletions packages/workflow/src/TelemetryHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import {
CHAIN_LLM_LANGCHAIN_NODE_TYPE,
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
EXECUTE_WORKFLOW_NODE_TYPE,
FREE_AI_CREDITS_ERROR_TYPE,
FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE,
HTTP_REQUEST_NODE_TYPE,
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
LANGCHAIN_CUSTOM_TOOLS,
MERGE_NODE_TYPE,
OPEN_AI_API_CREDENTIAL_TYPE,
OPENAI_LANGCHAIN_NODE_TYPE,
STICKY_NODE_TYPE,
WEBHOOK_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
} from './Constants';
import { ApplicationError } from './errors/application.error';
import type { NodeApiError } from './errors/node-api.error';
import type {
IConnection,
INode,
Expand All @@ -29,6 +33,10 @@ import type {
IRun,
} from './Interfaces';
import { getNodeParameters } from './NodeHelpers';
import { jsonParse } from './utils';

const isNodeApiError = (error: unknown): error is NodeApiError =>
typeof error === 'object' && error !== null && 'name' in error && error?.name === 'NodeApiError';

export function getNodeTypeForName(workflow: IWorkflowBase, nodeName: string): INode | undefined {
return workflow.nodes.find((node) => node.name === nodeName);
Expand Down Expand Up @@ -489,3 +497,31 @@ export function extractLastExecutedNodeCredentialData(

return { credentialId: id, credentialType };
}

export const userInInstanceRanOutOfFreeAiCredits = (runData: IRun): boolean => {
const credentials = extractLastExecutedNodeCredentialData(runData);

if (!credentials) return false;

if (credentials.credentialType !== OPEN_AI_API_CREDENTIAL_TYPE) return false;

const { error } = runData.data.resultData;

if (!isNodeApiError(error) || !error.messages[0]) return false;

const rawErrorResponse = error.messages[0].replace(`${error.httpCode} -`, '');

try {
const errorResponse = jsonParse<{ error: { code: number; type: string } }>(rawErrorResponse);
if (
errorResponse?.error?.type === FREE_AI_CREDITS_ERROR_TYPE &&
errorResponse.error.code === FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE
) {
return true;
}
} catch {
return false;
}

return false;
};
Loading
Loading