Skip to content

Commit 8e5b2e2

Browse files
authored
Support optional ToolMetadata (#100)
* feat: ToolMetadata * Changeset and docs
1 parent f554f30 commit 8e5b2e2

11 files changed

Lines changed: 264 additions & 33 deletions

File tree

.changeset/tool-metadata.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@stackables/bridge-types": minor
3+
"@stackables/bridge-core": minor
4+
"@stackables/bridge-stdlib": patch
5+
---
6+
7+
Add `ToolMetadata` — per-tool observability controls
8+
9+
Tools can now attach a `.bridge` property to declare how the engine should
10+
instrument them, imported as `ToolMetadata` from `@stackables/bridge`.
11+
12+
```ts
13+
import type { ToolMetadata } from "@stackables/bridge";
14+
15+
myTool.bridge = {
16+
trace: false, // skip OTel span for this tool
17+
log: {
18+
execution: "info", // log successful calls at info level
19+
errors: "error", // log failures at error level (default)
20+
},
21+
} satisfies ToolMetadata;
22+
```

packages/bridge-core/src/ExecutionTree.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import { internal } from "./tools/index.ts";
55
import type { ToolTrace } from "./tracing.ts";
66
import {
77
isOtelActive,
8-
otelTracer,
9-
SpanStatusCodeEnum,
8+
logToolError,
9+
logToolSuccess,
10+
recordSpanError,
11+
resolveToolMeta,
1012
toolCallCounter,
1113
toolDurationHistogram,
1214
toolErrorCounter,
1315
TraceCollector,
16+
withSpan,
1417
} from "./tracing.ts";
1518
import type {
1619
Logger,
@@ -255,14 +258,17 @@ export class ExecutionTree implements TreeContext {
255258
}
256259

257260
// ── Instrumented path ─────────────────────────────────────────
261+
const { doTrace, log } = resolveToolMeta(fnImpl);
258262
const traceStart = tracer?.now();
259263
const metricAttrs = {
260264
"bridge.tool.name": toolName,
261265
"bridge.tool.fn": fnName,
262266
};
263-
return otelTracer.startActiveSpan(
267+
268+
return withSpan(
269+
doTrace,
264270
`bridge.tool.${toolName}.${fnName}`,
265-
{ attributes: metricAttrs },
271+
metricAttrs,
266272
async (span) => {
267273
const wallStart = performance.now();
268274
try {
@@ -286,12 +292,7 @@ export class ExecutionTree implements TreeContext {
286292
}),
287293
);
288294
}
289-
logger?.debug?.(
290-
"[bridge] tool %s (%s) completed in %dms",
291-
toolName,
292-
fnName,
293-
durationMs,
294-
);
295+
logToolSuccess(logger, log.execution, toolName, fnName, durationMs);
295296
return result;
296297
} catch (err) {
297298
const durationMs = roundMs(performance.now() - wallStart);
@@ -310,17 +311,8 @@ export class ExecutionTree implements TreeContext {
310311
}),
311312
);
312313
}
313-
span.recordException(err as Error);
314-
span.setStatus({
315-
code: SpanStatusCodeEnum.ERROR,
316-
message: (err as Error).message,
317-
});
318-
logger?.error?.(
319-
"[bridge] tool %s (%s) failed: %s",
320-
toolName,
321-
fnName,
322-
(err as Error).message,
323-
);
314+
recordSpanError(span, err as Error);
315+
logToolError(logger, log.errors, toolName, fnName, err as Error);
324316
// Normalize platform AbortError to BridgeAbortError
325317
if (
326318
this.signal?.aborted &&
@@ -331,7 +323,7 @@ export class ExecutionTree implements TreeContext {
331323
}
332324
throw err;
333325
} finally {
334-
span.end();
326+
span?.end();
335327
}
336328
},
337329
);

packages/bridge-core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type {
6161
ToolDef,
6262
ToolDep,
6363
ToolMap,
64+
ToolMetadata,
6465
ToolWire,
6566
VersionDecl,
6667
Wire,
@@ -74,4 +75,3 @@ export {
7475
matchesRequestedFields,
7576
filterOutputFields,
7677
} from "./requested-fields.ts";
77-

packages/bridge-core/src/tracing.ts

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
*/
77

88
import { metrics, trace } from "@opentelemetry/api";
9+
import type { Span } from "@opentelemetry/api";
10+
import type { ToolMetadata } from "@stackables/bridge-types";
11+
import type { Logger } from "./tree-types.ts";
912
import { roundMs } from "./tree-utils.ts";
1013

1114
// ── OTel setup ──────────────────────────────────────────────────────────────
@@ -45,8 +48,9 @@ export const toolErrorCounter = otelMeter.createCounter("bridge.tool.errors", {
4548
description: "Total number of tool invocation errors",
4649
});
4750

48-
// Re-export SpanStatusCode for callTool usage
51+
// Re-export SpanStatusCode for internal usage
4952
export { SpanStatusCode as SpanStatusCodeEnum } from "@opentelemetry/api";
53+
import { SpanStatusCode } from "@opentelemetry/api";
5054

5155
// ── Trace types ─────────────────────────────────────────────────────────────
5256

@@ -96,10 +100,7 @@ export function boundedClone(
96100
0,
97101
Number.isFinite(maxStringLength) ? Math.floor(maxStringLength) : 1024,
98102
);
99-
const safeDepth = Math.max(
100-
0,
101-
Number.isFinite(depth) ? Math.floor(depth) : 5,
102-
);
103+
const safeDepth = Math.max(0, Number.isFinite(depth) ? Math.floor(depth) : 5);
103104
return _boundedClone(value, safeArrayItems, safeStringLength, safeDepth, 0);
104105
}
105106

@@ -165,7 +166,11 @@ export class TraceCollector {
165166

166167
constructor(
167168
level: "basic" | "full" = "full",
168-
options?: { maxArrayItems?: number; maxStringLength?: number; cloneDepth?: number },
169+
options?: {
170+
maxArrayItems?: number;
171+
maxStringLength?: number;
172+
cloneDepth?: number;
173+
},
169174
) {
170175
this.level = level;
171176
this.maxArrayItems = options?.maxArrayItems ?? 100;
@@ -225,3 +230,93 @@ export class TraceCollector {
225230
return t;
226231
}
227232
}
233+
234+
// ── Tool metadata helpers ────────────────────────────────────────────────────
235+
236+
/**
237+
* Resolved logging behaviour derived from a tool's `.bridge` metadata.
238+
* A fixed shape so call sites never branch on `undefined`.
239+
*/
240+
export type EffectiveToolLog = {
241+
/** Logger level for successful invocations, or `false` to suppress. */
242+
execution: false | "debug" | "info";
243+
/** Logger level for thrown errors, or `false` to suppress. */
244+
errors: false | "warn" | "error";
245+
};
246+
247+
/** Normalised metadata resolved from the optional `.bridge` property. */
248+
export type ResolvedToolMeta = {
249+
/** Emit an OTel span for this call. Default: `true`. */
250+
doTrace: boolean;
251+
log: EffectiveToolLog;
252+
};
253+
254+
function resolveToolLog(meta: ToolMetadata | undefined): EffectiveToolLog {
255+
const log = meta?.log;
256+
if (log === false) return { execution: false, errors: false };
257+
if (log == null) return { execution: false, errors: "error" };
258+
if (log === true) return { execution: "info", errors: "error" };
259+
return {
260+
execution:
261+
log.execution === "info" ? "info" : log.execution ? "debug" : false,
262+
errors:
263+
log.errors === false ? false : log.errors === "warn" ? "warn" : "error",
264+
};
265+
}
266+
267+
/** Read and normalise the `.bridge` metadata from a tool function. */
268+
export function resolveToolMeta(fn: (...args: any[]) => any): ResolvedToolMeta {
269+
const bridge = (fn as any).bridge as ToolMetadata | undefined;
270+
return { doTrace: bridge?.trace !== false, log: resolveToolLog(bridge) };
271+
}
272+
273+
/** Log a successful tool invocation. No-ops when `level` is `false`. */
274+
export function logToolSuccess(
275+
logger: Logger | undefined,
276+
level: EffectiveToolLog["execution"],
277+
toolName: string,
278+
fnName: string,
279+
durationMs: number,
280+
): void {
281+
if (!level) return;
282+
logger?.[level]?.(
283+
{ tool: toolName, fn: fnName, durationMs },
284+
"[bridge] tool completed",
285+
);
286+
}
287+
288+
/** Log a tool error. No-ops when `level` is `false`. */
289+
export function logToolError(
290+
logger: Logger | undefined,
291+
level: EffectiveToolLog["errors"],
292+
toolName: string,
293+
fnName: string,
294+
err: Error,
295+
): void {
296+
if (!level) return;
297+
logger?.[level]?.(
298+
{ tool: toolName, fn: fnName, err: err.message },
299+
"[bridge] tool failed",
300+
);
301+
}
302+
303+
/** Record an exception on a span and mark it as errored. */
304+
export function recordSpanError(span: Span | undefined, err: Error): void {
305+
if (!span) return;
306+
span.recordException(err);
307+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
308+
}
309+
310+
/**
311+
* Run `fn` inside an OTel span when `doTrace` is true;
312+
* otherwise call it directly with `undefined` as the span argument.
313+
*/
314+
export function withSpan<T>(
315+
doTrace: boolean,
316+
name: string,
317+
attrs: Record<string, string>,
318+
fn: (span: Span | undefined) => Promise<T>,
319+
): Promise<T> {
320+
if (!doTrace) return fn(undefined);
321+
return otelTracer.startActiveSpan(name, { attributes: attrs }, fn);
322+
}

packages/bridge-core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export type {
236236
ToolContext,
237237
ToolCallFn,
238238
ToolMap,
239+
ToolMetadata,
239240
CacheStore,
240241
} from "@stackables/bridge-types";
241242

packages/bridge-graphql/test/logging.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,22 +41,30 @@ function createLogCapture(): Logger & {
4141
} {
4242
const debugMessages: string[] = [];
4343
const errorMessages: string[] = [];
44+
// Structured log calls arrive as (data: object, msg: string) — Pino convention.
45+
// Flatten to a single searchable string for test assertions.
46+
const format = (...args: any[]): string =>
47+
args
48+
.map((a) => (a && typeof a === "object" ? JSON.stringify(a) : String(a)))
49+
.join(" ");
4450
return {
4551
debugMessages,
4652
errorMessages,
47-
debug: (...args: any[]) => debugMessages.push(args.join(" ")),
53+
debug: (...args: any[]) => debugMessages.push(format(...args)),
4854
info: () => {},
4955
warn: () => {},
50-
error: (...args: any[]) => errorMessages.push(args.join(" ")),
56+
error: (...args: any[]) => errorMessages.push(format(...args)),
5157
};
5258
}
5359

5460
describe("logging: basics", () => {
5561
test("logger.debug is called on successful tool call", async () => {
5662
const instructions = parseBridge(bridge);
5763
const logger = createLogCapture();
64+
const geocoder = async () => ({ label: "Berlin, DE" });
65+
geocoder.bridge = { log: { execution: "debug" as const } };
5866
const schema = bridgeTransform(createSchema({ typeDefs }), instructions, {
59-
tools: { geocoder: async () => ({ label: "Berlin, DE" }) },
67+
tools: { geocoder },
6068
logger,
6169
});
6270
const yoga = createYoga({ schema, graphqlEndpoint: "*" });

packages/bridge-stdlib/src/tools/arrays.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import type { ToolMetadata } from "@stackables/bridge-types";
2+
3+
const syncUtility = {
4+
sync: true,
5+
trace: false,
6+
} satisfies ToolMetadata;
7+
18
export function filter(opts: { in: any[]; [key: string]: any }) {
29
const { in: arr, ...criteria } = opts;
310
return arr.filter((obj) => {
@@ -10,6 +17,8 @@ export function filter(opts: { in: any[]; [key: string]: any }) {
1017
});
1118
}
1219

20+
filter.bridge = syncUtility;
21+
1322
export function find(opts: { in: any[]; [key: string]: any }) {
1423
const { in: arr, ...criteria } = opts;
1524
return arr.find((obj) => {
@@ -22,6 +31,8 @@ export function find(opts: { in: any[]; [key: string]: any }) {
2231
});
2332
}
2433

34+
find.bridge = syncUtility;
35+
2536
/**
2637
* Returns the first element of the array in `opts.in`.
2738
*
@@ -47,6 +58,8 @@ export function first(opts: { in: any[]; strict?: boolean | string }) {
4758
return Array.isArray(arr) ? arr[0] : undefined;
4859
}
4960

61+
first.bridge = syncUtility;
62+
5063
/**
5164
* Wraps a single value in an array.
5265
*
@@ -55,3 +68,5 @@ export function first(opts: { in: any[]; strict?: boolean | string }) {
5568
export function toArray(opts: { in: any }) {
5669
return Array.isArray(opts.in) ? opts.in : [opts.in];
5770
}
71+
72+
toArray.bridge = syncUtility;

packages/bridge-stdlib/src/tools/audit.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { ToolContext } from "@stackables/bridge-types";
1+
import type { ToolContext, ToolMetadata } from "@stackables/bridge-types";
2+
3+
const syncUtility = {
4+
sync: true,
5+
trace: false,
6+
} satisfies ToolMetadata;
27

38
/**
49
* Built-in audit tool — logs all inputs via the engine logger.
@@ -39,3 +44,5 @@ export function audit(input: Record<string, any>, context?: ToolContext) {
3944
log?.(data, "[bridge:audit]");
4045
return input;
4146
}
47+
48+
audit.bridge = syncUtility;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
1+
import type { ToolMetadata } from "@stackables/bridge-types";
2+
3+
const syncUtility = {
4+
sync: true,
5+
trace: false,
6+
} satisfies ToolMetadata;
7+
18
export function toLowerCase(opts: { in: string }) {
29
return opts.in?.toLowerCase();
310
}
411

12+
toLowerCase.bridge = syncUtility;
13+
514
export function toUpperCase(opts: { in: string }) {
615
return opts.in?.toUpperCase();
716
}
817

18+
toUpperCase.bridge = syncUtility;
19+
920
export function trim(opts: { in: string }) {
1021
return opts.in?.trim();
1122
}
1223

24+
trim.bridge = syncUtility;
25+
1326
export function length(opts: { in: string }) {
1427
return opts.in?.length;
1528
}
29+
30+
length.bridge = syncUtility;

0 commit comments

Comments
 (0)