Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/tool-metadata.md
Original file line number Diff line number Diff line change
@@ -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;
```
36 changes: 14 additions & 22 deletions packages/bridge-core/src/ExecutionTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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 &&
Expand All @@ -331,7 +323,7 @@ export class ExecutionTree implements TreeContext {
}
throw err;
} finally {
span.end();
span?.end();
}
},
);
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type {
ToolDef,
ToolDep,
ToolMap,
ToolMetadata,
ToolWire,
VersionDecl,
Wire,
Expand All @@ -74,4 +75,3 @@ export {
matchesRequestedFields,
filterOutputFields,
} from "./requested-fields.ts";

107 changes: 101 additions & 6 deletions packages/bridge-core/src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(
doTrace: boolean,
name: string,
attrs: Record<string, string>,
fn: (span: Span | undefined) => Promise<T>,
): Promise<T> {
if (!doTrace) return fn(undefined);
return otelTracer.startActiveSpan(name, { attributes: attrs }, fn);
}
1 change: 1 addition & 0 deletions packages/bridge-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export type {
ToolContext,
ToolCallFn,
ToolMap,
ToolMetadata,
CacheStore,
} from "@stackables/bridge-types";

Expand Down
14 changes: 11 additions & 3 deletions packages/bridge-graphql/test/logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,30 @@ 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)),
};
}

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: "*" });
Expand Down
15 changes: 15 additions & 0 deletions packages/bridge-stdlib/src/tools/arrays.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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`.
*
Expand All @@ -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.
*
Expand All @@ -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;
9 changes: 8 additions & 1 deletion packages/bridge-stdlib/src/tools/audit.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -39,3 +44,5 @@ export function audit(input: Record<string, any>, context?: ToolContext) {
log?.(data, "[bridge:audit]");
return input;
}

audit.bridge = syncUtility;
15 changes: 15 additions & 0 deletions packages/bridge-stdlib/src/tools/strings.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading