Skip to content

Commit 02288e6

Browse files
dmealingclaude
andcommitted
feat(ai): recorder redaction/truncateRow seam + never-throw LlmCallDbRecorder
Task 2: prove the redact seam (existing wiring confirmed via test) + add truncateRow helper that caps llmRequest/llmResponse strings to maxChars — composable into a redact fn to bound trace-row size. Task 3: LlmCallDbRecorder now accepts an optional opts.onError callback and wraps om.create in try/catch; default onError is a no-op so telemetry can never surface into the call path. LlmCallDbRecorderOpts exported from index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 94eac10 commit 02288e6

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

server/typescript/packages/runtime-ts/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,5 @@ export {
3737
// Format is re-exported here so callers of recordLlmCall can import it from a
3838
// single location rather than reaching into @metaobjectsdev/render directly.
3939
export { Format } from "@metaobjectsdev/render";
40-
export { LlmCallDbRecorder, NullRecorder, recordLlmCall, buildLlmCallRow, persistLlmCallRow } from "./llm-recorder.js";
41-
export type { LlmRecorder, LlmCallRow, LlmCallInput, RecordLlmCallOptions, RecordLlmCallResult } from "./llm-recorder.js";
40+
export { LlmCallDbRecorder, NullRecorder, recordLlmCall, buildLlmCallRow, persistLlmCallRow, truncateRow } from "./llm-recorder.js";
41+
export type { LlmRecorder, LlmCallRow, LlmCallInput, RecordLlmCallOptions, RecordLlmCallResult, LlmCallDbRecorderOpts } from "./llm-recorder.js";

server/typescript/packages/runtime-ts/src/llm-recorder.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,28 @@ export class NullRecorder implements LlmRecorder {
3838
// LlmCallDbRecorder — persists via ObjectManager
3939
// =============================================================================
4040

41+
export interface LlmCallDbRecorderOpts {
42+
/** Called when om.create throws. Default: swallow. Telemetry never breaks the app. */
43+
onError?: (error: unknown) => void;
44+
}
45+
4146
export class LlmCallDbRecorder implements LlmRecorder {
4247
private readonly om: ObjectManager;
4348
private readonly entityName: string;
49+
private readonly onError: (error: unknown) => void;
4450

45-
constructor(om: ObjectManager, entityName: string) {
51+
constructor(om: ObjectManager, entityName: string, opts?: LlmCallDbRecorderOpts) {
4652
this.om = om;
4753
this.entityName = entityName;
54+
this.onError = opts?.onError ?? (() => {});
4855
}
4956

5057
async record(call: LlmCallRow): Promise<void> {
51-
await this.om.create(this.entityName, call);
58+
try {
59+
await this.om.create(this.entityName, call);
60+
} catch (err) {
61+
this.onError(err);
62+
}
5263
}
5364
}
5465

@@ -132,6 +143,15 @@ export async function persistLlmCallRow(
132143
await recorder.record(opts?.redact ? opts.redact(row) : row);
133144
}
134145

146+
/** Cap the raw `llmRequest`/`llmResponse` string columns to `maxChars`.
147+
* Adopters compose this into a `redact` to bound trace-row size. Only the two
148+
* raw string columns are touched; all other fields pass through unchanged. */
149+
export function truncateRow(row: LlmCallRow, maxChars: number): LlmCallRow {
150+
const cap = (v: unknown): unknown =>
151+
typeof v === "string" && v.length > maxChars ? v.slice(0, maxChars) : v;
152+
return { ...row, llmRequest: cap(row.llmRequest), llmResponse: cap(row.llmResponse) };
153+
}
154+
135155
/** Persist one base trace row (envelope + raw I/O). Generic — does not extract. */
136156
export async function recordLlmCall(
137157
input: LlmCallInput,

server/typescript/packages/runtime-ts/test/llm-recorder.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,62 @@ describe("recordLlmCall envelope fields — parentSpanId + sessionId", () => {
260260
});
261261
});
262262

263+
// =============================================================================
264+
// Task 2 (P0) — redaction seam + truncateRow helper
265+
// =============================================================================
266+
267+
import { truncateRow } from "../src/llm-recorder.js";
268+
269+
class CaptureRedact extends NullRecorder {
270+
last: LlmCallRow | null = null;
271+
override async record(c: LlmCallRow): Promise<void> { this.last = c; }
272+
}
273+
274+
describe("recorder redaction + truncation", () => {
275+
test("redact is applied before persist", async () => {
276+
const rec = new CaptureRedact();
277+
await recordLlmCall(
278+
{ spanId:"s", traceId:"t", callType:"X", startedAt:"2026-06-06T00:00:00Z",
279+
llmRequest:{ secret:"sk-123" }, llmResponseText:"{}", status:"ok", errorDetail:null },
280+
{ recorder: rec, redact: (row) => ({ ...row, llmRequest: "[redacted]" }) },
281+
);
282+
expect(rec.last?.llmRequest).toBe("[redacted]");
283+
});
284+
285+
test("truncateRow caps the raw llmRequest/llmResponse strings", () => {
286+
const row: LlmCallRow = { llmRequest: "0123456789abcdef", llmResponse: "xxxxxxxxxx", callType: "X" };
287+
const capped = truncateRow(row, 5);
288+
expect(capped.llmRequest).toBe("01234");
289+
expect(capped.llmResponse).toBe("xxxxx");
290+
expect(capped.callType).toBe("X"); // non-raw fields untouched
291+
});
292+
});
293+
294+
// =============================================================================
295+
// Task 3 (P0) — LlmCallDbRecorder never-throw
296+
// =============================================================================
297+
298+
import { LlmCallDbRecorder as LlmCallDbRecorderImport } from "../src/llm-recorder.js";
299+
import type { ObjectManager as ObjectManagerType } from "../src/object-manager.js";
300+
301+
describe("LlmCallDbRecorder never-throw", () => {
302+
test("swallows om.create failure via onError, never throws", async () => {
303+
const om = { create: async () => { throw new Error("db down"); } } as unknown as ObjectManagerType;
304+
const errs: unknown[] = [];
305+
const rec = new LlmCallDbRecorderImport(om, "TraceCall", { onError: (e) => errs.push(e) });
306+
await rec.record({ spanId: "s" }); // must NOT throw
307+
expect(errs.length).toBe(1);
308+
expect(String((errs[0] as Error).message)).toContain("db down");
309+
});
310+
311+
test("default onError swallows (no throw, no crash)", async () => {
312+
const om = { create: async () => { throw new Error("x"); } } as unknown as ObjectManagerType;
313+
const rec = new LlmCallDbRecorderImport(om, "TraceCall");
314+
await rec.record({ spanId: "s" }); // must resolve, not reject
315+
expect(true).toBe(true);
316+
});
317+
});
318+
263319
// =============================================================================
264320
// Task 1 (P0) — buildLlmCallRow: base-row factory (exactly LlmCallBase fields)
265321
// =============================================================================

0 commit comments

Comments
 (0)