From 9d3a01f6da0fdc0b947adceac039364d94b30205 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Fri, 6 Mar 2026 09:16:32 +0100 Subject: [PATCH 1/2] feat: ToolMetadata --- packages/bridge-core/src/ExecutionTree.ts | 36 +++---- packages/bridge-core/src/index.ts | 2 +- packages/bridge-core/src/tracing.ts | 107 +++++++++++++++++-- packages/bridge-core/src/types.ts | 1 + packages/bridge-graphql/test/logging.test.ts | 14 ++- packages/bridge-stdlib/src/tools/arrays.ts | 15 +++ packages/bridge-stdlib/src/tools/audit.ts | 9 +- packages/bridge-stdlib/src/tools/strings.ts | 15 +++ packages/bridge-types/src/index.ts | 54 ++++++++++ 9 files changed, 220 insertions(+), 33 deletions(-) diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index ee42fa62..95bb6607 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -5,12 +5,15 @@ import { internal } from "./tools/index.ts"; import type { ToolTrace } from "./tracing.ts"; import { isOtelActive, - otelTracer, - SpanStatusCodeEnum, + logToolError, + logToolSuccess, + recordSpanError, + resolveToolMeta, toolCallCounter, toolDurationHistogram, toolErrorCounter, TraceCollector, + withSpan, } from "./tracing.ts"; import type { Logger, @@ -255,14 +258,17 @@ export class ExecutionTree implements TreeContext { } // ── Instrumented path ───────────────────────────────────────── + const { doTrace, log } = resolveToolMeta(fnImpl); const traceStart = tracer?.now(); const metricAttrs = { "bridge.tool.name": toolName, "bridge.tool.fn": fnName, }; - return otelTracer.startActiveSpan( + + return withSpan( + doTrace, `bridge.tool.${toolName}.${fnName}`, - { attributes: metricAttrs }, + metricAttrs, async (span) => { const wallStart = performance.now(); try { @@ -286,12 +292,7 @@ export class ExecutionTree implements TreeContext { }), ); } - logger?.debug?.( - "[bridge] tool %s (%s) completed in %dms", - toolName, - fnName, - durationMs, - ); + logToolSuccess(logger, log.execution, toolName, fnName, durationMs); return result; } catch (err) { const durationMs = roundMs(performance.now() - wallStart); @@ -310,17 +311,8 @@ export class ExecutionTree implements TreeContext { }), ); } - span.recordException(err as Error); - span.setStatus({ - code: SpanStatusCodeEnum.ERROR, - message: (err as Error).message, - }); - logger?.error?.( - "[bridge] tool %s (%s) failed: %s", - toolName, - fnName, - (err as Error).message, - ); + recordSpanError(span, err as Error); + logToolError(logger, log.errors, toolName, fnName, err as Error); // Normalize platform AbortError to BridgeAbortError if ( this.signal?.aborted && @@ -331,7 +323,7 @@ export class ExecutionTree implements TreeContext { } throw err; } finally { - span.end(); + span?.end(); } }, ); diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index e80a6515..650a050b 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -61,6 +61,7 @@ export type { ToolDef, ToolDep, ToolMap, + ToolMetadata, ToolWire, VersionDecl, Wire, @@ -74,4 +75,3 @@ export { matchesRequestedFields, filterOutputFields, } from "./requested-fields.ts"; - diff --git a/packages/bridge-core/src/tracing.ts b/packages/bridge-core/src/tracing.ts index b42f79f5..60905381 100644 --- a/packages/bridge-core/src/tracing.ts +++ b/packages/bridge-core/src/tracing.ts @@ -6,6 +6,9 @@ */ import { metrics, trace } from "@opentelemetry/api"; +import type { Span } from "@opentelemetry/api"; +import type { ToolMetadata } from "@stackables/bridge-types"; +import type { Logger } from "./tree-types.ts"; import { roundMs } from "./tree-utils.ts"; // ── OTel setup ────────────────────────────────────────────────────────────── @@ -45,8 +48,9 @@ export const toolErrorCounter = otelMeter.createCounter("bridge.tool.errors", { description: "Total number of tool invocation errors", }); -// Re-export SpanStatusCode for callTool usage +// Re-export SpanStatusCode for internal usage export { SpanStatusCode as SpanStatusCodeEnum } from "@opentelemetry/api"; +import { SpanStatusCode } from "@opentelemetry/api"; // ── Trace types ───────────────────────────────────────────────────────────── @@ -96,10 +100,7 @@ export function boundedClone( 0, Number.isFinite(maxStringLength) ? Math.floor(maxStringLength) : 1024, ); - const safeDepth = Math.max( - 0, - Number.isFinite(depth) ? Math.floor(depth) : 5, - ); + const safeDepth = Math.max(0, Number.isFinite(depth) ? Math.floor(depth) : 5); return _boundedClone(value, safeArrayItems, safeStringLength, safeDepth, 0); } @@ -165,7 +166,11 @@ export class TraceCollector { constructor( level: "basic" | "full" = "full", - options?: { maxArrayItems?: number; maxStringLength?: number; cloneDepth?: number }, + options?: { + maxArrayItems?: number; + maxStringLength?: number; + cloneDepth?: number; + }, ) { this.level = level; this.maxArrayItems = options?.maxArrayItems ?? 100; @@ -225,3 +230,93 @@ export class TraceCollector { return t; } } + +// ── Tool metadata helpers ──────────────────────────────────────────────────── + +/** + * Resolved logging behaviour derived from a tool's `.bridge` metadata. + * A fixed shape so call sites never branch on `undefined`. + */ +export type EffectiveToolLog = { + /** Logger level for successful invocations, or `false` to suppress. */ + execution: false | "debug" | "info"; + /** Logger level for thrown errors, or `false` to suppress. */ + errors: false | "warn" | "error"; +}; + +/** Normalised metadata resolved from the optional `.bridge` property. */ +export type ResolvedToolMeta = { + /** Emit an OTel span for this call. Default: `true`. */ + doTrace: boolean; + log: EffectiveToolLog; +}; + +function resolveToolLog(meta: ToolMetadata | undefined): EffectiveToolLog { + const log = meta?.log; + if (log === false) return { execution: false, errors: false }; + if (log == null) return { execution: false, errors: "error" }; + if (log === true) return { execution: "info", errors: "error" }; + return { + execution: + log.execution === "info" ? "info" : log.execution ? "debug" : false, + errors: + log.errors === false ? false : log.errors === "warn" ? "warn" : "error", + }; +} + +/** Read and normalise the `.bridge` metadata from a tool function. */ +export function resolveToolMeta(fn: (...args: any[]) => any): ResolvedToolMeta { + const bridge = (fn as any).bridge as ToolMetadata | undefined; + return { doTrace: bridge?.trace !== false, log: resolveToolLog(bridge) }; +} + +/** Log a successful tool invocation. No-ops when `level` is `false`. */ +export function logToolSuccess( + logger: Logger | undefined, + level: EffectiveToolLog["execution"], + toolName: string, + fnName: string, + durationMs: number, +): void { + if (!level) return; + logger?.[level]?.( + { tool: toolName, fn: fnName, durationMs }, + "[bridge] tool completed", + ); +} + +/** Log a tool error. No-ops when `level` is `false`. */ +export function logToolError( + logger: Logger | undefined, + level: EffectiveToolLog["errors"], + toolName: string, + fnName: string, + err: Error, +): void { + if (!level) return; + logger?.[level]?.( + { tool: toolName, fn: fnName, err: err.message }, + "[bridge] tool failed", + ); +} + +/** Record an exception on a span and mark it as errored. */ +export function recordSpanError(span: Span | undefined, err: Error): void { + if (!span) return; + span.recordException(err); + span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); +} + +/** + * Run `fn` inside an OTel span when `doTrace` is true; + * otherwise call it directly with `undefined` as the span argument. + */ +export function withSpan( + doTrace: boolean, + name: string, + attrs: Record, + fn: (span: Span | undefined) => Promise, +): Promise { + if (!doTrace) return fn(undefined); + return otelTracer.startActiveSpan(name, { attributes: attrs }, fn); +} diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 97cec2ab..d2524e76 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -236,6 +236,7 @@ export type { ToolContext, ToolCallFn, ToolMap, + ToolMetadata, CacheStore, } from "@stackables/bridge-types"; diff --git a/packages/bridge-graphql/test/logging.test.ts b/packages/bridge-graphql/test/logging.test.ts index 135f2a7e..46afd732 100644 --- a/packages/bridge-graphql/test/logging.test.ts +++ b/packages/bridge-graphql/test/logging.test.ts @@ -41,13 +41,19 @@ function createLogCapture(): Logger & { } { const debugMessages: string[] = []; const errorMessages: string[] = []; + // Structured log calls arrive as (data: object, msg: string) — Pino convention. + // Flatten to a single searchable string for test assertions. + const format = (...args: any[]): string => + args + .map((a) => (a && typeof a === "object" ? JSON.stringify(a) : String(a))) + .join(" "); return { debugMessages, errorMessages, - debug: (...args: any[]) => debugMessages.push(args.join(" ")), + debug: (...args: any[]) => debugMessages.push(format(...args)), info: () => {}, warn: () => {}, - error: (...args: any[]) => errorMessages.push(args.join(" ")), + error: (...args: any[]) => errorMessages.push(format(...args)), }; } @@ -55,8 +61,10 @@ describe("logging: basics", () => { test("logger.debug is called on successful tool call", async () => { const instructions = parseBridge(bridge); const logger = createLogCapture(); + const geocoder = async () => ({ label: "Berlin, DE" }); + geocoder.bridge = { log: { execution: "debug" as const } }; const schema = bridgeTransform(createSchema({ typeDefs }), instructions, { - tools: { geocoder: async () => ({ label: "Berlin, DE" }) }, + tools: { geocoder }, logger, }); const yoga = createYoga({ schema, graphqlEndpoint: "*" }); diff --git a/packages/bridge-stdlib/src/tools/arrays.ts b/packages/bridge-stdlib/src/tools/arrays.ts index 23cab810..6518e7b7 100644 --- a/packages/bridge-stdlib/src/tools/arrays.ts +++ b/packages/bridge-stdlib/src/tools/arrays.ts @@ -1,3 +1,10 @@ +import type { ToolMetadata } from "@stackables/bridge-types"; + +const syncUtility = { + sync: true, + trace: false, +} satisfies ToolMetadata; + export function filter(opts: { in: any[]; [key: string]: any }) { const { in: arr, ...criteria } = opts; return arr.filter((obj) => { @@ -10,6 +17,8 @@ export function filter(opts: { in: any[]; [key: string]: any }) { }); } +filter.bridge = syncUtility; + export function find(opts: { in: any[]; [key: string]: any }) { const { in: arr, ...criteria } = opts; return arr.find((obj) => { @@ -22,6 +31,8 @@ export function find(opts: { in: any[]; [key: string]: any }) { }); } +find.bridge = syncUtility; + /** * Returns the first element of the array in `opts.in`. * @@ -47,6 +58,8 @@ export function first(opts: { in: any[]; strict?: boolean | string }) { return Array.isArray(arr) ? arr[0] : undefined; } +first.bridge = syncUtility; + /** * Wraps a single value in an array. * @@ -55,3 +68,5 @@ export function first(opts: { in: any[]; strict?: boolean | string }) { export function toArray(opts: { in: any }) { return Array.isArray(opts.in) ? opts.in : [opts.in]; } + +toArray.bridge = syncUtility; diff --git a/packages/bridge-stdlib/src/tools/audit.ts b/packages/bridge-stdlib/src/tools/audit.ts index 5a15d240..df6c5ebb 100644 --- a/packages/bridge-stdlib/src/tools/audit.ts +++ b/packages/bridge-stdlib/src/tools/audit.ts @@ -1,4 +1,9 @@ -import type { ToolContext } from "@stackables/bridge-types"; +import type { ToolContext, ToolMetadata } from "@stackables/bridge-types"; + +const syncUtility = { + sync: true, + trace: false, +} satisfies ToolMetadata; /** * Built-in audit tool — logs all inputs via the engine logger. @@ -39,3 +44,5 @@ export function audit(input: Record, context?: ToolContext) { log?.(data, "[bridge:audit]"); return input; } + +audit.bridge = syncUtility; diff --git a/packages/bridge-stdlib/src/tools/strings.ts b/packages/bridge-stdlib/src/tools/strings.ts index 2c66eca8..a3bdffe5 100644 --- a/packages/bridge-stdlib/src/tools/strings.ts +++ b/packages/bridge-stdlib/src/tools/strings.ts @@ -1,15 +1,30 @@ +import type { ToolMetadata } from "@stackables/bridge-types"; + +const syncUtility = { + sync: true, + trace: false, +} satisfies ToolMetadata; + export function toLowerCase(opts: { in: string }) { return opts.in?.toLowerCase(); } +toLowerCase.bridge = syncUtility; + export function toUpperCase(opts: { in: string }) { return opts.in?.toUpperCase(); } +toUpperCase.bridge = syncUtility; + export function trim(opts: { in: string }) { return opts.in?.trim(); } +trim.bridge = syncUtility; + export function length(opts: { in: string }) { return opts.in?.length; } + +length.bridge = syncUtility; diff --git a/packages/bridge-types/src/index.ts b/packages/bridge-types/src/index.ts index 7c6cb785..e3ba8d2a 100644 --- a/packages/bridge-types/src/index.ts +++ b/packages/bridge-types/src/index.ts @@ -41,6 +41,60 @@ export type ToolCallFn = ( context?: ToolContext, ) => Promise>; +/** + * Optional metadata that can be attached to any tool function as a `.bridge` property. + * + * Used by the engine and observability layer to optimise execution and control telemetry. + * + * ```ts + * myTool.bridge = { + * sync: true, + * trace: false, + * log: { execution: false, errors: "error" }, + * } satisfies ToolMetadata; + * ``` + */ +export interface ToolMetadata { + // ─── Execution ──────────────────────────────────────────────────────── + + /** + * If true, the tool is a purely synchronous function. + * The compiler will bypass Promise wrappers and `await` for maximum throughput. + * Default: false + */ + sync?: boolean; + + // ─── Observability ──────────────────────────────────────────────────── + + /** + * Should the engine emit OpenTelemetry spans for this tool? + * Set to false for high-frequency utility functions to prevent trace spam. + * Default: true + */ + trace?: boolean; + + /** + * Granular control over the engine's automatic logging. + * If set to false, disables ALL logging for this tool. + */ + log?: + | boolean + | { + /** + * Log successful invocations (inputs, outputs, latency). + * Set to false to hide trace spam from loops. + * Default: true (or your global default log level) + */ + execution?: boolean | "debug" | "info"; + + /** + * Log exceptions thrown by the tool. + * Default: true + */ + errors?: boolean | "warn" | "error"; + }; +} + /** * Recursive tool map — supports namespaced tools via nesting. * From d2f1e5e81b1cc8968279708a81e743f85bb3fad1 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Fri, 6 Mar 2026 09:23:54 +0100 Subject: [PATCH 2/2] Changeset and docs --- .changeset/tool-metadata.md | 22 +++++++++++++++++++ .../src/content/docs/advanced/custom-tools.md | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .changeset/tool-metadata.md diff --git a/.changeset/tool-metadata.md b/.changeset/tool-metadata.md new file mode 100644 index 00000000..843bf40f --- /dev/null +++ b/.changeset/tool-metadata.md @@ -0,0 +1,22 @@ +--- +"@stackables/bridge-types": minor +"@stackables/bridge-core": minor +"@stackables/bridge-stdlib": patch +--- + +Add `ToolMetadata` — per-tool observability controls + +Tools can now attach a `.bridge` property to declare how the engine should +instrument them, imported as `ToolMetadata` from `@stackables/bridge`. + +```ts +import type { ToolMetadata } from "@stackables/bridge"; + +myTool.bridge = { + trace: false, // skip OTel span for this tool + log: { + execution: "info", // log successful calls at info level + errors: "error", // log failures at error level (default) + }, +} satisfies ToolMetadata; +``` diff --git a/packages/docs-site/src/content/docs/advanced/custom-tools.md b/packages/docs-site/src/content/docs/advanced/custom-tools.md index 74f1033a..29e6c2f7 100644 --- a/packages/docs-site/src/content/docs/advanced/custom-tools.md +++ b/packages/docs-site/src/content/docs/advanced/custom-tools.md @@ -86,3 +86,25 @@ export async function myHttpTool(input: { url: string }, context: ToolContext) { ``` By connecting the signal, the engine can instantly abort pending network requests the exact millisecond a failure state or client disconnect is detected, bypassing all local `?.` and `catch` fallbacks. + +## Tool Metadata + +You can attach a `.bridge` property to any tool function to control how the engine instruments it. Import `ToolMetadata` from `@stackables/bridge` for full type safety. + +```typescript +import type { ToolMetadata } from "@stackables/bridge"; + +export async function geocoder(input: { q: string }) { + return await geocodeService.lookup(input.q); +} + +geocoder.bridge = { + trace: true, // emit an OTel span (default: true) + log: { + // log successful calls at info level (default false) + execution: "info", + // log failures at error level (default error) + errors: "error", + }, +} satisfies ToolMetadata; +```