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
981 changes: 981 additions & 0 deletions docs/superpowers/plans/2026-05-14-wire-format-cleanup.md

Large diffs are not rendered by default.

10 changes: 3 additions & 7 deletions src/core/aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { MetricEntry, RawEvent, WindowSummary } from "./types.js";
import { isoNow } from "./time.js";

/**
* Maximum unique (provider, endpoint, method) triplets per window.
Expand Down Expand Up @@ -48,8 +49,6 @@ function computePercentile(sortedValues: number[], p: number): number {

/** Configuration passed to the Aggregator constructor. */
export interface AggregatorConfig {
/** Attached to every WindowSummary. Defaults to "". */
projectId?: string;
/** Attached to every WindowSummary. Defaults to "development". */
environment?: string;
/** SDK package version string. Defaults to "0.0.0". */
Expand All @@ -66,7 +65,6 @@ export interface AggregatorConfig {
* All sorting and percentile computation is deferred to flush().
*/
export class Aggregator {
private readonly _projectId: string;
private readonly _environment: string;
private readonly _sdkVersion: string;
private readonly _maxBuckets: number;
Expand All @@ -76,7 +74,6 @@ export class Aggregator {
private _size = 0;

constructor(config: AggregatorConfig = {}) {
this._projectId = config.projectId ?? "";
this._environment = config.environment ?? "development";
this._sdkVersion = config.sdkVersion ?? "0.0.0";
this._maxBuckets = config.maxBuckets ?? MAX_BUCKETS;
Expand Down Expand Up @@ -151,8 +148,8 @@ export class Aggregator {
flush(): WindowSummary | null {
if (this._buckets.size === 0) return null;

const windowStart = this._windowStart ?? new Date().toISOString();
const windowEnd = new Date().toISOString();
const windowStart = this._windowStart ?? isoNow();
const windowEnd = isoNow();

const metrics: MetricEntry[] = [];

Expand Down Expand Up @@ -181,7 +178,6 @@ export class Aggregator {
this._size = 0;

return {
projectId: this._projectId,
environment: this._environment,
sdkLanguage: "node",
sdkVersion: this._sdkVersion,
Expand Down
3 changes: 2 additions & 1 deletion src/core/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import http from "node:http";
import https from "node:https";
import { performance } from "node:perf_hooks";
import type { RawEvent } from "./types.js";
import { isoNow } from "./time.js";

// ---------------------------------------------------------------------------
// Public types
Expand Down Expand Up @@ -118,7 +119,7 @@ function buildEvent(
responseBytes: number,
): RawEvent {
return {
timestamp: new Date().toISOString(),
timestamp: isoNow(),
method: method.toUpperCase(),
url: parsed.url,
host: parsed.host,
Expand Down
17 changes: 17 additions & 0 deletions src/core/time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Wire-format timestamp helper.
*
* The cross-SDK contract for `WindowSummary.windowStart` and `WindowSummary.windowEnd`
* is **ISO 8601, millisecond precision, UTC `Z` suffix** — for example
* `2026-05-14T12:00:00.000Z`. Asserted in `tests/contract.test.ts`.
*
* Do not change this format without updating both:
* - `tests/contract.test.ts` (Node), and
* - the matching `test_contract.py` in `recost-dev/middleware-python`.
*
* `Date.prototype.toISOString()` already produces this exact format, so this
* helper is a thin wrapper. Its job is to keep the rule documented in one place.
*/
export function isoNow(): string {
return new Date().toISOString();
}
14 changes: 10 additions & 4 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,23 @@ export interface MetricEntry {

/** What the aggregator produces on flush. Sent to the cloud API or local extension. */
export interface WindowSummary {
/** ReCost project ID from config. */
projectId: string;
/** Environment tag (e.g. "development", "production") from config. */
environment: string;
/** Always "node" for this SDK. */
sdkLanguage: string;
/** Package version from package.json. */
sdkVersion: string;
/** ISO 8601 timestamp of the first event in this window. */
/**
* ISO 8601 timestamp of the first event in this window.
* Wire-format contract: millisecond precision, UTC `Z` suffix
* (e.g. `2026-05-14T12:00:00.000Z`). See `src/core/time.ts`.
*/
windowStart: string;
/** ISO 8601 timestamp of when the flush occurred. */
/**
* ISO 8601 timestamp of when the flush occurred.
* Wire-format contract: millisecond precision, UTC `Z` suffix
* (e.g. `2026-05-14T12:00:30.000Z`). See `src/core/time.ts`.
*/
windowEnd: string;
/** One entry per unique provider + endpoint + method observed during the window. */
metrics: MetricEntry[];
Expand Down
1 change: 0 additions & 1 deletion src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export function init(config: RecostConfig = {}): RecostHandle {
const maxBuckets = config.maxBuckets ?? MAX_BUCKETS;
const registry = new ProviderRegistry(config.customProviders);
const aggregator = new Aggregator({
...(config.projectId !== undefined && { projectId: config.projectId }),
...(config.environment !== undefined && { environment: config.environment }),
sdkVersion: "0.1.0",
maxBuckets,
Expand Down
7 changes: 2 additions & 5 deletions tests/aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("Aggregator — basic flush behavior", () => {
});

it("flush returns a WindowSummary after one event", () => {
const agg = new Aggregator({ projectId: "p1", environment: "test" });
const agg = new Aggregator({ environment: "test" });
agg.ingest(makeEvent({ latencyMs: 250, requestBytes: 512, responseBytes: 1024 }), 2.0);
const summary = agg.flush()!;

Expand Down Expand Up @@ -241,23 +241,20 @@ describe("Aggregator — window timestamps", () => {
describe("Aggregator — metadata", () => {
it("WindowSummary includes constructor config values", () => {
const agg = new Aggregator({
projectId: "proj_123",
environment: "production",
sdkVersion: "1.2.3",
});
agg.ingest(makeEvent());
const summary = agg.flush()!;
expect(summary.projectId).toBe("proj_123");
expect(summary.environment).toBe("production");
expect(summary.sdkVersion).toBe("1.2.3");
expect(summary.sdkLanguage).toBe("node");
});

it("defaults: projectId empty, environment 'development', sdkVersion '0.0.0'", () => {
it("defaults: environment 'development', sdkVersion '0.0.0'", () => {
const agg = new Aggregator();
agg.ingest(makeEvent());
const summary = agg.flush()!;
expect(summary.projectId).toBe("");
expect(summary.environment).toBe("development");
expect(summary.sdkVersion).toBe("0.0.0");
});
Expand Down
21 changes: 19 additions & 2 deletions tests/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import type { RawEvent, WindowSummary } from "../src/core/types.js";
// ---------------------------------------------------------------------------

const EXPECTED_TOP_LEVEL_KEYS = [
"projectId",
"environment",
"sdkLanguage",
"sdkVersion",
Expand Down Expand Up @@ -78,7 +77,6 @@ function buildFlushPayload(): WindowSummary {
// Build the payload the same way init.ts does: Aggregator.flush() →
// WindowSummary → JSON.stringify on the wire.
const aggregator = new Aggregator({
projectId: "proj-contract",
environment: "test",
sdkVersion: "0.1.0",
});
Expand Down Expand Up @@ -113,6 +111,25 @@ describe("contract — WindowSummary top-level", () => {
expect(Number.isFinite(Date.parse(summary.windowEnd))).toBe(true);
});

it("windowStart and windowEnd match the locked wire format (ms precision, UTC Z)", () => {
const summary = buildFlushPayload();
// The cross-SDK wire-format contract: ISO 8601, millisecond precision, UTC "Z".
// Mirrors the assertion in middleware-python/tests/test_contract.py.
const ISO_MS_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
expect(summary.windowStart).toMatch(ISO_MS_Z);
expect(summary.windowEnd).toMatch(ISO_MS_Z);
});

it("does not include projectId in the body — URL path is the source of truth", () => {
const summary = buildFlushPayload();
const onWire = JSON.stringify(summary);
// Belt-and-suspenders: even if the WindowSummary type ever drifted to
// include projectId again, the wire payload itself must not carry it.
// The API extracts projectId from the URL path; the body field would be
// dead weight at best and a silent mismatch source at worst.
expect(onWire).not.toContain("projectId");
});

it("identifies itself as the node SDK", () => {
const summary = buildFlushPayload();
expect(summary.sdkLanguage).toBe("node");
Expand Down
3 changes: 1 addition & 2 deletions tests/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ describe("init — config forwarding", () => {
uninstall();
});

it("forwards projectId and environment to the WindowSummary", async () => {
it("forwards environment to the WindowSummary", async () => {
const ws = await startWsCollector();
const httpServer = await startHttpServer();

Expand All @@ -626,7 +626,6 @@ describe("init — config forwarding", () => {

expect(ws.summaries.length).toBeGreaterThan(0);
const summary = ws.summaries[0]!;
expect(summary.projectId).toBe("my-project");
expect(summary.environment).toBe("staging");
expect(summary.sdkLanguage).toBe("node");
});
Expand Down
27 changes: 13 additions & 14 deletions tests/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ afterEach(() => {

function makeSummary(overrides: Partial<WindowSummary> = {}): WindowSummary {
return {
projectId: "proj-1",
environment: "test",
sdkLanguage: "node",
sdkVersion: "0.1.0",
Expand Down Expand Up @@ -176,7 +175,7 @@ describe("Transport cloud mode", () => {
expect(req.method).toBe("POST");
expect(req.auth).toBe("Bearer test-key");
const parsed = JSON.parse(req.body) as WindowSummary;
expect(parsed.projectId).toBe("proj-1");
expect(parsed.environment).toBe("test");
});

it("sends POST to the correct /projects/{projectId}/telemetry URL", async () => {
Expand Down Expand Up @@ -304,15 +303,15 @@ describe("Transport local mode", () => {
}, 20);
});

await t.send(makeSummary({ projectId: "ws-test" }));
await t.send(makeSummary({ environment: "ws-test" }));
await new Promise((r) => setTimeout(r, 50));

t.dispose();
await ws.close();

expect(ws.messages).toHaveLength(1);
const received = JSON.parse(ws.messages[0]!) as WindowSummary;
expect(received.projectId).toBe("ws-test");
expect(received.environment).toBe("ws-test");
});

it("queues messages when WebSocket is not connected yet, drains on open", async () => {
Expand All @@ -324,8 +323,8 @@ describe("Transport local mode", () => {
const t = new Transport({ localPort: port });

// Send while no server is listening — should queue
await t.send(makeSummary({ projectId: "queued-1" }));
await t.send(makeSummary({ projectId: "queued-2" }));
await t.send(makeSummary({ environment: "queued-1" }));
await t.send(makeSummary({ environment: "queued-2" }));

// Now start a WS server on the same port
const ws2 = await startFakeWsServer();
Expand All @@ -336,7 +335,7 @@ describe("Transport local mode", () => {
// Create new transport targeting ws2
const t2 = new Transport({ localPort: ws2.port });

await t2.send(makeSummary({ projectId: "direct" }));
await t2.send(makeSummary({ environment: "direct" }));

// Wait for connection and delivery
await new Promise((r) => setTimeout(r, 100));
Expand All @@ -346,7 +345,7 @@ describe("Transport local mode", () => {

expect(ws2.messages.length).toBeGreaterThanOrEqual(1);
const parsed = JSON.parse(ws2.messages[0]!) as WindowSummary;
expect(parsed.projectId).toBe("direct");
expect(parsed.environment).toBe("direct");
});

it("dispose can be called multiple times without error", () => {
Expand Down Expand Up @@ -528,7 +527,7 @@ describe("Transport — WebSocket queue cap", () => {
const t = new Transport({ localPort: 39901, maxWsQueueSize: 5 });

for (let i = 0; i < 100; i++) {
await t.send(makeSummary({ projectId: `p-${i}` }));
await t.send(makeSummary({ environment: `p-${i}` }));
}

expect(t._queueSize()).toBe(5);
Expand All @@ -539,7 +538,7 @@ describe("Transport — WebSocket queue cap", () => {
const t = new Transport({ localPort: 39902, maxWsQueueSize: 5 });

for (let i = 1; i <= 7; i++) {
await t.send(makeSummary({ projectId: `p-${i}` }));
await t.send(makeSummary({ environment: `p-${i}` }));
}

expect(t._queueSize()).toBe(5);
Expand All @@ -557,7 +556,7 @@ describe("Transport — WebSocket queue cap", () => {
t.dispose();
await ws.close();

const ids = ws.messages.map((m) => (JSON.parse(m) as WindowSummary).projectId);
const ids = ws.messages.map((m) => (JSON.parse(m) as WindowSummary).environment);
expect(ids).toEqual(["p-3", "p-4", "p-5", "p-6", "p-7"]);
}, 15_000);

Expand All @@ -570,7 +569,7 @@ describe("Transport — WebSocket queue cap", () => {
});

for (let i = 0; i < 100; i++) {
await t.send(makeSummary({ projectId: `e1-${i}` }));
await t.send(makeSummary({ environment: `e1-${i}` }));
}
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toContain("WebSocket queue overflowed");
Expand All @@ -589,7 +588,7 @@ describe("Transport — WebSocket queue cap", () => {

await new Promise<void>((resolve) => {
const iv = setInterval(() => {
void t.send(makeSummary({ projectId: "probe" })).then(() => {
void t.send(makeSummary({ environment: "probe" })).then(() => {
if (t._queueSize() >= 1) {
clearInterval(iv);
resolve();
Expand All @@ -600,7 +599,7 @@ describe("Transport — WebSocket queue cap", () => {

const before = errors.length;
for (let i = 0; i < 100; i++) {
await t.send(makeSummary({ projectId: `e2-${i}` }));
await t.send(makeSummary({ environment: `e2-${i}` }));
}
expect(errors.length).toBe(before + 1);
expect(errors[errors.length - 1]!.message).toContain("WebSocket queue overflowed");
Expand Down