@nifrajs/agent-telemetry 1.4.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/dist/index.d.ts +58 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/index.ts +191 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/agent-telemetry` — child-span instrumentation for AI agent tool calls.
|
|
3
|
+
*
|
|
4
|
+
* Extends `@nifrajs/otel`'s span model: when an AI agent invokes a tool via
|
|
5
|
+
* `/_nifra/tool/*` or the MCP endpoint, this middleware creates a child span
|
|
6
|
+
* tracking tool name, input size, output size, and execution time.
|
|
7
|
+
*
|
|
8
|
+
* **Zero production overhead when not registered** — Nifra's `bare` flag keeps
|
|
9
|
+
* routes on the sync fast path unless hooks are present at registration time.
|
|
10
|
+
*/
|
|
11
|
+
import { type ActiveObservation, type NifraSpan, type SpanExporter } from "@nifrajs/otel";
|
|
12
|
+
/** A tool-call span. Kept as an alias for package-specific naming without duplicating OTel types. */
|
|
13
|
+
export type AgentSpan = NifraSpan;
|
|
14
|
+
/** Exporter seam shared with `@nifrajs/otel`. */
|
|
15
|
+
export type AgentSpanExporter = SpanExporter;
|
|
16
|
+
export interface AgentTelemetryOptions {
|
|
17
|
+
/** Exporter that receives completed tool-call spans. */
|
|
18
|
+
readonly exporter: AgentSpanExporter;
|
|
19
|
+
/** Path prefix for nifra tool routes (default `"/_nifra/tool/"`). */
|
|
20
|
+
readonly toolPathPrefix?: string | undefined;
|
|
21
|
+
/** Path for the MCP endpoint (default `"/mcp"`). */
|
|
22
|
+
readonly mcpPath?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
/** Minimal context shape the middleware receives from hooks. */
|
|
25
|
+
interface HookContext {
|
|
26
|
+
readonly request: Request;
|
|
27
|
+
readonly trace?: {
|
|
28
|
+
readonly traceId: string;
|
|
29
|
+
readonly spanId: string;
|
|
30
|
+
readonly sampled: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** Present when `@nifrajs/otel` owns the enclosing request observation. */
|
|
33
|
+
readonly observation?: ActiveObservation;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Agent telemetry middleware. Register via `app.use(agentTelemetry({ exporter }))`.
|
|
37
|
+
*
|
|
38
|
+
* Creates child spans for requests targeting tool endpoints (`/_nifra/tool/*`)
|
|
39
|
+
* or the MCP endpoint (`/mcp`). Non-matching requests pass through with zero overhead
|
|
40
|
+
* (a single `startsWith` check — O(1), no regex).
|
|
41
|
+
*/
|
|
42
|
+
export declare function agentTelemetry(options: AgentTelemetryOptions): {
|
|
43
|
+
name: string;
|
|
44
|
+
beforeHandle(context: HookContext): undefined;
|
|
45
|
+
onError(error: unknown, context: HookContext): undefined;
|
|
46
|
+
onResponse(response: Response, request: Request): Response;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Pretty-prints agent tool call traces to the terminal.
|
|
50
|
+
*
|
|
51
|
+
* Output format:
|
|
52
|
+
* ```
|
|
53
|
+
* [agent] tool:get_weather 12ms ok (input: 45B, output: 128B)
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function consoleAgentExporter(log?: (line: string) => void): AgentSpanExporter;
|
|
57
|
+
export {};
|
|
58
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,KAAK,iBAAiB,EAEtB,KAAK,SAAS,EACd,KAAK,YAAY,EAClB,MAAM,eAAe,CAAA;AAEtB,qGAAqG;AACrG,MAAM,MAAM,SAAS,GAAG,SAAS,CAAA;AACjC,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GAAG,YAAY,CAAA;AAM5C,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,CAAA;IACpC,qEAAqE;IACrE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC5C,oDAAoD;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC;AAED,gEAAgE;AAChE,UAAU,WAAW;IACnB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;QACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;QACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;KAC1B,CAAA;IACD,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CACzC;AAMD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,qBAAqB;;0BAUnC,WAAW;mBAiClB,OAAO,WAAW,WAAW;yBAMvB,QAAQ,WAAW,OAAO;EAqDlD;AAMD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAEtB,GACA,iBAAiB,CAWnB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/agent-telemetry` — child-span instrumentation for AI agent tool calls.
|
|
3
|
+
*
|
|
4
|
+
* Extends `@nifrajs/otel`'s span model: when an AI agent invokes a tool via
|
|
5
|
+
* `/_nifra/tool/*` or the MCP endpoint, this middleware creates a child span
|
|
6
|
+
* tracking tool name, input size, output size, and execution time.
|
|
7
|
+
*
|
|
8
|
+
* **Zero production overhead when not registered** — Nifra's `bare` flag keeps
|
|
9
|
+
* routes on the sync fast path unless hooks are present at registration time.
|
|
10
|
+
*/
|
|
11
|
+
import { createObservationLifecycle, } from "@nifrajs/otel";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Middleware
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* Agent telemetry middleware. Register via `app.use(agentTelemetry({ exporter }))`.
|
|
17
|
+
*
|
|
18
|
+
* Creates child spans for requests targeting tool endpoints (`/_nifra/tool/*`)
|
|
19
|
+
* or the MCP endpoint (`/mcp`). Non-matching requests pass through with zero overhead
|
|
20
|
+
* (a single `startsWith` check — O(1), no regex).
|
|
21
|
+
*/
|
|
22
|
+
export function agentTelemetry(options) {
|
|
23
|
+
const toolPrefix = options.toolPathPrefix ?? "/_nifra/tool/";
|
|
24
|
+
const mcpPath = options.mcpPath ?? "/mcp";
|
|
25
|
+
const exporter = options.exporter;
|
|
26
|
+
const standaloneLifecycle = createObservationLifecycle({ adapters: [exporter] });
|
|
27
|
+
const inflight = new WeakMap();
|
|
28
|
+
return {
|
|
29
|
+
name: "agent-telemetry",
|
|
30
|
+
beforeHandle(context) {
|
|
31
|
+
const url = new URL(context.request.url);
|
|
32
|
+
const pathname = url.pathname;
|
|
33
|
+
const isToolCall = pathname.startsWith(toolPrefix);
|
|
34
|
+
const isMcpCall = pathname === mcpPath;
|
|
35
|
+
if (!isToolCall && !isMcpCall)
|
|
36
|
+
return undefined;
|
|
37
|
+
// Extract tool name
|
|
38
|
+
const toolName = isToolCall ? pathname.slice(toolPrefix.length) || "unknown" : "mcp";
|
|
39
|
+
// Measure input size from Content-Length header
|
|
40
|
+
const inputBytes = Number(context.request.headers.get("content-length") ?? "0") || 0;
|
|
41
|
+
const input = {
|
|
42
|
+
name: `tool:${toolName}`,
|
|
43
|
+
attributes: {
|
|
44
|
+
"tool.name": toolName,
|
|
45
|
+
"tool.input_bytes": inputBytes,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const observation = context.observation?.startChild(input, [exporter]) ??
|
|
49
|
+
standaloneLifecycle.start({
|
|
50
|
+
...input,
|
|
51
|
+
...(context.trace === undefined ? {} : { parent: context.trace }),
|
|
52
|
+
traceparent: context.request.headers.get("traceparent"),
|
|
53
|
+
});
|
|
54
|
+
inflight.set(context.request, observation);
|
|
55
|
+
return undefined; // don't short-circuit
|
|
56
|
+
},
|
|
57
|
+
onError(error, context) {
|
|
58
|
+
inflight.get(context.request)?.recordError(error);
|
|
59
|
+
return undefined; // don't swallow the error
|
|
60
|
+
},
|
|
61
|
+
onResponse(response, request) {
|
|
62
|
+
const observation = inflight.get(request);
|
|
63
|
+
if (!observation)
|
|
64
|
+
return response;
|
|
65
|
+
inflight.delete(request);
|
|
66
|
+
const end = (outputBytes) => {
|
|
67
|
+
observation.end({
|
|
68
|
+
statusCode: response.status,
|
|
69
|
+
attributes: {
|
|
70
|
+
"http.response.status_code": response.status,
|
|
71
|
+
"tool.output_bytes": outputBytes,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const declared = response.headers.get("content-length");
|
|
76
|
+
if (declared !== null) {
|
|
77
|
+
end(Number(declared) || 0);
|
|
78
|
+
return response;
|
|
79
|
+
}
|
|
80
|
+
if (response.body === null) {
|
|
81
|
+
end(0);
|
|
82
|
+
return response;
|
|
83
|
+
}
|
|
84
|
+
// Streamed response without content-length: count bytes as the body is consumed and
|
|
85
|
+
// end the span when the stream settles. `end()` is exactly-once, so close/cancel/error
|
|
86
|
+
// racing is safe; an abandoned stream still ends the span on cancel.
|
|
87
|
+
let outputBytes = 0;
|
|
88
|
+
const reader = response.body.getReader();
|
|
89
|
+
const counted = new ReadableStream({
|
|
90
|
+
async pull(controller) {
|
|
91
|
+
try {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done) {
|
|
94
|
+
end(outputBytes);
|
|
95
|
+
controller.close();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
outputBytes += value.byteLength;
|
|
99
|
+
controller.enqueue(value);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
end(outputBytes);
|
|
103
|
+
controller.error(error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
cancel(reason) {
|
|
107
|
+
end(outputBytes);
|
|
108
|
+
return reader.cancel(reason);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return new Response(counted, response);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Console exporter
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
/**
|
|
119
|
+
* Pretty-prints agent tool call traces to the terminal.
|
|
120
|
+
*
|
|
121
|
+
* Output format:
|
|
122
|
+
* ```
|
|
123
|
+
* [agent] tool:get_weather 12ms ok (input: 45B, output: 128B)
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function consoleAgentExporter(log = (line) => {
|
|
127
|
+
console.log(line);
|
|
128
|
+
}) {
|
|
129
|
+
return {
|
|
130
|
+
onEnd(span) {
|
|
131
|
+
const input = span.attributes["tool.input_bytes"] ?? 0;
|
|
132
|
+
const output = span.attributes["tool.output_bytes"] ?? 0;
|
|
133
|
+
const duration = span.durationMs ?? 0;
|
|
134
|
+
log(`[agent] ${span.name} ${duration}ms ${span.status} (input: ${input}B, output: ${output}B)`);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/agent-telemetry",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Agent telemetry plugin — child spans for AI tool calls on /_nifra/tool/* and MCP endpoints.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"bun": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@nifrajs/otel": "1.4.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@nifrajs/core": "^1.4.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@nifrajs/core": "workspace:*"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/agent-telemetry` — child-span instrumentation for AI agent tool calls.
|
|
3
|
+
*
|
|
4
|
+
* Extends `@nifrajs/otel`'s span model: when an AI agent invokes a tool via
|
|
5
|
+
* `/_nifra/tool/*` or the MCP endpoint, this middleware creates a child span
|
|
6
|
+
* tracking tool name, input size, output size, and execution time.
|
|
7
|
+
*
|
|
8
|
+
* **Zero production overhead when not registered** — Nifra's `bare` flag keeps
|
|
9
|
+
* routes on the sync fast path unless hooks are present at registration time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type ActiveObservation,
|
|
14
|
+
createObservationLifecycle,
|
|
15
|
+
type NifraSpan,
|
|
16
|
+
type SpanExporter,
|
|
17
|
+
} from "@nifrajs/otel"
|
|
18
|
+
|
|
19
|
+
/** A tool-call span. Kept as an alias for package-specific naming without duplicating OTel types. */
|
|
20
|
+
export type AgentSpan = NifraSpan
|
|
21
|
+
/** Exporter seam shared with `@nifrajs/otel`. */
|
|
22
|
+
export type AgentSpanExporter = SpanExporter
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Types
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export interface AgentTelemetryOptions {
|
|
29
|
+
/** Exporter that receives completed tool-call spans. */
|
|
30
|
+
readonly exporter: AgentSpanExporter
|
|
31
|
+
/** Path prefix for nifra tool routes (default `"/_nifra/tool/"`). */
|
|
32
|
+
readonly toolPathPrefix?: string | undefined
|
|
33
|
+
/** Path for the MCP endpoint (default `"/mcp"`). */
|
|
34
|
+
readonly mcpPath?: string | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Minimal context shape the middleware receives from hooks. */
|
|
38
|
+
interface HookContext {
|
|
39
|
+
readonly request: Request
|
|
40
|
+
readonly trace?: {
|
|
41
|
+
readonly traceId: string
|
|
42
|
+
readonly spanId: string
|
|
43
|
+
readonly sampled: boolean
|
|
44
|
+
}
|
|
45
|
+
/** Present when `@nifrajs/otel` owns the enclosing request observation. */
|
|
46
|
+
readonly observation?: ActiveObservation
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Middleware
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Agent telemetry middleware. Register via `app.use(agentTelemetry({ exporter }))`.
|
|
55
|
+
*
|
|
56
|
+
* Creates child spans for requests targeting tool endpoints (`/_nifra/tool/*`)
|
|
57
|
+
* or the MCP endpoint (`/mcp`). Non-matching requests pass through with zero overhead
|
|
58
|
+
* (a single `startsWith` check — O(1), no regex).
|
|
59
|
+
*/
|
|
60
|
+
export function agentTelemetry(options: AgentTelemetryOptions) {
|
|
61
|
+
const toolPrefix = options.toolPathPrefix ?? "/_nifra/tool/"
|
|
62
|
+
const mcpPath = options.mcpPath ?? "/mcp"
|
|
63
|
+
const exporter = options.exporter
|
|
64
|
+
const standaloneLifecycle = createObservationLifecycle({ adapters: [exporter] })
|
|
65
|
+
const inflight = new WeakMap<Request, ActiveObservation>()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: "agent-telemetry",
|
|
69
|
+
|
|
70
|
+
beforeHandle(context: HookContext) {
|
|
71
|
+
const url = new URL(context.request.url)
|
|
72
|
+
const pathname = url.pathname
|
|
73
|
+
const isToolCall = pathname.startsWith(toolPrefix)
|
|
74
|
+
const isMcpCall = pathname === mcpPath
|
|
75
|
+
|
|
76
|
+
if (!isToolCall && !isMcpCall) return undefined
|
|
77
|
+
|
|
78
|
+
// Extract tool name
|
|
79
|
+
const toolName = isToolCall ? pathname.slice(toolPrefix.length) || "unknown" : "mcp"
|
|
80
|
+
|
|
81
|
+
// Measure input size from Content-Length header
|
|
82
|
+
const inputBytes = Number(context.request.headers.get("content-length") ?? "0") || 0
|
|
83
|
+
|
|
84
|
+
const input = {
|
|
85
|
+
name: `tool:${toolName}`,
|
|
86
|
+
attributes: {
|
|
87
|
+
"tool.name": toolName,
|
|
88
|
+
"tool.input_bytes": inputBytes,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
const observation =
|
|
92
|
+
context.observation?.startChild(input, [exporter]) ??
|
|
93
|
+
standaloneLifecycle.start({
|
|
94
|
+
...input,
|
|
95
|
+
...(context.trace === undefined ? {} : { parent: context.trace }),
|
|
96
|
+
traceparent: context.request.headers.get("traceparent"),
|
|
97
|
+
})
|
|
98
|
+
inflight.set(context.request, observation)
|
|
99
|
+
|
|
100
|
+
return undefined // don't short-circuit
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
onError(error: unknown, context: HookContext) {
|
|
104
|
+
inflight.get(context.request)?.recordError(error)
|
|
105
|
+
|
|
106
|
+
return undefined // don't swallow the error
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
onResponse(response: Response, request: Request) {
|
|
110
|
+
const observation = inflight.get(request)
|
|
111
|
+
if (!observation) return response
|
|
112
|
+
inflight.delete(request)
|
|
113
|
+
const end = (outputBytes: number): void => {
|
|
114
|
+
observation.end({
|
|
115
|
+
statusCode: response.status,
|
|
116
|
+
attributes: {
|
|
117
|
+
"http.response.status_code": response.status,
|
|
118
|
+
"tool.output_bytes": outputBytes,
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const declared = response.headers.get("content-length")
|
|
124
|
+
if (declared !== null) {
|
|
125
|
+
end(Number(declared) || 0)
|
|
126
|
+
return response
|
|
127
|
+
}
|
|
128
|
+
if (response.body === null) {
|
|
129
|
+
end(0)
|
|
130
|
+
return response
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Streamed response without content-length: count bytes as the body is consumed and
|
|
134
|
+
// end the span when the stream settles. `end()` is exactly-once, so close/cancel/error
|
|
135
|
+
// racing is safe; an abandoned stream still ends the span on cancel.
|
|
136
|
+
let outputBytes = 0
|
|
137
|
+
const reader = response.body.getReader()
|
|
138
|
+
const counted = new ReadableStream<Uint8Array>({
|
|
139
|
+
async pull(controller) {
|
|
140
|
+
try {
|
|
141
|
+
const { done, value } = await reader.read()
|
|
142
|
+
if (done) {
|
|
143
|
+
end(outputBytes)
|
|
144
|
+
controller.close()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
outputBytes += value.byteLength
|
|
148
|
+
controller.enqueue(value)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
end(outputBytes)
|
|
151
|
+
controller.error(error)
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
cancel(reason) {
|
|
155
|
+
end(outputBytes)
|
|
156
|
+
return reader.cancel(reason)
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
return new Response(counted, response)
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Console exporter
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pretty-prints agent tool call traces to the terminal.
|
|
170
|
+
*
|
|
171
|
+
* Output format:
|
|
172
|
+
* ```
|
|
173
|
+
* [agent] tool:get_weather 12ms ok (input: 45B, output: 128B)
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export function consoleAgentExporter(
|
|
177
|
+
log: (line: string) => void = (line) => {
|
|
178
|
+
console.log(line)
|
|
179
|
+
},
|
|
180
|
+
): AgentSpanExporter {
|
|
181
|
+
return {
|
|
182
|
+
onEnd(span) {
|
|
183
|
+
const input = span.attributes["tool.input_bytes"] ?? 0
|
|
184
|
+
const output = span.attributes["tool.output_bytes"] ?? 0
|
|
185
|
+
const duration = span.durationMs ?? 0
|
|
186
|
+
log(
|
|
187
|
+
`[agent] ${span.name} ${duration}ms ${span.status} (input: ${input}B, output: ${output}B)`,
|
|
188
|
+
)
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|