@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.
@@ -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
+ }