Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); GitHub - recost-dev/middleware-node · GitHub
Skip to content

Repository files navigation

@recost-dev/node

Node.js SDK for ReCost — automatically tracks outbound HTTP API calls from your application and reports cost, latency, and usage patterns to the ReCost dashboard or your local VS Code extension.

How it works

The SDK monkey-patches fetch, http.request, and https.request to intercept outbound requests at runtime. It captures metadata only (URL, method, status, latency, byte sizes — never headers or bodies), matches each request against a built-in provider registry, aggregates events into time-windowed summaries, and ships those summaries either to the ReCost cloud API or to the ReCost VS Code extension running locally.

Your app
└─ fetch("https://api.openai.com/v1/chat/completions", ...)
│
▼
Interceptor ← patches globalThis.fetch, http.request, https.request
│ RawEvent { host, path, method, statusCode, latencyMs, ... }
▼
ProviderRegistry ← matches host/path → provider + endpointCategory + cost
│
▼
Aggregator ← buffers events, flushes WindowSummary every 30s
│
▼
Transport
├─ local mode → File (JSONL @ ~/.recost/local-telemetry by default)
│ or WebSocket (set localTransport: "ws", port 9847)
└─ cloud mode → HTTPS POST → api.recost.dev

Installation

npm install @recost-dev/node

Quick start

Local mode

No API key needed. Telemetry is written to a local NDJSON file by default (see Local mode for details and the optional WebSocket sub-mode).

import{init}from"@recost-dev/node";init();// all defaults — local mode writes to ~/.recost/local-telemetry/default.jsonl// (file name is `${projectId}.jsonl`; defaults to "default" when projectId is unset)

Cloud mode

import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,environment: process.env.NODE_ENV??"development",});

Express

importexpressfrom"express";import{createExpressMiddleware}from"@recost-dev/node";constapp=express();app.use(createExpressMiddleware({apiKey: process.env.RECOST_API_KEY}));

Fastify

importFastifyfrom"fastify";import{createFastifyPlugin}from"@recost-dev/node";constapp=Fastify();awaitapp.register(createFastifyPlugin,{apiKey: process.env.RECOST_API_KEY});

Configuration

All fields are optional.

OptionTypeDefaultDescription
apiKeystringReCost API key (rc-...). If omitted, runs in local mode.
projectIdstringReCost project ID. Required in cloud mode.
environmentstring"development"Environment tag attached to all telemetry.
flushIntervalMsnumber30000Milliseconds between automatic flushes.
maxBatchSizenumber100Early-flush threshold (number of events).
maxBucketsnumber2000Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush so the cloud API does not reject the payload with a 422.
localTransport"file" | "ws""file"Local-mode sub-mode. "file" writes NDJSON to disk (default); "ws" opens a WebSocket to a localhost consumer.
localDirstring~/.recost/local-telemetryFile sub-mode only — directory for ${projectId}.jsonl. Also honors RECOST_LOCAL_DIR.
maxFileBytesnumber10000000File sub-mode only — rotate to .jsonl.1 once the active file exceeds this many bytes. Disk usage bounded at ~2×.
maxLocalFileQueueSizenumber1000File sub-mode only — in-memory frames buffered when disk writes fail. FIFO overflow fires onError once per episode.
localPortnumber9847WS sub-mode only — WebSocket port for the local consumer (e.g. VS Code extension).
debugbooleanfalseLog telemetry activity to stdout.
enabledbooleantrueMaster kill switch. Set false to disable in tests.
customProvidersProviderDef[][]Extra provider rules merged with built-ins; sorted by specificity (longer pathPrefix wins; on tie, custom beats built-in).
excludePatternsstring[][]URL prefixes or exact hostnames. Matches when event.url.startsWith(pattern) OR event.host === pattern. No substring matching.
baseUrlstring"https://api.recost.dev"Override for self-hosted deployments.
maxRetriesnumber3Retry attempts for failed cloud flushes.
maxWsQueueSizenumber1000Local mode only — maximum serialized WindowSummary payloads buffered while the VS Code extension is unreachable. When full, the oldest payload is dropped (FIFO) and onError fires exactly once per overflow episode. The flag resets when the queue drains to empty (extension reconnects).
maxConsecutiveAuthFailuresnumber5Consecutive 401 responses after which the cloud transport is suspended for the lifetime of the process. See "Auth failures" below.
maxConsecutiveReconnectFailuresnumber20Consecutive failed WebSocket reconnect attempts after which the local transport is paused for the lifetime of the process. See "Local-mode unavailability" below.
shutdownFlushTimeoutMsnumber3000Milliseconds dispose() waits for the final shutdown flush to complete before closing the transport.
onError(err: Error) => voidCalled on internal SDK errors.

Validation

init() validates the config synchronously and throws if it would put the SDK in a known-broken state:

Cloud-mode rules (only checked when apiKey is set):

  • apiKey must be a string starting with rc-. The literal string "undefined" (a common env-var misread) is rejected.
  • projectId is required and must be non-empty.

Rules checked in both modes (file transport applies even without apiKey):

  • localTransport, if set, must be "ws" or "file".
  • localDir, if set, must be a non-empty string.
  • maxFileBytes, if set, must be a positive integer ≥ 1024.
  • maxLocalFileQueueSize, if set, must be a positive integer.
  • excludePatterns entries must be non-empty strings (an empty entry would silently exclude every event).

Wrap init() in a try/catch if a misconfigured environment should not crash your host process.

Auth failures

If api.recost.dev returns 401 (invalid or revoked apiKey), the SDK:

  1. Logs a one-time warning to stderr on the first 401: [recost] HTTP 401 — API key rejected. Telemetry will stop after 5 consecutive failures. Check your apiKey at https://recost.dev/dashboard/account.
  2. Calls onError(new RecostAuthError(401, n)) on every 401, where n is the consecutive-failure count.
  3. After maxConsecutiveAuthFailures (default 5) consecutive 401s, suspends the cloud transport for the lifetime of the process, logs a second stderr line announcing the suspension, and calls onError(new RecostFatalAuthError(401, n)). Subsequent init/send calls in this process are silent no-ops on the cloud transport.

Recovery is restart-only: update apiKey in your config and restart the process. The counter resets on any non-401 outcome (success, 403/404/422, 5xx after retries, network error), so transient outages do not accumulate toward the threshold.

Hosts can route auth failures separately from other errors by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elselog.debug(err);},});

Local mode

When apiKey is absent the SDK runs in local mode. By default it writes NDJSON WindowSummary lines to ~/.recost/local-telemetry/${projectId}.jsonl (override the directory with localDir or the RECOST_LOCAL_DIR env var). Each line carries protocolVersion: "1.0". File mode is 0o600 on POSIX.

To opt back into the WebSocket transport (e.g. you run a custom local consumer):

init({projectId: "my-proj",localTransport: "ws",localPort: 9847});

excludePatterns

Pass URL prefixes or exact hostnames. A pattern matches when event.url.startsWith(pattern) OR event.host === pattern. Example:

init({excludePatterns: ["https://api.example.com/v1/internal",// URL prefix"api.metrics.local",// exact host],});

Local-mode unavailability

WS sub-mode (localTransport: "ws"): the SDK reconnects on disconnect with exponential backoff (500 ms → 30 s, ±25 % jitter). If the consumer (e.g. VS Code extension) is never running, the SDK would otherwise spin forever. Instead:

  1. After maxConsecutiveReconnectFailures (default 20) consecutive failed reconnect attempts, the SDK pauses the local transport for the lifetime of the process.
  2. Logs a one-time warning to stderr: [recost] local WebSocket unreachable after 20 consecutive reconnect attempts. Restart the process after starting the VS Code extension.
  3. Calls onError(new RecostLocalUnreachableError(n)) exactly once, where n is the consecutive-failure count at the trip.
  4. Subsequent send() calls are silent no-ops on the local transport.

Recovery is restart-only: start the VS Code extension, then restart your host process. The counter resets on any successful WebSocket connect, so transient extension restarts do not accumulate toward the threshold.

Hosts can route local-unreachable separately from auth failures by narrowing on the error class:

import{init,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY,projectId: process.env.RECOST_PROJECT_ID,onError(err){if(errinstanceofRecostLocalUnreachableError)log.warn("recost: local extension unreachable; check VS Code");elseif(errinstanceofRecostFatalAuthError)pagerduty.fire(err);elseif(errinstanceofRecostAuthError)log.warn(err);elseif(errinstanceofRecostInterceptorAlreadyInstalledError)log.warn("recost: duplicate package load — first install is active");elseif(errinstanceofRecostInterceptorPatchOverwrittenError)log.warn(`recost: third-party wrapper detected on ${err.skippedBindings.join(", ")} — restart to recover`);},});

These two interceptor errors are advisory: the SDK either keeps the first install active (AlreadyInstalled) or detaches the callback and refuses re-install (PatchOverwritten). Recovery from PatchOverwritten requires a process restart with the conflicting library either removed or installed before recost.

Custom providers

init({customProviders: [{hostPattern: "api.internal.acme.com",pathPrefix: "/payments",provider: "acme-payments",endpointCategory: "charge",costPerRequestCents: 0.5,},],});

Custom provider priority

Custom and built-in rules are merged and sorted by specificity at ProviderRegistry construction time. The sort is:

  1. Rules with a pathPrefix come before rules without.
  2. Longer pathPrefix wins (more specific).
  3. Exact host beats *. wildcard host.
  4. On equal specificity, custom rules win.

So a custom catch-all ({ hostPattern: "api.openai.com", provider: "openai-mock" } with no pathPrefix) does NOT shadow built-in path-specific OpenAI rules — those are more specific. A custom rule with pathPrefix: "/v1/chat/completions" on the same host DOES override the built-in (equal specificity → custom wins).

Cleanup / teardown

init() returns a handle with two lifecycle methods:

  • dispose(): Promise<void> — stop intercepting, perform one final shutdown flush (bounded by shutdownFlushTimeoutMs), close the transport. Calling this twice is a no-op.
  • flush(): Promise<void> — flush the current aggregator window without disposing. Useful before a known process-exit boundary where dispose() doesn't fit your shutdown ordering. After dispose() has run, flush() resolves immediately.

Both methods route flush errors through your configured onError; they never reject.

constrecost=init({});// Manual checkpoint flush before a non-graceful exit:awaitrecost.flush();// Graceful shutdown:awaitrecost.dispose();

Cross-SDK parity. The Python SDK's dispose() is synchronous (returns immediately after spawning a flush thread); its flush_blocking(timeout_s=…) blocks the calling thread until the flush completes. Node's await handle.dispose() already provides blocking semantics, and await handle.flush() is the direct parallel of Python's flush_blocking(). There is no thread-blocking primitive in JavaScript, so the awaited promise is the only honest analogue.

Worker threads

init() patches fetch, http, and https for the worker that calls it. Workers spawned via node:worker_threads get their own module instances and their own globalThis, so they will not be instrumented until you call init() inside the worker's own entry point. SDK errors thrown in a worker route through that worker's own onError.

// In worker.ts (the worker entry point)import{init}from"@recost-dev/node";init({apiKey: process.env.RECOST_API_KEY});// …rest of worker logic…

The main thread's init() does not propagate to workers, and the SDK does not detect or warn about worker spawns — instrumenting workers is the host's responsibility.

Disabling in tests

init({enabled: process.env.NODE_ENV!=="test"});

Supported providers

The registry ships with built-in rules for these providers. Cost estimates are rough per-request averages for relative comparison — actual costs vary by model, token count, and region.

ProviderHostTracked endpointsCost estimate
OpenAIapi.openai.comchat completions, embeddings, image generation, audio transcription, TTS0.01–4.0¢/req
Anthropicapi.anthropic.commessages1.5¢/req
Stripeapi.stripe.comcharges, payment intents, customers, subscriptions0¢ (% billing)
Twilioapi.twilio.comSMS, voice calls0.79–1.3¢/req
SendGridapi.sendgrid.commail send0.1¢/req
Pinecone*.pinecone.iovector upsert, query0.08¢/req
AWS*.amazonaws.comall services (wildcard)0¢ (complex pricing)
Google Cloud*.googleapis.comall services (wildcard)0¢ (complex pricing)

Unrecognized hosts produce a RawEvent with provider: null — they still appear in telemetry grouped under "unknown".

Using the registry directly

import{ProviderRegistry,BUILTIN_PROVIDERS}from"@recost-dev/node";// Default registry (built-ins only)constregistry=newProviderRegistry();constresult=registry.match("https://api.openai.com/v1/chat/completions");// → { provider: "openai", endpointCategory: "chat_completions", costPerRequestCents: 2 }// Registry with custom rules — priority by specificity, custom wins on tieconstcustom=newProviderRegistry([{hostPattern: "api.acme.com",provider: "acme",endpointCategory: "api",costPerRequestCents: 0.1},]);// Inspect all loaded rulesconsole.log(BUILTIN_PROVIDERS.length);// 34 built-in rules

What is captured (and what is not)

Captured:

  • Request timestamp, method, URL (query params stripped), host, path
  • Response status code
  • Round-trip latency (ms) — measured to end of response body, identically for fetch and http.request / https.request. For a streaming response this is the full stream duration, not time-to-first-byte.
  • Request and response body size (bytes) — for streamed / chunked responses where the server does not send a Content-Length header, the SDK accumulates the observed byte count as the response body is transmitted. Bodyless responses (e.g. 204, 304, HEAD) fall back to the Content-Length header (typically 0).
  • Matched provider, endpoint category, and estimated cost

Never captured:

  • Request or response headers (contain API keys)
  • Request or response body content (may contain user data or PII) — the SDK observes byte counts via a passthrough stream but never reads chunk contents.

Core types and errors

Type-only imports (no runtime cost):

importtype{RawEvent,MetricEntry,WindowSummary,RecostConfig,ProviderDef,TransportMode,FlushStatus,InterceptorBinding,}from"@recost-dev/node";

Runtime error classes — narrow onError on these via instanceof:

import{RecostError,RecostAuthError,RecostFatalAuthError,RecostLocalUnreachableError,RecostLocalDiskError,RecostInterceptorAlreadyInstalledError,RecostInterceptorPatchOverwrittenError,}from"@recost-dev/node";

All error classes extend RecostError, which extends Error. See src/core/types.ts for the full type documentation.

Testing

Run the full test suite (305 tests across 12 files):

npm test

Watch mode during development:

npm run test:watch

TypeScript type-check only (does not run tests):

npm run lint

Test coverage

FileTestsWhat is covered
tests/provider-registry.test.ts50All 34 built-in rules, wildcard host matching, Twilio path refinement, edge cases (empty string, explicit port, query params), custom priority, specificity-based ordering, BUILTIN_PROVIDERS array ordering, pinned-count regression
tests/interceptor.test.ts60Lifecycle (install/uninstall/isInstalled), fetch/http.request/http.get capture, query stripping, URL/Request object inputs, safety wrappers, getRawFetch bypass, double-count guard, multi-realm install detection, identity-check restore on uninstall, IPv6 host preservation
tests/aggregator.test.ts38Flush/reset, event grouping, p50/p95 percentile edge cases, null provider/endpoint fallbacks, window timestamps, metadata forwarding, size/bucketCount tracking, soft bucket cap via _overflow bucket
tests/transport.test.ts39Cloud POST (URL, auth header, 4xx no-retry, 5xx retry + recovery, onError); 401 lifecycle (RecostAuthErrorRecostFatalAuthError, counter reset rules); WebSocket send / queue-and-drain / FIFO overflow / reconnect-failure threshold (RecostLocalUnreachableError); rejection signalling
tests/file-transport.test.ts16NDJSON appends with protocolVersion, file mode 0o600 on POSIX, rotation at maxFileBytes, in-memory queue + drain on disk-write failure, RecostLocalDiskError once-per-episode
tests/validate-config.test.ts28apiKey prefix + "undefined" rejection, projectId required in cloud mode, localTransport allowed values, localDir / maxFileBytes / maxLocalFileQueueSize bounds, empty excludePatterns rejection
tests/init.test.ts38Interceptor install/dispose, enabled: false, double-init, event enrichment, unknown provider grouping, exclude patterns, auto-exclude transport URL, flush interval, early batch flush, flush() handle method, dispose-then-flush, environment forwarding
tests/contract.test.ts11Wire-format contract: serialized WindowSummary shape, field names, types, windowStart / windowEnd ms-precision-with-Z timestamp format
tests/express.test.ts7Middleware arity, next() called without error, config forwarding
tests/fastify.test.ts6done() called, config forwarding
tests/scaffold.test.ts5Public export smoke tests
tests/dist.test.ts7Built dist/ smoke — emitted files exist; CJS (dist/cjs/index.cjs) and ESM (dist/esm/index.js) load and re-export the public surface; package.jsonmain and types paths resolve

Implementation status

ModuleStatus
core/types.tsShared interfaces (RawEvent, MetricEntry, WindowSummary, RecostConfig, TransportBackend, FlushStatus) and the RecostError class hierarchy
core/provider-registry.ts34 built-in rules across 14 providers, wildcard host matching, specificity-based sort (custom wins on tie)
core/interceptor.tsPatches fetch, http.request / .get, https.request / .get; double-count guard; safety wrappers; multi-realm install detection; identity-check restore on uninstall
core/aggregator.tsTime-windowed bucketing, p50/p95 percentiles, cost aggregation, soft maxBuckets cap via _overflow bucket
core/validate-config.tsSynchronous pre-flight rules — apiKey shape, projectId requirement, local-transport bounds, excludePatterns non-empty entries
core/transport.tsBackend selector — picks CloudBackend, WsBackend, or FileBackend based on config; owns bucket-overflow chunking
core/transport-cloud.tsHTTPS POST with exponential-backoff retry; 401 → RecostAuthErrorRecostFatalAuthError lifecycle
core/transport-ws.tsWebSocket to localhost with queue + drain, exponential-backoff reconnect (±25% jitter), pause after maxConsecutiveReconnectFailures
core/transport-file.tsNDJSON appends to ${localDir}/${projectId}.jsonl (file mode 0o600 on POSIX), rotation at maxFileBytes, in-memory queue on write failure
init.tsWires all modules; resolves transport mode; returns handle with async dispose() (final flush, bounded by shutdownFlushTimeoutMs) and flush() (Python flush_blocking() parity)
frameworks/express.tsThin wrapper around init()
frameworks/fastify.tsThin wrapper around init()

API reference

All requests go to https://api.recost.dev. Authentication uses an rc- prefixed API key passed as Authorization: Bearer {apiKey}.

Send telemetry manually (what the SDK does on flush)

curl -s -X POST https://api.recost.dev/projects/{projectId}/telemetry \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {apiKey}" \
-d @payload.json | jq .

View recent telemetry windows

curl -s "https://api.recost.dev/projects/{projectId}/telemetry/recent?limit=10" \
-H "Authorization: Bearer {apiKey}"| jq .

View analytics for a project

curl -s "https://api.recost.dev/projects/{projectId}/analytics?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z" \
-H "Authorization: Bearer {apiKey}"| jq .

License

Licensed under the Business Source License 1.1. You may use this software in production, but you may not offer it as a commercial API cost tracking or monitoring service. The source code will convert to Apache 2.0 on April 1, 2030.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages