Skip to content

Commit db3ea7d

Browse files
fix: serialize wire fields at top level from every entry point (0.5.1)
The v0.5.0 contract defines model, provider, input_tokens, output_tokens, total_tokens, duration_ms, ttft_ms, and cost as top-level Event properties so the ingest server writes them into typed Postgres columns. Two SDK paths leaked them into metadata instead, silently bypassing the typed-column path once the server started reading them from the top of the payload: - Generation.event() took only (type, metadata) so wire fields passed via metadata stayed inside metadata rather than surfacing top-level. - Feature.generation() passed model top-level via the merged spread AND injected baseMetadata.model = this.defaults.model, duplicating it into metadata. It also didn't accept per-call tokens/cost/latency. - Feature.track() never forwarded model/provider defaults to TrackEvent top-level, only into metadata.feature. Fix: Generation.event() now types wire keys explicitly on the metadata param and destructures them out before sending (they land at the top of TrackEvent, not metadata). Feature.generation() accepts per-call wire fields and no longer duplicates model into baseMetadata. Feature.track() forwards model/provider defaults top-level. Added "wire-level fields" regression block in tests/tracking.test.ts covering track(), generation(), gen.event(), feature.generation(), feature.track(), feature-default, and per-call override paths. Updated the test that asserted metadata.model == "gpt-4o" to assert top-level and absence from metadata.
1 parent c0cdd86 commit db3ea7d

8 files changed

Lines changed: 339 additions & 31 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@trylitmus/sdk",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"files": [
55
"dist"
66
],

src/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import { AbandonDetector, DEFAULT_ABANDON_THRESHOLD_MS } from "./abandon.js";
2323
import { ConsentManager } from "./consent.js";
24+
import { collectStartupMetadata } from "./environment.js";
2425
import { Feature } from "./feature.js";
2526
import { Generation } from "./generation.js";
2627
import { createLogger, type Logger } from "./logger.js";
@@ -36,7 +37,6 @@ import type {
3637
TrackEvent,
3738
} from "./types.js";
3839
import { SDK_NAME, SDK_VERSION } from "./version.js";
39-
import { collectStartupMetadata } from "./environment.js";
4040

