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
21 changes: 21 additions & 0 deletions packages/sdk-node/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Jayces

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
52 changes: 52 additions & 0 deletions packages/sdk-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @heightproof/sdk-node

Node.js client for a self-hosted [Heightproof](https://github.com/macjayz/heightproof) instance:
batched, non-blocking event delivery, plus Express middleware that records REST and GraphQL
requests so they can be correlated with RPC incidents.

For EVM JSON-RPC monitoring, use [`@heightproof/web3`](https://www.npmjs.com/package/@heightproof/web3),
which builds on this package.

```bash
npm install @heightproof/sdk-node
```

## Express

```ts
import { heightproofErrorHandler, heightproofExpress } from "@heightproof/sdk-node";

app.use(
heightproofExpress({
projectId: "my-project",
apiKey: process.env.HEIGHTPROOF_API_KEY!,
endpoint: "https://heightproof.internal",
serviceName: "payments-api"
})
);

// ...routes...

app.use(heightproofErrorHandler()); // captures thrown errors for error grouping
```

Sensitive headers and body fields (authorization, cookies, passwords, private keys, mnemonics)
are redacted before anything leaves the process. Add your own with the `redaction` option.

## Delivery options

| Option | Default | |
|---|---|---|
| `flushIntervalMs` | `5000` | How often queued events are sent |
| `maxBatchSize` | `25` | Events per request; reaching it also triggers a send |
| `maxQueueSize` | `5000` | Events held while Heightproof is unreachable; oldest dropped beyond this |
| `requestTimeoutMs` | `5000` | Abort an ingestion request after this long |
| `onDrop` | — | `(count, reason) => void`, called when events are discarded (`queue_full` or `rejected`) |

Network failures, timeouts, `408`, `429` and `5xx` responses are retried on the next flush. Other
`4xx` responses are dropped, since they would be rejected identically every time.
`HeightproofClient#stats()` reports how many events are queued and how many have been dropped.

## License

MIT
40 changes: 39 additions & 1 deletion packages/sdk-node/package.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,57 @@
{
"name": "@heightproof/sdk-node",
"version": "0.1.0",
"description": "Heightproof SDK for Node.js: batched, non-blocking event delivery and Express middleware.",
"keywords": [
"heightproof",
"express",
"observability",
"apm"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/macjayz/heightproof.git",
"directory": "packages/sdk-node"
},
"homepage": "https://github.com/macjayz/heightproof#readme",
"bugs": "https://github.com/macjayz/heightproof/issues",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run --passWithNoTests"
"test": "vitest run --passWithNoTests",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@heightproof/shared": "0.1.0"
},
"peerDependencies": {
"express": ">=4"
},
"peerDependenciesMeta": {
"express": {
"optional": true
}
},
"devDependencies": {
"@types/express": "^4.17.21",
"express": "^5.2.1"
Expand Down
110 changes: 109 additions & 1 deletion packages/sdk-node/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import type { Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HeightproofClient, heightproofErrorHandler, heightproofExpress } from "./index.js";

function createFakeRequest(overrides: Partial<Request> = {}): Request {
Expand Down Expand Up @@ -98,3 +98,111 @@ describe("heightproofExpress error capture", () => {
vi.restoreAllMocks();
});
});

describe("HeightproofClient delivery", () => {
const options = {
projectId: "demo",
apiKey: "dev-heightproof-key",
endpoint: "http://localhost:8080",
serviceName: "example-api",
flushIntervalMs: 60_000,
maxBatchSize: 2
};

function event(id: string) {
return {
id,
projectId: "demo",
serviceName: "example-api",
environment: "test",
timestamp: "2026-09-01T20:12:00.000Z",
kind: "rest" as const,
request: {
method: "GET" as const,
path: "/",
headers: {},
query: {},
auth: { present: false, failed: false }
},
response: { statusCode: 200, latencyMs: 1 }
};
}

function respondWith(...statuses: Array<number | Error>) {
const fetchMock = vi.fn();
for (const status of statuses) {
if (status instanceof Error) fetchMock.mockRejectedValueOnce(status);
else fetchMock.mockResolvedValueOnce({ ok: status < 300, status });
}
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

afterEach(() => {
vi.unstubAllGlobals();
});

it("caps memory while Heightproof is unreachable by dropping the oldest events", () => {
const drops: Array<[number, string]> = [];
const client = new HeightproofClient({
...options,
maxBatchSize: 100,
maxQueueSize: 3,
onDrop: (count, reason) => drops.push([count, reason])
});

for (const id of ["e1", "e2", "e3", "e4", "e5"]) client.capture(event(id));

expect(client.stats()).toEqual({ queued: 3, dropped: 2 });
expect(drops).toEqual([
[1, "queue_full"],
[1, "queue_full"]
]);
client.stop();
});

it("keeps a batch for retry when the network fails or the server is overloaded", async () => {
respondWith(new TypeError("fetch failed"), 503, 429);
const client = new HeightproofClient(options);
client.capture(event("e1"));

await client.flush();
await client.flush();
await client.flush();

expect(client.stats()).toEqual({ queued: 1, dropped: 0 });
client.stop();
});

it("drops a batch the server rejects instead of retrying it forever", async () => {
const fetchMock = respondWith(401, 202);
const drops: Array<[number, string]> = [];
const client = new HeightproofClient({
...options,
maxBatchSize: 1,
onDrop: (c, r) => drops.push([c, r])
});
client.capture(event("rejected"));
client.capture(event("next"));

await client.flush();
await client.flush();

expect(drops).toEqual([[1, "rejected"]]);
expect(client.stats()).toEqual({ queued: 0, dropped: 1 });
expect(JSON.parse(fetchMock.mock.calls[1][1].body).events[0].id).toBe("next");
client.stop();
});

it("aborts an ingestion request that hangs", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202 });
vi.stubGlobal("fetch", fetchMock);
const client = new HeightproofClient({ ...options, requestTimeoutMs: 1234 });
client.capture(event("e1"));

await client.flush();

expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
client.stop();
});
});
61 changes: 57 additions & 4 deletions packages/sdk-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,22 @@ export type HeightproofMiddlewareOptions = {
redaction?: RedactionConfig;
flushIntervalMs?: number;
maxBatchSize?: number;
/**
* Most events held in memory while Heightproof is unreachable. Beyond this the oldest are
* dropped, so an outage costs telemetry, never the host application's memory. Default 5000.
*/
maxQueueSize?: number;
/** Abort an ingestion request after this long. Default 5000ms. */
requestTimeoutMs?: number;
/** Called when events are discarded, with how many and why. */
onDrop?: (count: number, reason: DropReason) => void;
};

