Skip to content

Commit 94eac10

Browse files
dmealingclaude
andcommitted
feat(ai): generic recordLlmCall writes base row (raw I/O); buildLlmCallRow + persistLlmCallRow; system field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b758344 commit 94eac10

3 files changed

Lines changed: 104 additions & 95 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
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 } from "./llm-recorder.js";
40+
export { LlmCallDbRecorder, NullRecorder, recordLlmCall, buildLlmCallRow, persistLlmCallRow } from "./llm-recorder.js";
4141
export type { LlmRecorder, LlmCallRow, LlmCallInput, RecordLlmCallOptions, RecordLlmCallResult } from "./llm-recorder.js";
Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
1-
// LLM call recorder seam + parse-then-persist helper.
1+
// LLM call recorder seam + base-row factory + shared persist helper.
22
//
33
// LlmRecorder is a thin write-side interface for persisting LLM call rows.
44
// LlmCallDbRecorder writes via ObjectManager.create (using an entity declared
55
// in the caller's own metadata). NullRecorder is the no-op implementation
66
// used in unit tests or when tracing is disabled.
77
//
8-
// recordLlmCall combines the extract step (LLM response text → typed VO via
9-
// the FR-010 extractObject engine) with the persist step in a single
10-
// failure-resilient transaction: a lost-required field still writes a row
11-
// (status="error") so every call is observable.
8+
// recordLlmCall is the GENERIC trace path: it builds the base trace row (the
9+
// envelope + the raw `llmRequest`/`llmResponse` columns declared on the shipped
10+
// `LlmCallBase`) and persists it. It does NOT parse the response into a typed
11+
// VO — that extract step + the typed voRequest/voResponse columns live on the
12+
// generated typed helper (a later layer), so this generic path only ever writes
13+
// the base field set.
1214

1315
import type { ObjectManager } from "./object-manager.js";
14-
import { extractSchemaFor } from "./extract-object.js";
15-
import { Format, extract } from "@metaobjectsdev/render";
16-
import type { ExtractOptions } from "@metaobjectsdev/render";
17-
import type { MetaObject } from "@metaobjectsdev/metadata";
1816

1917
// =============================================================================
2018
// Public types
@@ -55,7 +53,7 @@ export class LlmCallDbRecorder implements LlmRecorder {
5553
}
5654

5755
// =============================================================================
58-
// recordLlmCall — parse-then-persist
56+
// recordLlmCall — generic base-row persist
5957
// =============================================================================
6058