4141
// ---------------------------------------------------------------------------
4242
// Constants
@@ -339,8 +339,8 @@ export class LitmusClient implements GenerationHost {
339339
// Sanitize numeric fields. NaN/Infinity would cause Postgres NUMERIC
340340
// columns to reject the entire batch insert.
341341
const sanitized = { ...event };
342-
for (const key of ['cost', 'input_tokens', 'output_tokens', 'total_tokens', 'duration_ms', 'ttft_ms'] as const) {
343-
if (key in sanitized && typeof sanitized[key] === 'number' && !Number.isFinite(sanitized[key])) {
342+
for (const key of ["cost", "input_tokens", "output_tokens", "total_tokens", "duration_ms", "ttft_ms"] as const) {
343+
if (key in sanitized && typeof sanitized[key] === "number" && !Number.isFinite(sanitized[key])) {
344344
delete sanitized[key];
345345
}
346346
}

src/environment.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
declare const Deno: unknown;
1414
declare const Bun: unknown;
1515
declare const EdgeRuntime: unknown;
16-
declare const process: undefined | {
17-
versions?: { node?: string };
18-
platform?: string;
19-
arch?: string;
20-
memoryUsage?: () => { rss: number };
21-
env?: Record<string, string | undefined>;
22-
};
16+
declare const process:
17+
| undefined
18+
| {
19+
versions?: { node?: string };
20+
platform?: string;
21+
arch?: string;
22+
memoryUsage?: () => { rss: number };
23+
env?: Record<string, string | undefined>;
24+
};
2325

2426
export function detectPlatform(): string {
2527
if (typeof Deno !== "undefined") return "deno";
@@ -73,9 +75,7 @@ function browserContext(): Record<string, unknown> {
7375

7476
// User preferences
7577
if (typeof window.matchMedia === "function") {
76-
ctx.prefers_color_scheme = window.matchMedia("(prefers-color-scheme: dark)").matches
77-
? "dark"
78-
: "light";
78+
ctx.prefers_color_scheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
7979
ctx.prefers_reduced_motion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8080
}
8181
}
@@ -103,9 +103,11 @@ function browserContext(): Record<string, unknown> {
103103
function detectCloud(env: Record<string, string | undefined>): Record<string, unknown> | undefined {
104104
// Order matters: more specific checks first.
105105
if (env.VERCEL) return { cloud_provider: "vercel", cloud_region: env.VERCEL_REGION };
106-
if (env.AWS_REGION) return { cloud_provider: "aws", cloud_region: env.AWS_REGION, cloud_platform: env.AWS_EXECUTION_ENV };
106+
if (env.AWS_REGION)
107+
return { cloud_provider: "aws", cloud_region: env.AWS_REGION, cloud_platform: env.AWS_EXECUTION_ENV };
107108
if (env.FLY_REGION) return { cloud_provider: "fly", cloud_region: env.FLY_REGION };
108-
if (env.RAILWAY_ENVIRONMENT_NAME) return { cloud_provider: "railway", cloud_environment: env.RAILWAY_ENVIRONMENT_NAME };
109+
if (env.RAILWAY_ENVIRONMENT_NAME)
110+
return { cloud_provider: "railway", cloud_environment: env.RAILWAY_ENVIRONMENT_NAME };
109111
if (env.RENDER) return { cloud_provider: "render", cloud_region: env.RENDER_REGION };
110112
if (env.GCP_PROJECT || env.GOOGLE_CLOUD_PROJECT) return { cloud_provider: "gcp" };
111113
if (env.WEBSITE_SITE_NAME && env.REGION_NAME) return { cloud_provider: "azure", cloud_region: env.REGION_NAME };

src/feature.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export interface FeatureHost {
2424
opts?: FeatureDefaults & {
2525
prompt_version?: string;
2626
metadata?: Record<string, unknown>;
27+
input_tokens?: number;
28+
output_tokens?: number;
29+
total_tokens?: number;
30+
duration_ms?: number;
31+
ttft_ms?: number;
32+
cost?: number;
2733
},
2834
): Generation;
2935
}
@@ -40,30 +46,52 @@ export class Feature {
4046
this.defaults = { ...defaults, prompt_id: defaults.prompt_id ?? name };
4147
}
4248

49+
/**
50+
* Create a generation scoped to this feature.
51+
*
52+
* Per-call wire fields (model, provider, tokens, latency, cost) win over
53+
* the feature's defaults and land at the top of the event payload — the
54+
* ingest server writes them to typed Postgres columns, NOT into metadata.
55+
*/
4356
generation(
4457
sessionId: string,
4558
opts?: {
4659
user_id?: string;
4760
prompt_version?: string;
4861
metadata?: Record<string, unknown>;
62+
model?: string;
63+
provider?: string;
64+
input_tokens?: number;
65+
output_tokens?: number;
66+
total_tokens?: number;
67+
duration_ms?: number;
68+
ttft_ms?: number;
69+
cost?: number;
4970
},
5071
): Generation {
51-
const baseMetadata: Record<string, unknown> = { feature: this.name };
52-
if (this.defaults.model) baseMetadata.model = this.defaults.model;
53-
const merged: FeatureDefaults = {
72+
return this.host.generation(sessionId, {
5473
...this.defaults,
5574
user_id: opts?.user_id ?? this.defaults.user_id,
5675
prompt_version: opts?.prompt_version ?? this.defaults.prompt_version,
57-
metadata: { ...baseMetadata, ...this.defaults.metadata, ...opts?.metadata },
58-
};
59-
return this.host.generation(sessionId, merged);
76+
model: opts?.model ?? this.defaults.model,
77+
provider: opts?.provider ?? this.defaults.provider,
78+
input_tokens: opts?.input_tokens,
79+
output_tokens: opts?.output_tokens,
80+
total_tokens: opts?.total_tokens,
81+
duration_ms: opts?.duration_ms,
82+
ttft_ms: opts?.ttft_ms,
83+
cost: opts?.cost,
84+
metadata: { feature: this.name, ...this.defaults.metadata, ...opts?.metadata },
85+
});
6086
}
6187

6288
track(event: Omit<TrackEvent, "prompt_id"> & { prompt_id?: string }) {
6389
this.host.track({
6490
...event,
6591
prompt_id: event.prompt_id ?? this.defaults.prompt_id,
6692
user_id: event.user_id ?? this.defaults.user_id,
93+
model: event.model ?? this.defaults.model,
94+
provider: event.provider ?? this.defaults.provider,
6795
metadata: { ...this.defaults.metadata, feature: this.name, ...event.metadata },
6896
});
6997
}

src/generation.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,48 @@ export class Generation {
6666
* gen.event("$rate", { value: 4, scale: "5-star" });
6767
* gen.event("$view"); // passive — won't cancel auto-abandon
6868
*/
69-
event(type: SystemEvent | (string & {}), metadata?: Record<string, unknown>) {
69+
event(
70+
type: SystemEvent | (string & {}),
71+
metadata?: Record<string, unknown> & {
72+
// Wire-level fields are typed so callers get autocomplete. At send time
73+
// we split them off into top-level fields on the event payload — they
74+
// land in real Postgres columns, not JSONB keys.
75+
model?: string;
76+
provider?: string;
77+
input_tokens?: number;
78+
output_tokens?: number;
79+
total_tokens?: number;
80+
duration_ms?: number;
81+
ttft_ms?: number;
82+
cost?: number;
83+
},
84+
) {
7085
// $view is passive observation — doesn't resolve auto-abandon.
7186
// Everything else indicates the user interacted with the output.
7287
if (type !== "$view") {
7388
this.host._resolveGeneration(this.id);
7489
}
7590

91+
// Split wire-level keys out of metadata so they serialize top-level.
92+
const { model, provider, input_tokens, output_tokens, total_tokens, duration_ms, ttft_ms, cost, ...rest } =
93+
metadata ?? {};
94+
7695
this.host.track({
7796
type,
7897
session_id: this.sessionId,
7998
user_id: this.defaults.user_id,
8099
prompt_id: this.defaults.prompt_id,
81100
prompt_version: this.defaults.prompt_version,
82101
generation_id: this.id,
83-
metadata: { ...this.defaults.metadata, ...metadata },
102+
model,
103+
provider,
104+
input_tokens,
105+
output_tokens,
106+
total_tokens,
107+
duration_ms,
108+
ttft_ms,
109+
cost,
110+
metadata: { ...this.defaults.metadata, ...rest },
84111
});
85112
}
86113

test/client.test.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LitmusClient } from "../src";
33
import type { TrackEvent } from "../src";
44

55
// Captures request bodies sent to the mock server.
6+
// Mirrors the wire shape defined in contract/openapi.yaml.
67
interface CapturedEvent {
78
id: string;
89
type: string;
@@ -11,7 +12,16 @@ interface CapturedEvent {
1112
timestamp: string;
1213
generation_id?: string;
1314
prompt_id?: string;
15+
prompt_version?: string;
1416
metadata?: Record<string, unknown>;
17+
model?: string;
18+
provider?: string;
19+
input_tokens?: number;
20+
output_tokens?: number;
21+
total_tokens?: number;
22+
duration_ms?: number;
23+
ttft_ms?: number;
24+
cost?: number;
1525
}
1626

1727
interface CapturedRequest {
@@ -849,8 +859,12 @@ describe("LitmusClient", () => {
849859
for (const e of events) {
850860
expect(e.user_id).toBe("user_99");
851861
}
852-
// model in metadata
853-
expect(events[0].metadata).toEqual(expect.objectContaining({ model: "claude-sonnet" }));
862+
// model is a wire-level field — goes top-level on the $generation event,
863+
// NOT duplicated into metadata. The ingest server writes it to the
864+
// events.model column; metadata stays for freeform props only.
865+
const genEvent = events.find((e: CapturedEvent) => e.type === "$generation")!;
866+
expect(genEvent.model).toBe("claude-sonnet");
867+
expect(genEvent.metadata).not.toHaveProperty("model");
854868

855869
client.destroy();
856870
mock.restore();
@@ -926,6 +940,128 @@ describe("LitmusClient", () => {
926940
});
927941
});
928942

943+
describe("wire-level fields on every entry point", () => {
944+
// Regression guard for the v0.5.0 bug where Feature.generation() and
945+
// Feature.track() dropped wire fields or duplicated them into metadata.
946+
// Every public entry point must (a) surface wire fields at the top of
947+
// the event JSON and (b) NOT leak them into metadata alongside.
948+
const SAMPLE = {
949+
model: "gpt-4o",
950+
provider: "openai",
951+
input_tokens: 120,
952+
output_tokens: 340,
953+
total_tokens: 460,
954+
duration_ms: 1850,
955+
ttft_ms: 240,
956+
cost: 0.0042,
957+
} as const;
958+
959+
function assertTopLevelNoLeak(event: CapturedEvent) {
960+
for (const [key, expected] of Object.entries(SAMPLE)) {
961+
expect(event[key as keyof CapturedEvent]).toBe(expected);
962+
expect(event.metadata).not.toHaveProperty(key);
963+
}
964+
}
965+
966+
it("track() serializes wire fields at top level", async () => {
967+
const mock = createMockServer([]);
968+
const client = newClient();
969+
970+
client.track({ type: "$generation", session_id: "s1", ...SAMPLE });
971+
await client.flush();
972+
973+
assertTopLevelNoLeak(mock.requests[0].events[0]);
974+
client.destroy();
975+
mock.restore();
976+
});
977+
978+
it("generation() serializes wire fields at top level", async () => {
979+
const mock = createMockServer([]);
980+
const client = newClient();
981+
982+
client.generation("s1", { prompt_id: "chat", ...SAMPLE });
983+
await client.flush();
984+
985+
const gen = mock.requests[0].events.find((e) => e.type === "$generation")!;
986+
assertTopLevelNoLeak(gen);
987+
client.destroy();
988+
mock.restore();
989+
});
990+
991+
it("gen.event() serializes wire fields at top level", async () => {
992+
// Mid-stream $switch_model needs to carry new model + token deltas.
993+
const mock = createMockServer([]);
994+
const client = newClient();
995+
996+
const gen = client.generation("s1", { prompt_id: "chat" });
997+
gen.event("$switch_model", { ...SAMPLE });
998+
await client.flush();
999+
1000+
const switchEvent = mock.requests[0].events.find((e) => e.type === "$switch_model")!;
1001+
assertTopLevelNoLeak(switchEvent);
1002+
client.destroy();
1003+
mock.restore();
1004+
});
1005+
1006+
it("feature.generation() serializes per-call wire fields at top level", async () => {
1007+
const mock = createMockServer([]);
1008+
const client = newClient();
1009+
1010+
const feat = client.feature("summarizer");
1011+
feat.generation("s1", { ...SAMPLE });
1012+
await client.flush();
1013+
1014+
const gen = mock.requests[0].events.find((e) => e.type === "$generation")!;
1015+
assertTopLevelNoLeak(gen);
1016+
client.destroy();
1017+
mock.restore();
1018+
});
1019+
1020+
it("feature.track() serializes wire fields at top level", async () => {
1021+
const mock = createMockServer([]);
1022+
const client = newClient();
1023+
1024+
const feat = client.feature("summarizer");
1025+
feat.track({ type: "$generation", session_id: "s1", ...SAMPLE });
1026+
await client.flush();
1027+
1028+
assertTopLevelNoLeak(mock.requests[0].events[0]);
1029+
client.destroy();
1030+
mock.restore();
1031+
});
1032+
1033+
it("feature defaults for model/provider go top-level, never into metadata", async () => {
1034+
const mock = createMockServer([]);
1035+
const client = newClient();
1036+
1037+
const feat = client.feature("content_gen", { model: "gpt-4o", provider: "openai" });
1038+
feat.generation("s1");
1039+
await client.flush();
1040+
1041+
const gen = mock.requests[0].events.find((e) => e.type === "$generation")!;
1042+
expect(gen.model).toBe("gpt-4o");
1043+
expect(gen.provider).toBe("openai");
1044+
expect(gen.metadata).not.toHaveProperty("model");
1045+
expect(gen.metadata).not.toHaveProperty("provider");
1046+
client.destroy();
1047+
mock.restore();
1048+
});
1049+
1050+
it("per-call model overrides feature default", async () => {
1051+
const mock = createMockServer([]);
1052+
const client = newClient();
1053+
1054+
const feat = client.feature("chat", { model: "gpt-4o-mini" });
1055+
feat.generation("s1", { model: "gpt-4o" });
1056+
await client.flush();
1057+
1058+
const gen = mock.requests[0].events.find((e) => e.type === "$generation")!;
1059+
expect(gen.model).toBe("gpt-4o");
1060+
client.destroy();
1061+
mock.restore();
1062+
});
1063+
});
1064+
9291065
describe("attach()", () => {
9301066
it("returns a Generation handle without emitting $generation", async () => {
9311067
const mock = createMockServer([]);

tests/helpers.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
1717

18-
/** Shape of a single event as it arrives at the server. */
18+
/** Shape of a single event as it arrives at the server. Mirrors contract/openapi.yaml. */
1919
export interface CapturedEvent {
2020
type: string;
2121
session_id: string;
@@ -26,6 +26,15 @@ export interface CapturedEvent {
2626
metadata?: Record<string, unknown>;
2727
id: string;
2828
timestamp: string;
29+
// Wire-level fields that the ingest server writes to typed Postgres columns.
30+
model?: string;
31+
provider?: string;
32+
input_tokens?: number;
33+
output_tokens?: number;
34+
total_tokens?: number;
35+
duration_ms?: number;
36+
ttft_ms?: number;
37+
cost?: number;
2938
}
3039

3140
/** One POST body received by the server. */

0 commit comments

Comments
 (0)