Skip to content

Commit 864f869

Browse files
authored
feat(core): Add input/output token tracking to node graph telemetry (#27992)
1 parent 5a11c58 commit 864f869

10 files changed

Lines changed: 522 additions & 6 deletions

File tree

packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/message.operation.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
INodeExecutionData,
66
INodeProperties,
77
} from 'n8n-workflow';
8-
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
8+
import { accumulateTokenUsage, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
99
import zodToJsonSchema from 'zod-to-json-schema';
1010

1111
import { getConnectedTools } from '@utils/helpers';
@@ -343,6 +343,17 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
343343
enableAnthropicBetas: { codeExecution: options.codeExecution },
344344
})) as MessagesResponse;
345345

346+
const captureUsage = () => {
347+
const usage = (response as unknown as Record<string, unknown>).usage as
348+
| { input_tokens: number; output_tokens: number }
349+
| undefined;
350+
if (usage) {
351+
accumulateTokenUsage(this, usage.input_tokens, usage.output_tokens);
352+
}
353+
};
354+
355+
captureUsage();
356+
346357
const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number;
347358
const abortSignal = this.getExecutionCancelSignal();
348359
let currentIteration = 0;
@@ -382,6 +393,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
382393
body,
383394
enableAnthropicBetas: { codeExecution: options.codeExecution },
384395
})) as MessagesResponse;
396+
397+
captureUsage();
385398
}
386399

387400
const mergedResponse = options.includeMergedResponse

packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type IExecuteFunctions,
55
type INodeExecutionData,
66
type INodeProperties,
7+
accumulateTokenUsage,
78
jsonParse,
89
updateDisplayOptions,
910
validateNodeParameters,
@@ -510,6 +511,21 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
510511
body,
511512
})) as GenerateContentResponse;
512513

514+
const captureUsage = () => {
515+
const usageMetadata = (response as unknown as Record<string, unknown>).usageMetadata as
516+
| { promptTokenCount: number; candidatesTokenCount: number }
517+
| undefined;
518+
if (usageMetadata) {
519+
accumulateTokenUsage(
520+
this,
521+
usageMetadata.promptTokenCount,
522+
usageMetadata.candidatesTokenCount,
523+
);
524+
}
525+
};
526+
527+
captureUsage();
528+
513529
const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number;
514530
const abortSignal = this.getExecutionCancelSignal();
515531
let currentIteration = 1;
@@ -551,6 +567,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
551567
response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, {
552568
body,
553569
})) as GenerateContentResponse;
570+
captureUsage();
554571
toolCalls = getToolCalls(response);
555572
currentIteration++;
556573
}

packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/message.operation.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Tool } from '@langchain/core/tools';
22
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
3-
import { updateDisplayOptions } from 'n8n-workflow';
3+
import { accumulateTokenUsage, updateDisplayOptions } from 'n8n-workflow';
44
import { zodToJsonSchema } from 'zod-to-json-schema';
55

66
import { getConnectedTools } from '@utils/helpers';
@@ -404,6 +404,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
404404
body,
405405
});
406406

407+
if (response.prompt_eval_count != null || response.eval_count != null) {
408+
accumulateTokenUsage(this, response.prompt_eval_count ?? 0, response.eval_count ?? 0);
409+
}
410+
407411
if (tools.length > 0 && response.message.tool_calls && response.message.tool_calls.length > 0) {
408412
const toolCalls = response.message.tool_calls;
409413

@@ -449,6 +453,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
449453
response = await apiRequest.call(this, 'POST', '/api/chat', {
450454
body: updatedBody,
451455
});
456+
457+
if (response.prompt_eval_count != null || response.eval_count != null) {
458+
accumulateTokenUsage(this, response.prompt_eval_count ?? 0, response.eval_count ?? 0);
459+
}
452460
}
453461

454462
if (simplify) {

packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/text/message.operation.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
INodeExecutionData,
77
IDataObject,
88
} from 'n8n-workflow';
9-
import { jsonParse, updateDisplayOptions } from 'n8n-workflow';
9+
import { accumulateTokenUsage, jsonParse, updateDisplayOptions } from 'n8n-workflow';
1010

1111
import { getConnectedTools } from '@utils/helpers';
1212

@@ -293,6 +293,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
293293

294294
if (!response) return [];
295295

296+
if (response.usage) {
297+
accumulateTokenUsage(this, response.usage.prompt_tokens, response.usage.completion_tokens);
298+
}
299+
296300
let currentIteration = 1;
297301
let toolCalls = response?.choices[0]?.message?.tool_calls;
298302

@@ -334,6 +338,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
334338
body,
335339
})) as ChatCompletion;
336340

341+
if (response.usage) {
342+
accumulateTokenUsage(this, response.usage.prompt_tokens, response.usage.completion_tokens);
343+
}
344+
337345
toolCalls = response.choices[0].message.tool_calls;
338346
currentIteration += 1;
339347
}

packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v2/actions/text/response.operation.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import type {
77
INodeExecutionData,
88
INodeProperties,
99
} from 'n8n-workflow';
10-
import { jsonParse, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
10+
import {
11+
accumulateTokenUsage,
12+
jsonParse,
13+
NodeOperationError,
14+
updateDisplayOptions,
15+
} from 'n8n-workflow';
1116
import { MODELS_NOT_SUPPORT_FUNCTION_CALLS } from '../../../helpers/constants';
1217
import type { ChatResponse } from '../../../helpers/interfaces';
1318
import { formatToOpenAIResponsesTool } from '../../../helpers/utils';
@@ -630,6 +635,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
630635

631636
if (!response) return [];
632637

638+
if (response.usage) {
639+
accumulateTokenUsage(this, response.usage.input_tokens, response.usage.output_tokens);
640+
}
641+
633642
// reasoning models such as gpt5 include reasoning items that must be included in the request
634643
const isToolRelatedCall: (item: { type: string }) => boolean = (item) =>
635644
item.type === 'function_call' || item.type === 'reasoning';
@@ -681,6 +690,11 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
681690
response = (await apiRequest.call(this, 'POST', '/responses', {
682691
body,
683692
})) as ChatResponse;
693+
694+
if (response.usage) {
695+
accumulateTokenUsage(this, response.usage.input_tokens, response.usage.output_tokens);
696+
}
697+
684698
toolCalls = response.output.filter(isToolRelatedCall);
685699

686700
currentIteration++;

packages/workflow/src/interfaces.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2780,6 +2780,15 @@ export interface ITaskMetadata {
27802780
*/
27812781
resumeUrl?: string;
27822782

2783+
/**
2784+
* AI model token usage captured from vendor node API responses before the simplify step
2785+
* strips it. Used by telemetry to populate ai_input_tokens / ai_output_tokens in node_graph_string.
2786+
*/
2787+
tokenUsage?: {
2788+
inputTokens: number;
2789+
outputTokens: number;
2790+
};
2791+
27832792
/**
27842793
* Key-value pairs that can be set for tracing - they will be attached to the OTEL node span
27852794
* */
@@ -3258,6 +3267,8 @@ export interface INodeGraphItem {
32583267
used_guardrails?: string[]; // only for @n8n/n8n-nodes-langchain.guardrails
32593268
mcp_client_auth_method?: string; // for @n8n/n8n-nodes-langchain.mcpClientTool and @n8n/n8n-nodes-langchain.mcpClient
32603269
ai_model?: string; // AI model for model nodes and standalone AI nodes
3270+
ai_input_tokens?: number; // AI input (prompt) tokens for model nodes
3271+
ai_output_tokens?: number; // AI output (completion) tokens for model nodes
32613272
}
32623273

32633274
export interface INodeNameIndex {

packages/workflow/src/metadata-utils.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ITaskMetadata } from '.';
1+
import type { IExecuteFunctions, ITaskMetadata } from '.';
22
import { hasKey } from './utils';
33

44
function responseHasSubworkflowData(
@@ -23,6 +23,20 @@ function parseErrorResponseWorkflowMetadata(response: unknown): ISubWorkflowMeta
2323
};
2424
}
2525

26+
export function accumulateTokenUsage(
27+
context: IExecuteFunctions,
28+
inputTokens: number,
29+
outputTokens: number,
30+
): void {
31+
const prev = context.getExecuteData()?.metadata?.tokenUsage;
32+
context.setMetadata({
33+
tokenUsage: {
34+
inputTokens: (prev?.inputTokens ?? 0) + inputTokens,
35+
outputTokens: (prev?.outputTokens ?? 0) + outputTokens,
36+
},
37+
});
38+
}
39+
2640
export function parseErrorMetadata(error: unknown): ISubWorkflowMetadata | undefined {
2741
if (hasKey(error, 'errorResponse')) {
2842
return parseErrorResponseWorkflowMetadata(error.errorResponse);

packages/workflow/src/telemetry-helpers.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ export function isNumber(value: unknown): value is number {
6262
return typeof value === 'number';
6363
}
6464

65+
function isTokenUsage(value: unknown): value is { promptTokens: number; completionTokens: number } {
66+
return (
67+
typeof value === 'object' &&
68+
value !== null &&
69+
'promptTokens' in value &&
70+
typeof value.promptTokens === 'number' &&
71+
'completionTokens' in value &&
72+
typeof value.completionTokens === 'number'
73+
);
74+
}
75+
6576
function resolveParameterValue(value: unknown): string | undefined {
6677
if (typeof value === 'string') {
6778
return value;
@@ -74,6 +85,32 @@ function resolveParameterValue(value: unknown): string | undefined {
7485
return undefined;
7586
}
7687

88+
function extractNodeTokenUsage(
89+
nodeRunData: ITaskData[],
90+
): { input: number; output: number } | undefined {
91+
let input = 0;
92+
let output = 0;
93+
for (const task of nodeRunData) {
94+
const lmOutputs = task.data?.[NodeConnectionTypes.AiLanguageModel];
95+
if (lmOutputs) {
96+
// LM sub-nodes (connected to Agent/Chain) — token data from N8nLlmTracing
97+
for (const branch of lmOutputs) {
98+
for (const item of branch ?? []) {
99+
const usage = item?.json?.tokenUsage ?? item?.json?.tokenUsageEstimate;
100+
if (!isTokenUsage(usage)) continue;
101+
input += usage.promptTokens;
102+
output += usage.completionTokens;
103+
}
104+
}
105+
} else if (task.metadata?.tokenUsage) {
106+
// Standalone vendor nodes — token data captured via setMetadata before simplify
107+
input += task.metadata.tokenUsage.inputTokens ?? 0;
108+
output += task.metadata.tokenUsage.outputTokens ?? 0;
109+
}
110+
}
111+
return input > 0 || output > 0 ? { input, output } : undefined;
112+
}
113+
77114
const countPlaceholders = (text: string) => {
78115
const placeholder = /(\{[a-zA-Z0-9_]+\})/g;
79116
let returnData = 0;
@@ -613,6 +650,14 @@ export function generateNodesGraph(
613650
}
614651
}
615652

653+
if (nodeItem.ai_model && runData?.[node.name]) {
654+
const tokenUsage = extractNodeTokenUsage(runData[node.name]);
655+
if (tokenUsage) {
656+
nodeItem.ai_input_tokens = tokenUsage.input;
657+
nodeItem.ai_output_tokens = tokenUsage.output;
658+
}
659+
}
660+
616661
if (options?.isCloudDeployment === true) {
617662
if (node.type === OPENAI_LANGCHAIN_NODE_TYPE) {
618663
nodeItem.prompts =

packages/workflow/test/metadata-utils.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,52 @@
1-
import { parseErrorMetadata } from '../src/metadata-utils';
1+
import type { IExecuteData, IExecuteFunctions, ITaskMetadata } from '../src/interfaces';
2+
import { accumulateTokenUsage, parseErrorMetadata } from '../src/metadata-utils';
3+
4+
function createMockContext(metadata?: ITaskMetadata): IExecuteFunctions {
5+
const executeData: IExecuteData = {
6+
data: {},
7+
node: {} as IExecuteData['node'],
8+
source: null,
9+
metadata,
10+
};
11+
return {
12+
getExecuteData: () => executeData,
13+
setMetadata(newMetadata: ITaskMetadata) {
14+
executeData.metadata = { ...executeData.metadata, ...newMetadata };
15+
},
16+
} as unknown as IExecuteFunctions;
17+
}
218

319
describe('MetadataUtils', () => {
20+
describe('accumulateTokenUsage', () => {
21+
it('should set token usage when no previous metadata exists', () => {
22+
const context = createMockContext();
23+
accumulateTokenUsage(context, 100, 50);
24+
expect(context.getExecuteData().metadata?.tokenUsage).toEqual({
25+
inputTokens: 100,
26+
outputTokens: 50,
27+
});
28+
});
29+
30+
it('should accumulate tokens across multiple calls', () => {
31+
const context = createMockContext();
32+
accumulateTokenUsage(context, 100, 50);
33+
accumulateTokenUsage(context, 200, 80);
34+
accumulateTokenUsage(context, 50, 20);
35+
expect(context.getExecuteData().metadata?.tokenUsage).toEqual({
36+
inputTokens: 350,
37+
outputTokens: 150,
38+
});
39+
});
40+
41+
it('should preserve existing metadata fields', () => {
42+
const context = createMockContext({ subExecutionsCount: 3 });
43+
accumulateTokenUsage(context, 100, 50);
44+
const metadata = context.getExecuteData().metadata;
45+
expect(metadata?.subExecutionsCount).toBe(3);
46+
expect(metadata?.tokenUsage).toEqual({ inputTokens: 100, outputTokens: 50 });
47+
});
48+
});
49+
450
describe('parseMetadataFromError', () => {
551
const expectedMetadata = {
652
subExecution: {

0 commit comments

Comments
 (0)