6159
export interface LlmCallInput {
@@ -66,9 +64,12 @@ export interface LlmCallInput {
6664
/** Logical session/conversation id (gen_ai session grouping). */
6765
sessionId?: string;
6866
callType: string;
67+
/** gen_ai.system — provider name, caller-supplied. */
68+
system?: string;
6969
/** ISO 8601 timestamp, supplied by the caller before the LLM call was made. */
7070
startedAt: string;
7171
llmRequest: unknown;
72+
/** Raw response text/body — stored as the raw `llmResponse` column. */
7273
llmResponseText: string;
7374
requestModel?: string;
7475
/** gen_ai.response.model — the model the provider actually used. */
@@ -78,70 +79,68 @@ export interface LlmCallInput {
7879
costMinor?: number;
7980
latencyMs?: number;
8081
finishReason?: string;
82+
/** Call outcome, caller-supplied (provider/parse failure → "error"). */
83+
status: "ok" | "error";
84+
/** Failure detail (null on success). */
85+
errorDetail: string | null;
8186
}
8287

8388
export interface RecordLlmCallOptions {
8489
recorder: LlmRecorder;
85-
responseMo: MetaObject;
86-
format?: Format;
87-
extractOpts?: Partial<ExtractOptions>;
90+
/** Optional scrub/cap applied immediately before persist (PII/secrets). */
91+
redact?: (row: LlmCallRow) => LlmCallRow;
8892
}
8993

9094
export interface RecordLlmCallResult {
91-
voResponse: Record<string, unknown> | null;
9295
status: "ok" | "error";
9396
errorDetail: string | null;
9497
}
9598

96-
/**
97-
* Parse `input.llmResponseText` into a typed VO, then persist a trace row via
98-
* `opts.recorder` regardless of whether parsing succeeded.
99-
*
100-
* Contract:
101-
* - A lost-required field → status "error", voResponse null, errorDetail set,
102-
* row STILL persisted.
103-
* - A successful parse → status "ok", voResponse is the plain-object form,
104-
* row persisted with voResponse populated.
105-
* - DB errors propagate (never swallowed).
106-
*/
107-
export async function recordLlmCall(
108-
input: LlmCallInput,
109-
opts: RecordLlmCallOptions,
110-
): Promise<RecordLlmCallResult> {
111-
// Use the lower-level extract() to get a plain Record (no cyclic back-ref).
112-
// extractSchemaFor builds the field descriptors; extract() runs the engine.
113-
const schema = extractSchemaFor(opts.responseMo, opts.format ?? Format.JSON);
114-
const outcome = extract(input.llmResponseText, schema, opts.extractOpts);
115-
116-
const failed = outcome.report.hasLostRequired();
117-
const status: "ok" | "error" = failed ? "error" : "ok";
118-
const errorDetail: string | null = failed
119-
? `lost required: ${outcome.report.lostRequired().join(", ")}`
120-
: null;
121-
// outcome.data is already a plain Record<string,unknown> — safe to store.
122-
const voResponse: Record<string, unknown> | null = failed ? null : outcome.data;
123-
124-
const row: LlmCallRow = {
125-
spanId: input.spanId,
99+
/** Build the base trace row (envelope + raw llmRequest/llmResponse) — key set
100+
* is exactly LlmCallBase's fields. Typed voRequest/voResponse are added by the
101+
* generated typed helper, never here. */
102+
export function buildLlmCallRow(input: LlmCallInput): LlmCallRow {
103+
return {
126104
traceId: input.traceId,
105+
spanId: input.spanId,
127106
parentSpanId: input.parentSpanId ?? null,
128107
sessionId: input.sessionId ?? null,
129108
callType: input.callType,
109+
system: input.system ?? null,
130110
requestModel: input.requestModel ?? null,
131111
responseModel: input.responseModel ?? null,
132112
inputTokens: input.inputTokens ?? null,
133113
outputTokens: input.outputTokens ?? null,
134114
costMinor: input.costMinor ?? null,
135115
latencyMs: input.latencyMs ?? null,
136116
finishReason: input.finishReason ?? null,
137-
status,
138-
errorDetail,
117+
status: input.status,
118+
errorDetail: input.errorDetail,
139119
startedAt: input.startedAt,
140120
llmRequest: JSON.stringify(input.llmRequest),
141-
voResponse,
121+
llmResponse: JSON.stringify(input.llmResponseText),
142122
};
123+
}
143124

144-
await opts.recorder.record(row);
125+
/** Shared persist step: redact then record. Used by recordLlmCall AND (later) the
126+
* generated typed helper, so redaction applies on both paths. */
127+
export async function persistLlmCallRow(
128+
recorder: LlmRecorder,
129+
row: LlmCallRow,
130+
opts?: { redact?: (row: LlmCallRow) => LlmCallRow },
131+
): Promise<void> {
132+
await recorder.record(opts?.redact ? opts.redact(row) : row);
133+
}
145134

146-
return { voResponse, status, errorDetail };
135+
/** Persist one base trace row (envelope + raw I/O). Generic — does not extract. */
136+
export async function recordLlmCall(
137+
input: LlmCallInput,
138+
opts: RecordLlmCallOptions,
139+
): Promise<RecordLlmCallResult> {
140+
await persistLlmCallRow(
141+
opts.recorder,
142+
buildLlmCallRow(input),
143+
opts.redact ? { redact: opts.redact } : undefined,
144+
);
145+
return { status: input.status, errorDetail: input.errorDetail };
147146
}

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

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { describe, test, expect } from "bun:test";
22
import { MetaDataLoader } from "@metaobjectsdev/metadata";
3-
import type { MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
4-
import { Format } from "@metaobjectsdev/render";
3+
import type { MetaRoot } from "@metaobjectsdev/metadata";
54
import { ObjectManager } from "../src/object-manager.js";
65
import { inMemoryDriver } from "../src/drivers/in-memory-driver.js";
76
import {
87
NullRecorder,
98
LlmCallDbRecorder,
109
recordLlmCall,
10+
buildLlmCallRow,
1111
type LlmCallRow,
12+
type LlmCallInput,
1213
} from "../src/llm-recorder.js";
1314

1415
// =============================================================================
@@ -36,6 +37,7 @@ const META_JSON = {
3637
{ "field.string": { name: "parentSpanId" } },
3738
{ "field.string": { name: "sessionId" } },
3839
{ "field.string": { name: "callType" } },
40+
{ "field.string": { name: "system" } },
3941
{ "field.string": { name: "requestModel" } },
4042
{ "field.string": { name: "responseModel" } },
4143
{ "field.long": { name: "inputTokens" } },
@@ -50,6 +52,8 @@ const META_JSON = {
5052
// ObjectManager writes the JSON.stringify'd string; in-memory driver
5153
// keeps it as a string on read-back.
5254
{ "field.string": { name: "llmRequest", "@dbColumnType": "jsonb" } },
55+
// llmResponse: raw response body as jsonb string (matches LlmCallBase).
56+
{ "field.string": { name: "llmResponse", "@dbColumnType": "jsonb" } },
5357
// voResponse: typed VO stored as jsonb via @objectRef + @storage.
5458
// ObjectManager validates the object against VerdictResponse and stores it.
5559
{ "field.object": { name: "voResponse", "@objectRef": "VerdictResponse", "@storage": "jsonb" } },
@@ -133,13 +137,11 @@ describe("LlmCallDbRecorder", () => {
133137
// Task 2 — recordLlmCall
134138
// =============================================================================
135139

136-
describe("recordLlmCall", () => {
137-
test("good responsestatus ok, voResponse populated, row persisted", async () => {
140+
describe("recordLlmCall (generic base-row path)", () => {
141+
test("status okbase row persisted (raw llmRequest/llmResponse, no voResponse)", async () => {
138142
const root = await loadFixture();
139143
const om = makeOm(root);
140144
const recorder = new LlmCallDbRecorder(om, "TraceCall");
141-
const verdictMo = root.findObject("VerdictResponse") as MetaObject;
142-
expect(verdictMo).toBeDefined();
143145

144146
const result = await recordLlmCall(
145147
{
@@ -149,29 +151,34 @@ describe("recordLlmCall", () => {
149151
startedAt: "2026-01-01T00:00:00Z",
150152
llmRequest: { prompt: "hello" },
151153
llmResponseText: '{"verdict":"approve","score":90}',
154+
status: "ok",
155+
errorDetail: null,
152156
},
153-
{ recorder, responseMo: verdictMo, format: Format.JSON },
157+
{ recorder },
154158
);
155159

160+
// recordLlmCall echoes the caller-supplied outcome; it does not extract.
156161
expect(result.status).toBe("ok");
157162
expect(result.errorDetail).toBeNull();
158-
expect(result.voResponse).toEqual({ verdict: "approve", score: 90 });
159163

160-
// Row must have been persisted
164+
// Row must have been persisted with the raw envelope + I/O.
161165
const row = await om.findById("TraceCall", 1);
162166
expect(row).not.toBeNull();
163167
expect(row!.spanId).toBe("span-ok");
164168
expect(row!.status).toBe("ok");
165-
expect(row!.voResponse).toEqual({ verdict: "approve", score: 90 });
169+
// The generic path never writes voResponse — TraceCall declares it nullable,
170+
// so it stays absent/null in the stored row.
171+
expect(row!.voResponse ?? null).toBeNull();
166172
// In-memory driver stores llmRequest as the JSON string (field.string + @dbColumnType jsonb).
167173
expect(JSON.parse(row!.llmRequest as string)).toEqual({ prompt: "hello" });
174+
// llmResponse stores the JSON-encoded raw response text.
175+
expect(JSON.parse(row!.llmResponse as string)).toBe('{"verdict":"approve","score":90}');
168176
});
169177

170-
test("bad response (missing required verdict) → status error, voResponse null, row still persisted", async () => {
178+
test("caller-supplied error outcome is threaded onto the persisted row", async () => {
171179
const root = await loadFixture();
172180
const om = makeOm(root);
173181
const recorder = new LlmCallDbRecorder(om, "TraceCall");
174-
const verdictMo = root.findObject("VerdictResponse") as MetaObject;
175182

176183
const result = await recordLlmCall(
177184
{
@@ -181,45 +188,28 @@ describe("recordLlmCall", () => {
181188
startedAt: "2026-01-01T00:00:00Z",
182189
llmRequest: { prompt: "hello" },
183190
llmResponseText: '{"score":50}',
191+
status: "error",
192+
errorDetail: "lost required: verdict",
184193
},
185-
{ recorder, responseMo: verdictMo, format: Format.JSON },
194+
{ recorder },
186195
);
187196

188197
expect(result.status).toBe("error");
189-
expect(result.voResponse).toBeNull();
190-
expect(typeof result.errorDetail).toBe("string");
191-
expect(result.errorDetail).toContain("verdict");
198+
expect(result.errorDetail).toBe("lost required: verdict");
192199

193-
// Row must STILL have been persisted (failure-resilient contract)
200+
// Row must STILL have been persisted (every call is observable).
194201
const row = await om.findById("TraceCall", 1);
195202
expect(row).not.toBeNull();
196203
expect(row!.spanId).toBe("span-bad");
197204
expect(row!.status).toBe("error");
198-
expect(row!.voResponse).toBeNull();
199-
expect(typeof row!.errorDetail).toBe("string");
205+
expect(row!.errorDetail).toBe("lost required: verdict");
200206
});
201207
});
202208

203209
// =============================================================================
204210
// Task 4 — parentSpanId + sessionId envelope fields
205211
// =============================================================================
206212

207-
// Minimal metadata: a value object with one required string field.
208-
// Mirrors the VerdictResponse shape used in the integration test.
209-
const ENVELOPE_META = JSON.stringify({
210-
"metadata.root": {
211-
package: "test::ai",
212-
children: [
213-
{
214-
"object.value": {
215-
name: "Resp",
216-
children: [{ "field.string": { name: "verdict", "@required": true } }],
217-
},
218-
},
219-
],
220-
},
221-
});
222-
223213
// A capturing recorder that stores the last row written by recordLlmCall.
224214
class CaptureRecorder extends NullRecorder {
225215
last: LlmCallRow | null = null;
@@ -228,16 +218,9 @@ class CaptureRecorder extends NullRecorder {
228218
}
229219
}
230220

231-
async function loadRespMo() {
232-
const res = await MetaDataLoader.fromString(ENVELOPE_META, "json");
233-
expect(res.errors).toEqual([]);
234-
return res.root.findObject("Resp")!;
235-
}
236-
237221
describe("recordLlmCall envelope fields — parentSpanId + sessionId", () => {
238222
test("threads parentSpanId + sessionId into the persisted row", async () => {
239223
const rec = new CaptureRecorder();
240-
const responseMo = await loadRespMo();
241224
await recordLlmCall(
242225
{
243226
spanId: "s1",
@@ -248,16 +231,17 @@ describe("recordLlmCall envelope fields — parentSpanId + sessionId", () => {
248231
startedAt: "2026-06-05T00:00:00Z",
249232
llmRequest: { a: 1 },
250233
llmResponseText: JSON.stringify({ verdict: "ok" }),
234+
status: "ok",
235+
errorDetail: null,
251236
},
252-
{ recorder: rec, responseMo },
237+
{ recorder: rec },
253238
);
254239
expect(rec.last?.parentSpanId).toBe("p1");
255240
expect(rec.last?.sessionId).toBe("sess1");
256241
});
257242

258243
test("omitted parentSpanId + sessionId default to null in the row", async () => {
259244
const rec = new CaptureRecorder();
260-
const responseMo = await loadRespMo();
261245
await recordLlmCall(
262246
{
263247
spanId: "s2",
@@ -266,10 +250,36 @@ describe("recordLlmCall envelope fields — parentSpanId + sessionId", () => {
266250
startedAt: "2026-06-05T00:00:00Z",
267251
llmRequest: {},
268252
llmResponseText: JSON.stringify({ verdict: "ok" }),
253+
status: "ok",
254+
errorDetail: null,
269255
},
270-
{ recorder: rec, responseMo },
256+
{ recorder: rec },
271257
);
272258
expect(rec.last?.parentSpanId).toBeNull();
273259
expect(rec.last?.sessionId).toBeNull();
274260
});
275261
});
262+
263+
// =============================================================================
264+
// Task 1 (P0) — buildLlmCallRow: base-row factory (exactly LlmCallBase fields)
265+
// =============================================================================
266+
267+
const BASE_INPUT: LlmCallInput = {
268+
spanId: "s", traceId: "t", callType: "X",
269+
startedAt: "2026-06-06T00:00:00Z",
270+
llmRequest: { q: 1 }, llmResponseText: '{"a":1}',
271+
status: "ok", errorDetail: null,
272+
};
273+
274+
describe("buildLlmCallRow", () => {
275+
test("row keys exactly match LlmCallBase's 18 fields (no voResponse; with llmResponse + system)", () => {
276+
const row = buildLlmCallRow({ ...BASE_INPUT, system: "anthropic" });
277+
const base = ["traceId","spanId","parentSpanId","sessionId","callType","system",
278+
"requestModel","responseModel","inputTokens","outputTokens","costMinor",
279+
"latencyMs","finishReason","status","errorDetail","startedAt","llmRequest","llmResponse"];
280+
expect(Object.keys(row).sort()).toEqual([...base].sort());
281+
expect("voResponse" in row).toBe(false);
282+
expect(row.system).toBe("anthropic");
283+
expect(row.llmRequest).toBe('{"q":1}');
284+
});
285+
});

0 commit comments

Comments
 (0)