export type DropReason = "queue_full" | "rejected";

const DEFAULT_MAX_QUEUE_SIZE = 5000;
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;

type RequestWithCapturedError = Request & { heightproofError?: unknown };

export function heightproofErrorHandler() {
Expand Down Expand Up @@ -124,6 +138,7 @@ function getTraceId(req: Request) {
export class HeightproofClient {
private queue: HeightproofEvent[] = [];
private timer?: NodeJS.Timeout;
private dropped = 0;

constructor(private readonly options: HeightproofMiddlewareOptions) {
this.timer = setInterval(() => void this.flush(), options.flushIntervalMs ?? 5000);
Expand All @@ -132,11 +147,17 @@ export class HeightproofClient {

capture(event: HeightproofEvent): void {
this.queue.push(event);
this.enforceQueueLimit();
if (this.queue.length >= (this.options.maxBatchSize ?? 25)) {
void this.flush();
}
}

/** Events waiting to be sent, and events discarded since the client started. */
stats(): { queued: number; dropped: number } {
return { queued: this.queue.length, dropped: this.dropped };
}

async flush(): Promise<void> {
if (this.queue.length === 0) return;
const events = this.queue.splice(0, this.options.maxBatchSize ?? 25);
Expand All @@ -149,14 +170,42 @@ export class HeightproofClient {
"content-type": "application/json",
"x-heightproof-api-key": this.options.apiKey
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS)
});

if (!response.ok) {
this.queue.unshift(...events);
if (response.ok) return;
if (isRetryableStatus(response.status)) {
this.requeue(events);
} else {
// A 400, 401, 403 or 413 will be rejected identically on every retry. Re-queueing it
// would put it back at the head of the queue and block every later batch for good.
this.drop(events.length, "rejected");
}
} catch {
this.queue.unshift(...events);
// Network failure or timeout: Heightproof may come back, so keep the batch.
this.requeue(events);
}
}

private requeue(events: HeightproofEvent[]) {
this.queue.unshift(...events);
this.enforceQueueLimit();
}

private enforceQueueLimit() {
const overflow = this.queue.length - (this.options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE);
if (overflow <= 0) return;
this.queue.splice(0, overflow);
this.drop(overflow, "queue_full");
}

private drop(count: number, reason: DropReason) {
this.dropped += count;
try {
this.options.onDrop?.(count, reason);
} catch {
// A throwing callback must not break capture in the host application.
}
}

Expand All @@ -165,6 +214,10 @@ export class HeightproofClient {
}
}

function isRetryableStatus(status: number) {
return status === 408 || status === 429 || status >= 500;
}

function normalizeHeaders(headers: Request["headers"]): Record<string, string> {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Jayces

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
14 changes: 14 additions & 0 deletions packages/shared/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# @heightproof/shared

Internal building blocks shared by the [Heightproof](https://github.com/macjayz/heightproof)
SDKs and services: the `HeightproofEvent` schema (Zod), JSON-RPC result normalization and
hashing, redaction helpers, and the content-aware RPC reliability detectors.

You normally do not install this directly; it is a dependency of
[`@heightproof/web3`](https://www.npmjs.com/package/@heightproof/web3) and
[`@heightproof/sdk-node`](https://www.npmjs.com/package/@heightproof/sdk-node). Its API follows
those packages and may change between minor versions while Heightproof is pre-1.0.

## License

MIT
Loading
Loading