@outputai/core 0.4.1-dev.06c2b50.0 → 0.4.1-dev.222a94b.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/activity_integration/tracing.d.ts +5 -11
- package/src/activity_integration/tracing.js +4 -10
- package/src/consts.js +4 -0
- package/src/interface/aggregations.js +24 -0
- package/src/interface/aggregations.spec.js +91 -0
- package/src/interface/workflow.js +35 -17
- package/src/interface/workflow.spec.js +183 -7
- package/src/tracing/processors/local/index.js +10 -4
- package/src/tracing/processors/local/index.spec.js +52 -21
- package/src/tracing/processors/s3/index.js +3 -3
- package/src/tracing/processors/s3/index.spec.js +26 -1
- package/src/tracing/processors/s3/s3_client.js +11 -3
- package/src/tracing/processors/s3/s3_client.spec.js +27 -15
- package/src/tracing/tools/build_trace_tree.js +1 -1
- package/src/tracing/tools/build_trace_tree.spec.js +49 -11
- package/src/tracing/tools/utils.js +0 -28
- package/src/tracing/tools/utils.spec.js +2 -134
- package/src/tracing/trace_attribute.d.ts +38 -0
- package/src/tracing/trace_attribute.js +80 -0
- package/src/tracing/trace_engine.js +12 -2
- package/src/worker/index.js +1 -1
- package/src/worker/index.spec.js +1 -1
- package/src/worker/interceptors/activity.js +9 -2
- package/src/worker/interceptors/activity.spec.js +16 -3
- package/src/worker/interceptors.js +2 -2
- package/src/tracing/tools/aggregate_trace_attributes.js +0 -118
- package/src/tracing/tools/aggregate_trace_attributes.spec.js +0 -231
- package/src/tracing/tools/index.js +0 -7
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Decimal from 'decimal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* All attributes inherit from this
|
|
5
|
+
*/
|
|
6
|
+
export class BaseAttribute {
|
|
7
|
+
activityId;
|
|
8
|
+
activityName;
|
|
9
|
+
date = Date.now();
|
|
10
|
+
type;
|
|
11
|
+
|
|
12
|
+
constructor( type ) {
|
|
13
|
+
this.type = type;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setActivity( id, name ) {
|
|
17
|
+
this.activityId = id;
|
|
18
|
+
this.activityName = name;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class HTTPRequestCount extends BaseAttribute {
|
|
23
|
+
static TYPE = 'http:request:count';
|
|
24
|
+
url;
|
|
25
|
+
requestId;
|
|
26
|
+
|
|
27
|
+
constructor( url, requestId ) {
|
|
28
|
+
super( HTTPRequestCount.TYPE );
|
|
29
|
+
this.url = url;
|
|
30
|
+
this.requestId = requestId;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class HTTPRequestCost extends BaseAttribute {
|
|
35
|
+
static TYPE = 'http:request:cost';
|
|
36
|
+
url;
|
|
37
|
+
requestId;
|
|
38
|
+
total = 0;
|
|
39
|
+
|
|
40
|
+
constructor( url, requestId, total ) {
|
|
41
|
+
super( HTTPRequestCost.TYPE );
|
|
42
|
+
this.url = url;
|
|
43
|
+
this.requestId = requestId;
|
|
44
|
+
this.total = total;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class LLMUsage extends BaseAttribute {
|
|
49
|
+
static TYPE = 'llm:usage';
|
|
50
|
+
modelId;
|
|
51
|
+
usage = [];
|
|
52
|
+
total = 0;
|
|
53
|
+
tokensUsed = 0;
|
|
54
|
+
|
|
55
|
+
constructor( modelId ) {
|
|
56
|
+
super( LLMUsage.TYPE );
|
|
57
|
+
this.modelId = modelId;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addUsage( { type, ppm, amount } ) {
|
|
61
|
+
const total = Decimal( amount ).div( 1_000_000 ).mul( ppm ).toNumber();
|
|
62
|
+
this.usage.push( {
|
|
63
|
+
type,
|
|
64
|
+
ppm,
|
|
65
|
+
amount,
|
|
66
|
+
total
|
|
67
|
+
} );
|
|
68
|
+
this.total = Decimal( this.total ).add( total ).toNumber();
|
|
69
|
+
this.tokensUsed = Decimal( this.tokensUsed ).add( amount ).toNumber();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Types of ADD_ATTR attributes
|
|
75
|
+
*/
|
|
76
|
+
export const Attribute = {
|
|
77
|
+
LLMUsage,
|
|
78
|
+
HTTPRequestCost,
|
|
79
|
+
HTTPRequestCount
|
|
80
|
+
};
|
|
@@ -4,8 +4,10 @@ import { serializeError } from './tools/utils.js';
|
|
|
4
4
|
import { isStringboolTrue } from '#utils';
|
|
5
5
|
import * as localProcessor from './processors/local/index.js';
|
|
6
6
|
import * as s3Processor from './processors/s3/index.js';
|
|
7
|
-
import { ComponentType } from '#consts';
|
|
7
|
+
import { ComponentType, Signal } from '#consts';
|
|
8
8
|
import { createChildLogger } from '#logger';
|
|
9
|
+
import { EventAction } from './trace_consts.js';
|
|
10
|
+
import { BaseAttribute } from './trace_attribute.js';
|
|
9
11
|
|
|
10
12
|
const log = createChildLogger( 'Tracing' );
|
|
11
13
|
|
|
@@ -91,7 +93,15 @@ export const addEventAction = ( action, { kind, name, id, parentId, details, exe
|
|
|
91
93
|
export function addEventActionWithContext( action, options ) {
|
|
92
94
|
const storeContent = Storage.load();
|
|
93
95
|
if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
|
|
94
|
-
const { parentId, executionContext } = storeContent;
|
|
96
|
+
const { parentId, parentName, executionContext, workflowHandle } = storeContent;
|
|
97
|
+
if ( action === EventAction.ADD_ATTR ) {
|
|
98
|
+
const attribute = options.details;
|
|
99
|
+
if ( !( attribute instanceof BaseAttribute ) ) {
|
|
100
|
+
throw new Error( `${EventAction.ADD_ATTR} called argument that is not a BaseAttribute instance` );
|
|
101
|
+
}
|
|
102
|
+
attribute.setActivity( parentId, parentName );
|
|
103
|
+
workflowHandle.signal( Signal.ADD_ATTRIBUTE, attribute );
|
|
104
|
+
}
|
|
95
105
|
addEventAction( action, { ...options, parentId, executionContext } );
|
|
96
106
|
}
|
|
97
107
|
};
|
package/src/worker/index.js
CHANGED
|
@@ -69,7 +69,7 @@ const callerDir = process.argv[2];
|
|
|
69
69
|
workflowsPath,
|
|
70
70
|
activities,
|
|
71
71
|
sinks,
|
|
72
|
-
interceptors: initInterceptors( { activities, workflows } ),
|
|
72
|
+
interceptors: initInterceptors( { activities, workflows, connection } ),
|
|
73
73
|
maxConcurrentWorkflowTaskExecutions,
|
|
74
74
|
maxConcurrentActivityTaskExecutions,
|
|
75
75
|
maxCachedWorkflows,
|
package/src/worker/index.spec.js
CHANGED
|
@@ -123,7 +123,7 @@ describe( 'worker/index', () => {
|
|
|
123
123
|
maxConcurrentActivityTaskPolls: configValues.maxConcurrentActivityTaskPolls,
|
|
124
124
|
maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
|
|
125
125
|
} ) );
|
|
126
|
-
expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [] } );
|
|
126
|
+
expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [], connection: mockConnection } );
|
|
127
127
|
expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
|
|
128
128
|
expect( startCatalogMock ).toHaveBeenCalledWith( {
|
|
129
129
|
connection: mockConnection,
|
|
@@ -5,6 +5,7 @@ import { headersToObject } from '../sandboxed_utils.js';
|
|
|
5
5
|
import { BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
6
6
|
import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
|
|
7
7
|
import { messageBus } from '#bus';
|
|
8
|
+
import { Client } from '@temporalio/client';
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
This interceptor wraps every activity execution with cross-cutting concerns:
|
|
@@ -23,7 +24,7 @@ import { messageBus } from '#bus';
|
|
|
23
24
|
- Headers injected by the workflow interceptor (executionContext)
|
|
24
25
|
*/
|
|
25
26
|
export class ActivityExecutionInterceptor {
|
|
26
|
-
constructor( { activities, workflows } ) {
|
|
27
|
+
constructor( { activities, workflows, connection } ) {
|
|
27
28
|
this.activities = activities;
|
|
28
29
|
this.workflowsMap = workflows.reduce( ( map, w ) => {
|
|
29
30
|
map.set( w.name, w );
|
|
@@ -32,14 +33,19 @@ export class ActivityExecutionInterceptor {
|
|
|
32
33
|
}
|
|
33
34
|
return map;
|
|
34
35
|
}, new Map() );
|
|
36
|
+
this.connection = connection;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
async execute( input, next ) {
|
|
38
40
|
const startDate = Date.now();
|
|
41
|
+
const client = new Client( { connection: this.connection } );
|
|
42
|
+
|
|
39
43
|
const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
|
|
40
44
|
const { executionContext } = headersToObject( input.headers );
|
|
41
45
|
const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
|
|
42
46
|
|
|
47
|
+
const workflowHandle = client.workflow.getHandle( workflowId );
|
|
48
|
+
|
|
43
49
|
messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
|
|
44
50
|
Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
|
|
45
51
|
|
|
@@ -56,7 +62,8 @@ export class ActivityExecutionInterceptor {
|
|
|
56
62
|
intervals.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
|
|
57
63
|
|
|
58
64
|
// Wraps the execution with accessible metadata for the activity
|
|
59
|
-
const
|
|
65
|
+
const ctx = { parentId: id, parentName: name, executionContext, workflowFilename, workflowHandle };
|
|
66
|
+
const output = await Storage.runWithContext( async _ => next( input ), ctx );
|
|
60
67
|
|
|
61
68
|
messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
|
|
62
69
|
Tracing.addEventEnd( { id, details: output, executionContext } );
|
|
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import { BusEventType } from '#consts';
|
|
3
3
|
|
|
4
4
|
const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
|
|
5
|
+
const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
|
|
6
|
+
const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
|
|
5
7
|
|
|
6
8
|
const heartbeatMock = vi.fn();
|
|
7
9
|
const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
|
|
@@ -21,6 +23,14 @@ vi.mock( '@temporalio/activity', () => ( {
|
|
|
21
23
|
}
|
|
22
24
|
} ) );
|
|
23
25
|
|
|
26
|
+
vi.mock( '@temporalio/client', () => ( {
|
|
27
|
+
Client: class Client {
|
|
28
|
+
workflow = {
|
|
29
|
+
getHandle: getHandleMock
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
} ) );
|
|
33
|
+
|
|
24
34
|
vi.mock( '#async_storage', () => ( {
|
|
25
35
|
Storage: {
|
|
26
36
|
runWithContext: runWithContextMock
|
|
@@ -108,12 +118,15 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
108
118
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
109
119
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
110
120
|
expect.any( Function ),
|
|
111
|
-
{
|
|
121
|
+
expect.objectContaining( {
|
|
112
122
|
parentId: 'act-1',
|
|
123
|
+
parentName: 'myWorkflow#myStep',
|
|
113
124
|
executionContext: { workflowId: 'wf-1' },
|
|
114
|
-
workflowFilename: '/workflows/myWorkflow.js'
|
|
115
|
-
|
|
125
|
+
workflowFilename: '/workflows/myWorkflow.js',
|
|
126
|
+
workflowHandle: workflowHandleMock
|
|
127
|
+
} )
|
|
116
128
|
);
|
|
129
|
+
expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
|
|
117
130
|
} );
|
|
118
131
|
|
|
119
132
|
it( 'records trace error event on failed execution', async () => {
|
|
@@ -4,7 +4,7 @@ import { ActivityExecutionInterceptor } from './interceptors/activity.js';
|
|
|
4
4
|
|
|
5
5
|
const __dirname = dirname( fileURLToPath( import.meta.url ) );
|
|
6
6
|
|
|
7
|
-
export const initInterceptors = ( { activities, workflows } ) => ( {
|
|
7
|
+
export const initInterceptors = ( { activities, workflows, connection } ) => ( {
|
|
8
8
|
workflowModules: [ join( __dirname, './interceptors/workflow.js' ) ],
|
|
9
|
-
activityInbound: [ () => new ActivityExecutionInterceptor( { activities, workflows } ) ]
|
|
9
|
+
activityInbound: [ () => new ActivityExecutionInterceptor( { activities, workflows, connection } ) ]
|
|
10
10
|
} );
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Aggregate `attributes.cost` and `attributes.token_usage` across an entire trace tree.
|
|
3
|
-
*
|
|
4
|
-
* Walks every node in the tree, sums `attributes.cost.total` grouped by the emitting
|
|
5
|
-
* event name (inferred from node `kind` — see `eventNameForKind`), and sums
|
|
6
|
-
* `attributes.token_usage` across LLM nodes. Falls back to `output.usage` on
|
|
7
|
-
* legacy llm trace nodes that predate the `attributes.token_usage` write
|
|
8
|
-
* (see overview §1.2).
|
|
9
|
-
*
|
|
10
|
-
* @typedef {object} TraceAttributes
|
|
11
|
-
* @property {{ total: number, components: Array<{ name: string, value: number }> }} cost
|
|
12
|
-
* @property {{ inputTokens: number, outputTokens: number, cachedInputTokens: number, totalTokens: number }} tokenUsage
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const COST_EVENT_LLM = 'cost:llm:request';
|
|
16
|
-
const COST_EVENT_HTTP = 'cost:http:request';
|
|
17
|
-
const COST_EVENT_OTHER = 'other';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Map a trace node `kind` to the canonical cost event name that would emit it.
|
|
21
|
-
* Unknown kinds bucket into `other` so future event sources still roll up cleanly.
|
|
22
|
-
*
|
|
23
|
-
* @param {string} kind
|
|
24
|
-
* @returns {string}
|
|
25
|
-
*/
|
|
26
|
-
const eventNameForKind = kind => {
|
|
27
|
-
if ( kind === 'llm' ) {
|
|
28
|
-
return COST_EVENT_LLM;
|
|
29
|
-
}
|
|
30
|
-
if ( kind === 'http' ) {
|
|
31
|
-
return COST_EVENT_HTTP;
|
|
32
|
-
}
|
|
33
|
-
return COST_EVENT_OTHER;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const isNumber = value => typeof value === 'number' && Number.isFinite( value );
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Pull token usage off an llm node, preferring the new attribute over the legacy
|
|
40
|
-
* `output.usage` fallback. Returns `null` when neither shape is present.
|
|
41
|
-
*/
|
|
42
|
-
const readTokenUsage = node => {
|
|
43
|
-
const attrUsage = node.attributes?.token_usage;
|
|
44
|
-
if ( attrUsage && typeof attrUsage === 'object' ) {
|
|
45
|
-
return attrUsage;
|
|
46
|
-
}
|
|
47
|
-
const legacyUsage = node.output?.usage;
|
|
48
|
-
if ( legacyUsage && typeof legacyUsage === 'object' ) {
|
|
49
|
-
return legacyUsage;
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Recursively walk a trace tree depth-first, applying `visit` to each node.
|
|
56
|
-
*/
|
|
57
|
-
const walk = ( node, visit ) => {
|
|
58
|
-
if ( !node ) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
visit( node );
|
|
62
|
-
for ( const child of node.children ?? [] ) {
|
|
63
|
-
walk( child, visit );
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Build the aggregated `attributes` payload returned by `/trace-attributes`.
|
|
69
|
-
* Component buckets always appear in a stable order so callers can index them
|
|
70
|
-
* positionally if they want to.
|
|
71
|
-
*
|
|
72
|
-
* @param {object|null} root - The root NodeEntry returned by `buildTraceTree`.
|
|
73
|
-
* @returns {TraceAttributes}
|
|
74
|
-
*/
|
|
75
|
-
export default function aggregateTraceAttributes( root ) {
|
|
76
|
-
const costByEvent = new Map( [
|
|
77
|
-
[ COST_EVENT_LLM, 0 ],
|
|
78
|
-
[ COST_EVENT_HTTP, 0 ],
|
|
79
|
-
[ COST_EVENT_OTHER, 0 ]
|
|
80
|
-
] );
|
|
81
|
-
const tokenUsage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, totalTokens: 0 };
|
|
82
|
-
|
|
83
|
-
walk( root, node => {
|
|
84
|
-
const cost = node.attributes?.cost;
|
|
85
|
-
if ( cost && isNumber( cost.total ) ) {
|
|
86
|
-
const eventName = eventNameForKind( node.kind );
|
|
87
|
-
costByEvent.set( eventName, ( costByEvent.get( eventName ) ?? 0 ) + cost.total );
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if ( node.kind === 'llm' ) {
|
|
91
|
-
const usage = readTokenUsage( node );
|
|
92
|
-
if ( usage ) {
|
|
93
|
-
if ( isNumber( usage.inputTokens ) ) {
|
|
94
|
-
tokenUsage.inputTokens += usage.inputTokens;
|
|
95
|
-
}
|
|
96
|
-
if ( isNumber( usage.outputTokens ) ) {
|
|
97
|
-
tokenUsage.outputTokens += usage.outputTokens;
|
|
98
|
-
}
|
|
99
|
-
if ( isNumber( usage.cachedInputTokens ) ) {
|
|
100
|
-
tokenUsage.cachedInputTokens += usage.cachedInputTokens;
|
|
101
|
-
}
|
|
102
|
-
if ( isNumber( usage.totalTokens ) ) {
|
|
103
|
-
tokenUsage.totalTokens += usage.totalTokens;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
} );
|
|
108
|
-
|
|
109
|
-
const components = Array.from( costByEvent, ( [ name, value ] ) => ( { name, value } ) );
|
|
110
|
-
const total = components.reduce( ( sum, { value } ) => sum + value, 0 );
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
cost: { total, components },
|
|
114
|
-
tokenUsage
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export { COST_EVENT_LLM, COST_EVENT_HTTP, COST_EVENT_OTHER };
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import aggregateTraceAttributes, {
|
|
3
|
-
COST_EVENT_LLM,
|
|
4
|
-
COST_EVENT_HTTP,
|
|
5
|
-
COST_EVENT_OTHER
|
|
6
|
-
} from './aggregate_trace_attributes.js';
|
|
7
|
-
|
|
8
|
-
const node = ( { id, kind = 'step', attributes = {}, output, children = [] } ) => ( {
|
|
9
|
-
id,
|
|
10
|
-
kind,
|
|
11
|
-
name: id,
|
|
12
|
-
startedAt: 0,
|
|
13
|
-
endedAt: 0,
|
|
14
|
-
input: undefined,
|
|
15
|
-
output,
|
|
16
|
-
attributes,
|
|
17
|
-
children
|
|
18
|
-
} );
|
|
19
|
-
|
|
20
|
-
describe( 'aggregate_trace_attributes', () => {
|
|
21
|
-
it( 'returns zeros for a null root', () => {
|
|
22
|
-
const result = aggregateTraceAttributes( null );
|
|
23
|
-
expect( result.cost.total ).toBe( 0 );
|
|
24
|
-
expect( result.cost.components ).toEqual( [
|
|
25
|
-
{ name: COST_EVENT_LLM, value: 0 },
|
|
26
|
-
{ name: COST_EVENT_HTTP, value: 0 },
|
|
27
|
-
{ name: COST_EVENT_OTHER, value: 0 }
|
|
28
|
-
] );
|
|
29
|
-
expect( result.tokenUsage ).toEqual( {
|
|
30
|
-
inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, totalTokens: 0
|
|
31
|
-
} );
|
|
32
|
-
} );
|
|
33
|
-
|
|
34
|
-
it( 'returns zeros for a tree with no cost or usage attributes', () => {
|
|
35
|
-
const root = node( {
|
|
36
|
-
id: 'wf',
|
|
37
|
-
kind: 'workflow',
|
|
38
|
-
children: [ node( { id: 's1' } ), node( { id: 's2' } ) ]
|
|
39
|
-
} );
|
|
40
|
-
const result = aggregateTraceAttributes( root );
|
|
41
|
-
expect( result.cost.total ).toBe( 0 );
|
|
42
|
-
expect( result.tokenUsage.totalTokens ).toBe( 0 );
|
|
43
|
-
} );
|
|
44
|
-
|
|
45
|
-
it( 'buckets cost by node kind into llm / http / other components', () => {
|
|
46
|
-
const root = node( {
|
|
47
|
-
id: 'wf',
|
|
48
|
-
kind: 'workflow',
|
|
49
|
-
children: [
|
|
50
|
-
node( { id: 'llm-1', kind: 'llm', attributes: { cost: { total: 0.20 } } } ),
|
|
51
|
-
node( { id: 'llm-2', kind: 'llm', attributes: { cost: { total: 0.10 } } } ),
|
|
52
|
-
node( { id: 'http-1', kind: 'http', attributes: { cost: { total: 0.50 } } } ),
|
|
53
|
-
// Unknown kind falls into the catch-all bucket
|
|
54
|
-
node( { id: 'step-1', kind: 'step', attributes: { cost: { total: 0.07 } } } )
|
|
55
|
-
]
|
|
56
|
-
} );
|
|
57
|
-
const result = aggregateTraceAttributes( root );
|
|
58
|
-
|
|
59
|
-
const byName = Object.fromEntries( result.cost.components.map( c => [ c.name, c.value ] ) );
|
|
60
|
-
expect( byName[COST_EVENT_LLM] ).toBeCloseTo( 0.30, 10 );
|
|
61
|
-
expect( byName[COST_EVENT_HTTP] ).toBeCloseTo( 0.50, 10 );
|
|
62
|
-
expect( byName[COST_EVENT_OTHER] ).toBeCloseTo( 0.07, 10 );
|
|
63
|
-
expect( result.cost.total ).toBeCloseTo( 0.87, 10 );
|
|
64
|
-
} );
|
|
65
|
-
|
|
66
|
-
it( 'total equals the sum of all components', () => {
|
|
67
|
-
const root = node( {
|
|
68
|
-
id: 'wf',
|
|
69
|
-
kind: 'workflow',
|
|
70
|
-
children: [
|
|
71
|
-
node( { id: 'llm-1', kind: 'llm', attributes: { cost: { total: 0.1234 } } } ),
|
|
72
|
-
node( { id: 'http-1', kind: 'http', attributes: { cost: { total: 0.0011 } } } )
|
|
73
|
-
]
|
|
74
|
-
} );
|
|
75
|
-
const { cost } = aggregateTraceAttributes( root );
|
|
76
|
-
const sum = cost.components.reduce( ( s, c ) => s + c.value, 0 );
|
|
77
|
-
expect( cost.total ).toBeCloseTo( sum, 10 );
|
|
78
|
-
} );
|
|
79
|
-
|
|
80
|
-
it( 'sums token_usage across llm nodes from the attribute path', () => {
|
|
81
|
-
const root = node( {
|
|
82
|
-
id: 'wf',
|
|
83
|
-
kind: 'workflow',
|
|
84
|
-
children: [
|
|
85
|
-
node( {
|
|
86
|
-
id: 'llm-1', kind: 'llm', attributes: {
|
|
87
|
-
token_usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 5, totalTokens: 125 }
|
|
88
|
-
}
|
|
89
|
-
} ),
|
|
90
|
-
node( {
|
|
91
|
-
id: 'llm-2', kind: 'llm', attributes: {
|
|
92
|
-
token_usage: { inputTokens: 50, outputTokens: 10, cachedInputTokens: 1, totalTokens: 61 }
|
|
93
|
-
}
|
|
94
|
-
} )
|
|
95
|
-
]
|
|
96
|
-
} );
|
|
97
|
-
const { tokenUsage } = aggregateTraceAttributes( root );
|
|
98
|
-
expect( tokenUsage ).toEqual( {
|
|
99
|
-
inputTokens: 150,
|
|
100
|
-
outputTokens: 30,
|
|
101
|
-
cachedInputTokens: 6,
|
|
102
|
-
totalTokens: 186
|
|
103
|
-
} );
|
|
104
|
-
} );
|
|
105
|
-
|
|
106
|
-
it( 'falls back to output.usage on legacy llm nodes that lack attributes.token_usage', () => {
|
|
107
|
-
const root = node( {
|
|
108
|
-
id: 'wf',
|
|
109
|
-
kind: 'workflow',
|
|
110
|
-
children: [
|
|
111
|
-
// Legacy shape — usage lives on output.usage, no attributes.token_usage
|
|
112
|
-
node( {
|
|
113
|
-
id: 'llm-legacy',
|
|
114
|
-
kind: 'llm',
|
|
115
|
-
output: { result: '...', usage: { inputTokens: 200, outputTokens: 40, totalTokens: 240 } }
|
|
116
|
-
} )
|
|
117
|
-
]
|
|
118
|
-
} );
|
|
119
|
-
const { tokenUsage } = aggregateTraceAttributes( root );
|
|
120
|
-
expect( tokenUsage.inputTokens ).toBe( 200 );
|
|
121
|
-
expect( tokenUsage.outputTokens ).toBe( 40 );
|
|
122
|
-
expect( tokenUsage.totalTokens ).toBe( 240 );
|
|
123
|
-
expect( tokenUsage.cachedInputTokens ).toBe( 0 );
|
|
124
|
-
} );
|
|
125
|
-
|
|
126
|
-
it( 'prefers attributes.token_usage over output.usage when both are present', () => {
|
|
127
|
-
const root = node( {
|
|
128
|
-
id: 'wf',
|
|
129
|
-
kind: 'workflow',
|
|
130
|
-
children: [
|
|
131
|
-
node( {
|
|
132
|
-
id: 'llm-1',
|
|
133
|
-
kind: 'llm',
|
|
134
|
-
attributes: { token_usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 } },
|
|
135
|
-
output: { usage: { inputTokens: 999, outputTokens: 999, totalTokens: 999 } }
|
|
136
|
-
} )
|
|
137
|
-
]
|
|
138
|
-
} );
|
|
139
|
-
const { tokenUsage } = aggregateTraceAttributes( root );
|
|
140
|
-
expect( tokenUsage.inputTokens ).toBe( 10 );
|
|
141
|
-
expect( tokenUsage.totalTokens ).toBe( 12 );
|
|
142
|
-
} );
|
|
143
|
-
|
|
144
|
-
it( 'ignores token_usage shapes on non-llm nodes', () => {
|
|
145
|
-
const root = node( {
|
|
146
|
-
id: 'wf',
|
|
147
|
-
kind: 'workflow',
|
|
148
|
-
// attributes.token_usage on a non-llm node is intentionally ignored —
|
|
149
|
-
// only llm nodes contribute to the token-usage rollup today.
|
|
150
|
-
children: [
|
|
151
|
-
node( {
|
|
152
|
-
id: 'step-1', kind: 'step', attributes: {
|
|
153
|
-
token_usage: { inputTokens: 999, outputTokens: 999, totalTokens: 999 }
|
|
154
|
-
}
|
|
155
|
-
} )
|
|
156
|
-
]
|
|
157
|
-
} );
|
|
158
|
-
const { tokenUsage } = aggregateTraceAttributes( root );
|
|
159
|
-
expect( tokenUsage.totalTokens ).toBe( 0 );
|
|
160
|
-
} );
|
|
161
|
-
|
|
162
|
-
it( 'aggregates a mixed tree with cost on http nodes and usage on llm nodes', () => {
|
|
163
|
-
const root = node( {
|
|
164
|
-
id: 'wf',
|
|
165
|
-
kind: 'workflow',
|
|
166
|
-
children: [
|
|
167
|
-
node( {
|
|
168
|
-
id: 'llm-1',
|
|
169
|
-
kind: 'llm',
|
|
170
|
-
attributes: {
|
|
171
|
-
cost: { total: 0.0038 },
|
|
172
|
-
token_usage: { inputTokens: 2264, outputTokens: 411, cachedInputTokens: 100, totalTokens: 2775 }
|
|
173
|
-
}
|
|
174
|
-
} ),
|
|
175
|
-
node( {
|
|
176
|
-
id: 'http-1',
|
|
177
|
-
kind: 'http',
|
|
178
|
-
attributes: { cost: { total: 0.50 } }
|
|
179
|
-
} )
|
|
180
|
-
]
|
|
181
|
-
} );
|
|
182
|
-
const result = aggregateTraceAttributes( root );
|
|
183
|
-
|
|
184
|
-
const byName = Object.fromEntries( result.cost.components.map( c => [ c.name, c.value ] ) );
|
|
185
|
-
expect( byName[COST_EVENT_LLM] ).toBeCloseTo( 0.0038, 10 );
|
|
186
|
-
expect( byName[COST_EVENT_HTTP] ).toBeCloseTo( 0.50, 10 );
|
|
187
|
-
expect( byName[COST_EVENT_OTHER] ).toBe( 0 );
|
|
188
|
-
expect( result.cost.total ).toBeCloseTo( 0.5038, 10 );
|
|
189
|
-
|
|
190
|
-
expect( result.tokenUsage ).toEqual( {
|
|
191
|
-
inputTokens: 2264,
|
|
192
|
-
outputTokens: 411,
|
|
193
|
-
cachedInputTokens: 100,
|
|
194
|
-
totalTokens: 2775
|
|
195
|
-
} );
|
|
196
|
-
} );
|
|
197
|
-
|
|
198
|
-
it( 'recurses through nested children', () => {
|
|
199
|
-
const root = node( {
|
|
200
|
-
id: 'wf',
|
|
201
|
-
kind: 'workflow',
|
|
202
|
-
children: [
|
|
203
|
-
node( {
|
|
204
|
-
id: 's1',
|
|
205
|
-
kind: 'step',
|
|
206
|
-
children: [
|
|
207
|
-
node( {
|
|
208
|
-
id: 'llm-1', kind: 'llm', attributes: {
|
|
209
|
-
cost: { total: 0.01 },
|
|
210
|
-
token_usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }
|
|
211
|
-
}
|
|
212
|
-
} )
|
|
213
|
-
]
|
|
214
|
-
} )
|
|
215
|
-
]
|
|
216
|
-
} );
|
|
217
|
-
const result = aggregateTraceAttributes( root );
|
|
218
|
-
expect( result.cost.total ).toBeCloseTo( 0.01, 10 );
|
|
219
|
-
expect( result.tokenUsage.totalTokens ).toBe( 15 );
|
|
220
|
-
} );
|
|
221
|
-
|
|
222
|
-
it( 'keeps the canonical component ordering: llm, http, other', () => {
|
|
223
|
-
const root = node( { id: 'wf', kind: 'workflow' } );
|
|
224
|
-
const { cost } = aggregateTraceAttributes( root );
|
|
225
|
-
expect( cost.components.map( c => c.name ) ).toEqual( [
|
|
226
|
-
COST_EVENT_LLM,
|
|
227
|
-
COST_EVENT_HTTP,
|
|
228
|
-
COST_EVENT_OTHER
|
|
229
|
-
] );
|
|
230
|
-
} );
|
|
231
|
-
} );
|