@autter/runtime-node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @autter/runtime-node
2
+
3
+ Autter Runtime for Node.js — two halves in one package:
4
+
5
+ 1. **Same-origin browser relay** for `@autter/runtime-browser`
6
+ 2. **Curated OpenTelemetry server tracker** exporting OTLP/HTTP JSON
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @autter/runtime-node
12
+ ```
13
+
14
+ ## 1. Browser relay
15
+
16
+ The browser tracker posts to your backend; this handler validates and
17
+ whitelist-sanitises the payload, attaches your private ingest key
18
+ server-side, forwards asynchronously, and returns 202 immediately.
19
+
20
+ Express / Node http:
21
+
22
+ ```ts
23
+ import { createBrowserRelayHandler } from "@autter/runtime-node";
24
+
25
+ app.post(
26
+ "/api/autter-runtime",
27
+ createBrowserRelayHandler({ apiKey: process.env.AUTTER_RUNTIME_KEY! }),
28
+ );
29
+ ```
30
+
31
+ Next.js App Router / any fetch-style runtime:
32
+
33
+ ```ts
34
+ import { createBrowserRelayFetchHandler } from "@autter/runtime-node";
35
+
36
+ export const POST = createBrowserRelayFetchHandler({
37
+ apiKey: process.env.AUTTER_RUNTIME_KEY!,
38
+ });
39
+ ```
40
+
41
+ Options: `endpoint` (default `https://otlp.autter.dev`), `maxBodyBytes`
42
+ (default 64 KB), `onError`.
43
+
44
+ ## 2. Server tracker
45
+
46
+ ```ts
47
+ // instrument.ts — must run before anything else creates connections
48
+ import { initAutterServer } from "@autter/runtime-node";
49
+
50
+ const autter = initAutterServer({
51
+ apiKey: process.env.AUTTER_RUNTIME_KEY!,
52
+ service: "payments-api",
53
+ environment: process.env.NODE_ENV,
54
+ release: process.env.GIT_SHA,
55
+ });
56
+
57
+ // handled errors — always recorded, never sampled out:
58
+ autter.captureException(err, { "order.id": "…" });
59
+
60
+ // warnings/info without an exception — same grouping+aggregation as
61
+ // errors, just a lower severity ("fatal" | "error" | "warning" | "info"):
62
+ autter.captureMessage("Legacy /orders lookup used", "warning");
63
+
64
+ // graceful shutdown flushes exporters:
65
+ await autter.shutdown();
66
+ ```
67
+
68
+ Run it first: `node --require ./instrument.cjs server.js` (CJS), or for
69
+ pure-ESM apps add OTel's loader hook
70
+ (`node --import ./instrument.mjs --experimental-loader=@opentelemetry/instrumentation/hook.mjs server.js`)
71
+ so `http` auto-instrumentation can patch ESM imports.
72
+
73
+ Defaults (cheap by construction):
74
+
75
+ | Signal | Default |
76
+ | --- | --- |
77
+ | Captured/unhandled exceptions | 100% (dedicated always-on tracer) |
78
+ | Traces | 1% head sampling (`traceSampleRate`) |
79
+ | Request metrics | exported every 60 s |
80
+ | Logs | not collected |
81
+
82
+ Crashes are observed via `process.uncaughtExceptionMonitor`, which does
83
+ **not** change your process's exit behaviour; the final flush is
84
+ best-effort. Framework instrumentations are opt-in:
85
+
86
+ ```ts
87
+ import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
88
+ initAutterServer({ ..., instrumentations: [new ExpressInstrumentation()] });
89
+ ```
90
+
91
+ Note on usage rollups: requests are counted from the `http.server.duration`
92
+ metric (100% accurate) and additionally from sampled server spans. At the
93
+ default 1% sampling the span contribution is negligible; if you set
94
+ `traceSampleRate: 1` in development, expect request counts roughly doubled.
package/dist/index.cjs ADDED
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ captureException: () => captureException,
24
+ captureMessage: () => captureMessage,
25
+ createBrowserRelayFetchHandler: () => createBrowserRelayFetchHandler,
26
+ createBrowserRelayHandler: () => createBrowserRelayHandler,
27
+ initAutterServer: () => initAutterServer,
28
+ sanitizeBrowserPayload: () => sanitizeBrowserPayload
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/relay.ts
33
+ var IpWindow = class {
34
+ constructor(limitPerMinute) {
35
+ this.limitPerMinute = limitPerMinute;
36
+ }
37
+ limitPerMinute;
38
+ windows = /* @__PURE__ */ new Map();
39
+ allow(ip) {
40
+ const start = Math.floor(Date.now() / 6e4) * 6e4;
41
+ const entry = this.windows.get(ip);
42
+ if (!entry || entry.start !== start) {
43
+ this.windows.set(ip, { start, count: 1 });
44
+ if (this.windows.size > 5e4) this.windows.clear();
45
+ return true;
46
+ }
47
+ entry.count += 1;
48
+ return entry.count <= this.limitPerMinute;
49
+ }
50
+ };
51
+ function firstForwardedFor(value) {
52
+ const raw = Array.isArray(value) ? value[0] : value;
53
+ return raw ? (raw.split(",")[0] ?? "").trim() : "";
54
+ }
55
+ var DEFAULT_ENDPOINT = "https://otlp.autter.dev";
56
+ var DEFAULT_MAX_BODY = 64 * 1024;
57
+ var MAX_EVENTS = 50;
58
+ var EVENT_TYPES = /* @__PURE__ */ new Set([
59
+ "exception",
60
+ "unhandled_rejection",
61
+ "message",
62
+ "session_start",
63
+ "track_event"
64
+ ]);
65
+ var SEVERITIES = /* @__PURE__ */ new Set(["fatal", "error", "warning", "info"]);
66
+ function sanitizeBrowserPayload(raw) {
67
+ if (typeof raw !== "object" || raw === null) return null;
68
+ const p = raw;
69
+ if (p.version !== 1) return null;
70
+ if (typeof p.service !== "string" || !p.service) return null;
71
+ if (typeof p.environment !== "string" || !p.environment) return null;
72
+ if (!Array.isArray(p.events) || p.events.length > MAX_EVENTS) return null;
73
+ const events = [];
74
+ for (const rawEvent of p.events) {
75
+ if (typeof rawEvent !== "object" || rawEvent === null) return null;
76
+ const e = rawEvent;
77
+ if (typeof e.type !== "string" || !EVENT_TYPES.has(e.type)) return null;
78
+ if (typeof e.timestamp !== "string") return null;
79
+ events.push({
80
+ type: e.type,
81
+ timestamp: e.timestamp,
82
+ ...typeof e.severity === "string" && SEVERITIES.has(e.severity) ? { severity: e.severity } : {},
83
+ message: typeof e.message === "string" ? e.message.slice(0, 4e3) : "",
84
+ ...typeof e.name === "string" ? { name: e.name.slice(0, 200) } : {},
85
+ ...typeof e.stack === "string" ? { stack: e.stack.slice(0, 32e3) } : {},
86
+ ...typeof e.errorType === "string" ? { errorType: e.errorType.slice(0, 200) } : {},
87
+ ...typeof e.filename === "string" ? { filename: e.filename.split("?")[0].slice(0, 1e3) } : {},
88
+ ...typeof e.line === "number" ? { line: e.line } : {},
89
+ ...typeof e.column === "number" ? { column: e.column } : {},
90
+ ...typeof e.route === "string" ? { route: e.route.split("?")[0].slice(0, 1e3) } : {},
91
+ ...typeof e.context === "object" && e.context !== null ? { context: e.context } : {}
92
+ });
93
+ }
94
+ return {
95
+ version: 1,
96
+ ...typeof p.sessionId === "string" ? { sessionId: p.sessionId.slice(0, 100) } : {},
97
+ service: p.service.slice(0, 200),
98
+ environment: p.environment.slice(0, 100),
99
+ ...typeof p.release === "string" ? { release: p.release.slice(0, 200) } : {},
100
+ events
101
+ };
102
+ }
103
+ function forward(payload, opts) {
104
+ const url = `${(opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "")}/v1/browser`;
105
+ void fetch(url, {
106
+ method: "POST",
107
+ headers: {
108
+ "content-type": "application/json",
109
+ authorization: `Bearer ${opts.apiKey}`
110
+ },
111
+ body: JSON.stringify(payload),
112
+ signal: AbortSignal.timeout(1e4)
113
+ }).catch((err) => {
114
+ (opts.onError ?? ((e) => console.warn("autter relay forward failed:", e)))(
115
+ err
116
+ );
117
+ });
118
+ }
119
+ function createBrowserRelayFetchHandler(opts) {
120
+ const maxBody = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
121
+ const limiter = opts.perIpRateLimit === false ? null : new IpWindow(opts.perIpRateLimit ?? 120);
122
+ return async (request) => {
123
+ if (request.method !== "POST") {
124
+ return new Response(null, { status: 405 });
125
+ }
126
+ if (limiter) {
127
+ const ip = firstForwardedFor(request.headers.get("x-forwarded-for")) || "unknown";
128
+ if (!limiter.allow(ip)) {
129
+ return new Response(JSON.stringify({ error: "rate limit exceeded" }), {
130
+ status: 429
131
+ });
132
+ }
133
+ }
134
+ const text = await request.text();
135
+ if (text.length > maxBody) {
136
+ return new Response(JSON.stringify({ error: "payload too large" }), {
137
+ status: 413
138
+ });
139
+ }
140
+ let raw;
141
+ try {
142
+ raw = JSON.parse(text);
143
+ } catch {
144
+ return new Response(JSON.stringify({ error: "invalid json" }), {
145
+ status: 400
146
+ });
147
+ }
148
+ const payload = sanitizeBrowserPayload(raw);
149
+ if (!payload) {
150
+ return new Response(JSON.stringify({ error: "invalid payload" }), {
151
+ status: 400
152
+ });
153
+ }
154
+ forward(payload, opts);
155
+ return new Response(null, { status: 202 });
156
+ };
157
+ }
158
+ function createBrowserRelayHandler(opts) {
159
+ const maxBody = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
160
+ const limiter = opts.perIpRateLimit === false ? null : new IpWindow(opts.perIpRateLimit ?? 120);
161
+ function respond(res, status, body) {
162
+ res.statusCode = status;
163
+ if (body) {
164
+ res.setHeader("content-type", "application/json");
165
+ res.end(JSON.stringify(body));
166
+ } else {
167
+ res.end();
168
+ }
169
+ }
170
+ function handleParsed(raw, res) {
171
+ const payload = sanitizeBrowserPayload(raw);
172
+ if (!payload) {
173
+ respond(res, 400, { error: "invalid payload" });
174
+ return;
175
+ }
176
+ forward(payload, opts);
177
+ respond(res, 202);
178
+ }
179
+ return (req, res) => {
180
+ if (req.method !== "POST") {
181
+ respond(res, 405);
182
+ return;
183
+ }
184
+ if (limiter) {
185
+ const ip = firstForwardedFor(req.headers["x-forwarded-for"]) || req.socket?.remoteAddress || "unknown";
186
+ if (!limiter.allow(ip)) {
187
+ respond(res, 429, { error: "rate limit exceeded" });
188
+ return;
189
+ }
190
+ }
191
+ if (req.body !== void 0) {
192
+ let raw = req.body;
193
+ if (typeof raw === "string" || Buffer.isBuffer(raw)) {
194
+ try {
195
+ raw = JSON.parse(raw.toString());
196
+ } catch {
197
+ respond(res, 400, { error: "invalid json" });
198
+ return;
199
+ }
200
+ }
201
+ handleParsed(raw, res);
202
+ return;
203
+ }
204
+ const chunks = [];
205
+ let size = 0;
206
+ let aborted = false;
207
+ req.on("data", (chunk) => {
208
+ size += chunk.length;
209
+ if (size > maxBody) {
210
+ aborted = true;
211
+ respond(res, 413, { error: "payload too large" });
212
+ req.destroy();
213
+ return;
214
+ }
215
+ chunks.push(chunk);
216
+ });
217
+ req.on("end", () => {
218
+ if (aborted) return;
219
+ try {
220
+ handleParsed(JSON.parse(Buffer.concat(chunks).toString()), res);
221
+ } catch {
222
+ respond(res, 400, { error: "invalid json" });
223
+ }
224
+ });
225
+ req.on("error", () => {
226
+ if (!aborted) respond(res, 400, { error: "read error" });
227
+ });
228
+ };
229
+ }
230
+
231
+ // src/server.ts
232
+ var import_api = require("@opentelemetry/api");
233
+ var import_exporter_metrics_otlp_http = require("@opentelemetry/exporter-metrics-otlp-http");
234
+ var import_exporter_trace_otlp_http = require("@opentelemetry/exporter-trace-otlp-http");
235
+ var import_instrumentation_http = require("@opentelemetry/instrumentation-http");
236
+ var import_resources = require("@opentelemetry/resources");
237
+ var import_sdk_metrics = require("@opentelemetry/sdk-metrics");
238
+ var import_sdk_node = require("@opentelemetry/sdk-node");
239
+ var import_sdk_trace_base = require("@opentelemetry/sdk-trace-base");
240
+ var import_semantic_conventions = require("@opentelemetry/semantic-conventions");
241
+ var active = null;
242
+ function initAutterServer(options) {
243
+ if (active) return active;
244
+ const endpoint = (options.endpoint ?? "https://otlp.autter.dev").replace(
245
+ /\/$/,
246
+ ""
247
+ );
248
+ const headers = { authorization: `Bearer ${options.apiKey}` };
249
+ const environment = options.environment ?? process.env.NODE_ENV ?? "production";
250
+ const resource = new import_resources.Resource({
251
+ [import_semantic_conventions.ATTR_SERVICE_NAME]: options.service,
252
+ ...options.release ? { [import_semantic_conventions.ATTR_SERVICE_VERSION]: options.release } : {},
253
+ "deployment.environment": environment
254
+ });
255
+ const sdk = new import_sdk_node.NodeSDK({
256
+ resource,
257
+ sampler: new import_sdk_trace_base.ParentBasedSampler({
258
+ root: new import_sdk_trace_base.TraceIdRatioBasedSampler(options.traceSampleRate ?? 0.01)
259
+ }),
260
+ traceExporter: new import_exporter_trace_otlp_http.OTLPTraceExporter({
261
+ url: `${endpoint}/v1/traces`,
262
+ headers
263
+ }),
264
+ metricReader: new import_sdk_metrics.PeriodicExportingMetricReader({
265
+ exporter: new import_exporter_metrics_otlp_http.OTLPMetricExporter({
266
+ url: `${endpoint}/v1/metrics`,
267
+ headers
268
+ }),
269
+ exportIntervalMillis: options.metricIntervalMs ?? 6e4
270
+ }),
271
+ instrumentations: [
272
+ new import_instrumentation_http.HttpInstrumentation(),
273
+ ...options.instrumentations ?? []
274
+ ]
275
+ });
276
+ sdk.start();
277
+ const errorProvider = new import_sdk_trace_base.BasicTracerProvider({
278
+ resource,
279
+ sampler: new import_sdk_trace_base.AlwaysOnSampler(),
280
+ spanProcessors: [
281
+ new import_sdk_trace_base.BatchSpanProcessor(
282
+ new import_exporter_trace_otlp_http.OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }),
283
+ { scheduledDelayMillis: 2e3 }
284
+ )
285
+ ]
286
+ });
287
+ const errorTracer = errorProvider.getTracer("autter-errors");
288
+ function captureException2(error, attributes) {
289
+ const isError = error instanceof Error;
290
+ const message = isError ? error.message : String(error);
291
+ const span = errorTracer.startSpan(isError ? error.name : "Error", {
292
+ attributes: { "autter.severity": "error", ...attributes }
293
+ });
294
+ if (isError) {
295
+ span.recordException(error);
296
+ } else {
297
+ span.addEvent("exception", {
298
+ "exception.type": "Error",
299
+ "exception.message": message
300
+ });
301
+ }
302
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message });
303
+ span.end();
304
+ }
305
+ function captureMessage2(message, severity = "warning", attributes) {
306
+ const stack = new Error().stack?.split("\n").filter((line, i) => i === 0 || !line.includes("captureMessage")).join("\n");
307
+ const span = errorTracer.startSpan("Message", {
308
+ attributes: { "autter.severity": severity, ...attributes }
309
+ });
310
+ span.addEvent("exception", {
311
+ "exception.type": "Message",
312
+ "exception.message": message,
313
+ ...stack ? { "exception.stacktrace": stack } : {},
314
+ "autter.severity": severity
315
+ });
316
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message });
317
+ span.end();
318
+ }
319
+ if (options.captureGlobalErrors !== false) {
320
+ process.on("uncaughtExceptionMonitor", (err) => {
321
+ captureException2(err, { "autter.unhandled": true });
322
+ void errorProvider.forceFlush().catch(() => {
323
+ });
324
+ });
325
+ }
326
+ const server = {
327
+ captureException: captureException2,
328
+ captureMessage: captureMessage2,
329
+ shutdown: async () => {
330
+ active = null;
331
+ await Promise.allSettled([errorProvider.shutdown(), sdk.shutdown()]);
332
+ }
333
+ };
334
+ active = server;
335
+ return server;
336
+ }
337
+ function captureException(error, attributes) {
338
+ if (active) {
339
+ active.captureException(error, attributes);
340
+ return;
341
+ }
342
+ const span = import_api.trace.getTracer("autter-errors").startSpan(error instanceof Error ? error.name : "Error", { attributes });
343
+ if (error instanceof Error) span.recordException(error);
344
+ span.setStatus({
345
+ code: import_api.SpanStatusCode.ERROR,
346
+ message: error instanceof Error ? error.message : String(error)
347
+ });
348
+ span.end();
349
+ }
350
+ function captureMessage(message, severity = "warning", attributes) {
351
+ if (active) {
352
+ active.captureMessage(message, severity, attributes);
353
+ return;
354
+ }
355
+ const span = import_api.trace.getTracer("autter-errors").startSpan("Message", {
356
+ attributes: { "autter.severity": severity, ...attributes }
357
+ });
358
+ span.addEvent("exception", {
359
+ "exception.type": "Message",
360
+ "exception.message": message,
361
+ "autter.severity": severity
362
+ });
363
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message });
364
+ span.end();
365
+ }
366
+ // Annotate the CommonJS export names for ESM import in node:
367
+ 0 && (module.exports = {
368
+ captureException,
369
+ captureMessage,
370
+ createBrowserRelayFetchHandler,
371
+ createBrowserRelayHandler,
372
+ initAutterServer,
373
+ sanitizeBrowserPayload
374
+ });
@@ -0,0 +1,96 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { Attributes } from '@opentelemetry/api';
3
+
4
+ /**
5
+ * Same-origin browser relay. The browser tracker posts to a route on the
6
+ * customer's own backend; this handler validates + sanitises the payload,
7
+ * attaches the private ingest key server-side, forwards asynchronously to
8
+ * the Autter ingester, and returns 202 immediately. The key never reaches
9
+ * the browser, and there is no CORS/CSP surface.
10
+ */
11
+ interface RelayOptions {
12
+ /** Private ingest key (autter_rt_…). Keep it in an env var. */
13
+ apiKey: string;
14
+ /** Ingester base URL. Default: https://otlp.autter.dev */
15
+ endpoint?: string;
16
+ /** Max accepted request body. Default 64 KB. */
17
+ maxBodyBytes?: number;
18
+ /**
19
+ * The relay route is necessarily public (browsers must reach it), so it
20
+ * ships with a per-IP fixed-window limit. Default 120 req/min; set
21
+ * `false` to disable (e.g. when a WAF already rate-limits).
22
+ */
23
+ perIpRateLimit?: number | false;
24
+ /** Called when the async forward fails (default: console.warn). */
25
+ onError?: (err: unknown) => void;
26
+ }
27
+ declare function sanitizeBrowserPayload(raw: unknown): object | null;
28
+ /**
29
+ * Fetch-style handler (Next.js App Router route, Remix, Hono, Bun, Deno):
30
+ *
31
+ * export const POST = createBrowserRelayFetchHandler({ apiKey: env.AUTTER_RUNTIME_KEY });
32
+ */
33
+ declare function createBrowserRelayFetchHandler(opts: RelayOptions): (request: Request) => Promise<Response>;
34
+ /**
35
+ * Node http / Express / Fastify(raw) handler:
36
+ *
37
+ * app.post("/api/autter-runtime", createBrowserRelayHandler({ apiKey: process.env.AUTTER_RUNTIME_KEY! }));
38
+ *
39
+ * Works with or without a body parser: uses `req.body` when a middleware
40
+ * already parsed it, otherwise reads the raw stream (capped).
41
+ */
42
+ declare function createBrowserRelayHandler(opts: RelayOptions): (req: IncomingMessage & {
43
+ body?: unknown;
44
+ }, res: ServerResponse) => void;
45
+
46
+ /**
47
+ * Curated OpenTelemetry setup for Autter Runtime — deliberately NOT the
48
+ * auto-instrumentation metapackage. Default data-volume policy:
49
+ *
50
+ * errors / captured exceptions : 100% (dedicated always-on error tracer)
51
+ * successful traces : 1% (head sampling, configurable)
52
+ * request metrics : aggregated every 60 s
53
+ * logs : not collected
54
+ *
55
+ * Exports OTLP/HTTP JSON to the Autter ingester (`/v1/traces`,
56
+ * `/v1/metrics`) with the ingest key as a bearer header.
57
+ */
58
+ interface AutterServerOptions {
59
+ /** Private ingest key (autter_rt_…). */
60
+ apiKey: string;
61
+ /** Ingester base URL. Default: https://otlp.autter.dev */
62
+ endpoint?: string;
63
+ service: string;
64
+ environment?: string;
65
+ /** e.g. a git SHA. Maps to service.version / release. */
66
+ release?: string;
67
+ /** Head-sampling ratio for regular traces. Default 0.01 (1%). */
68
+ traceSampleRate?: number;
69
+ /** Metric export interval. Default 60_000 ms. */
70
+ metricIntervalMs?: number;
71
+ /** Capture crashing exceptions via process.uncaughtExceptionMonitor (default true). */
72
+ captureGlobalErrors?: boolean;
73
+ /** Extra instrumentations (e.g. `new ExpressInstrumentation()`). */
74
+ instrumentations?: unknown[];
75
+ }
76
+ type AutterSeverity = "fatal" | "error" | "warning" | "info";
77
+ interface AutterServer {
78
+ /** Report a handled exception. Always recorded (never sampled out). */
79
+ captureException(error: unknown, attributes?: Attributes): void;
80
+ /**
81
+ * Report a warning (or info) without an exception — deprecated code
82
+ * paths, recoverable failures, degraded dependencies. Stored alongside
83
+ * errors with a lower severity so aggregations can slice one dataset.
84
+ */
85
+ captureMessage(message: string, severity?: AutterSeverity, attributes?: Attributes): void;
86
+ /** Flush and stop all exporters. Call on graceful shutdown. */
87
+ shutdown(): Promise<void>;
88
+ }
89
+ declare function initAutterServer(options: AutterServerOptions): AutterServer;
90
+ /** Module-level convenience — routes to the active server, or degrades to a
91
+ * span on the global tracer so libraries can call it unconditionally. */
92
+ declare function captureException(error: unknown, attributes?: Attributes): void;
93
+ /** Module-level convenience for warnings/info — see AutterServer.captureMessage. */
94
+ declare function captureMessage(message: string, severity?: AutterSeverity, attributes?: Attributes): void;
95
+
96
+ export { type AutterServer, type AutterServerOptions, type AutterSeverity, type RelayOptions, captureException, captureMessage, createBrowserRelayFetchHandler, createBrowserRelayHandler, initAutterServer, sanitizeBrowserPayload };
@@ -0,0 +1,96 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { Attributes } from '@opentelemetry/api';
3
+
4
+ /**
5
+ * Same-origin browser relay. The browser tracker posts to a route on the
6
+ * customer's own backend; this handler validates + sanitises the payload,
7
+ * attaches the private ingest key server-side, forwards asynchronously to
8
+ * the Autter ingester, and returns 202 immediately. The key never reaches
9
+ * the browser, and there is no CORS/CSP surface.
10
+ */
11
+ interface RelayOptions {
12
+ /** Private ingest key (autter_rt_…). Keep it in an env var. */
13
+ apiKey: string;
14
+ /** Ingester base URL. Default: https://otlp.autter.dev */
15
+ endpoint?: string;
16
+ /** Max accepted request body. Default 64 KB. */
17
+ maxBodyBytes?: number;
18
+ /**
19
+ * The relay route is necessarily public (browsers must reach it), so it
20
+ * ships with a per-IP fixed-window limit. Default 120 req/min; set
21
+ * `false` to disable (e.g. when a WAF already rate-limits).
22
+ */
23
+ perIpRateLimit?: number | false;
24
+ /** Called when the async forward fails (default: console.warn). */
25
+ onError?: (err: unknown) => void;
26
+ }
27
+ declare function sanitizeBrowserPayload(raw: unknown): object | null;
28
+ /**
29
+ * Fetch-style handler (Next.js App Router route, Remix, Hono, Bun, Deno):
30
+ *
31
+ * export const POST = createBrowserRelayFetchHandler({ apiKey: env.AUTTER_RUNTIME_KEY });
32
+ */
33
+ declare function createBrowserRelayFetchHandler(opts: RelayOptions): (request: Request) => Promise<Response>;
34
+ /**
35
+ * Node http / Express / Fastify(raw) handler:
36
+ *
37
+ * app.post("/api/autter-runtime", createBrowserRelayHandler({ apiKey: process.env.AUTTER_RUNTIME_KEY! }));
38
+ *
39
+ * Works with or without a body parser: uses `req.body` when a middleware
40
+ * already parsed it, otherwise reads the raw stream (capped).
41
+ */
42
+ declare function createBrowserRelayHandler(opts: RelayOptions): (req: IncomingMessage & {
43
+ body?: unknown;
44
+ }, res: ServerResponse) => void;
45
+
46
+ /**
47
+ * Curated OpenTelemetry setup for Autter Runtime — deliberately NOT the
48
+ * auto-instrumentation metapackage. Default data-volume policy:
49
+ *
50
+ * errors / captured exceptions : 100% (dedicated always-on error tracer)
51
+ * successful traces : 1% (head sampling, configurable)
52
+ * request metrics : aggregated every 60 s
53
+ * logs : not collected
54
+ *
55
+ * Exports OTLP/HTTP JSON to the Autter ingester (`/v1/traces`,
56
+ * `/v1/metrics`) with the ingest key as a bearer header.
57
+ */
58
+ interface AutterServerOptions {
59
+ /** Private ingest key (autter_rt_…). */
60
+ apiKey: string;
61
+ /** Ingester base URL. Default: https://otlp.autter.dev */
62
+ endpoint?: string;
63
+ service: string;
64
+ environment?: string;
65
+ /** e.g. a git SHA. Maps to service.version / release. */
66
+ release?: string;
67
+ /** Head-sampling ratio for regular traces. Default 0.01 (1%). */
68
+ traceSampleRate?: number;
69
+ /** Metric export interval. Default 60_000 ms. */
70
+ metricIntervalMs?: number;
71
+ /** Capture crashing exceptions via process.uncaughtExceptionMonitor (default true). */
72
+ captureGlobalErrors?: boolean;
73
+ /** Extra instrumentations (e.g. `new ExpressInstrumentation()`). */
74
+ instrumentations?: unknown[];
75
+ }
76
+ type AutterSeverity = "fatal" | "error" | "warning" | "info";
77
+ interface AutterServer {
78
+ /** Report a handled exception. Always recorded (never sampled out). */
79
+ captureException(error: unknown, attributes?: Attributes): void;
80
+ /**
81
+ * Report a warning (or info) without an exception — deprecated code
82
+ * paths, recoverable failures, degraded dependencies. Stored alongside
83
+ * errors with a lower severity so aggregations can slice one dataset.
84
+ */
85
+ captureMessage(message: string, severity?: AutterSeverity, attributes?: Attributes): void;
86
+ /** Flush and stop all exporters. Call on graceful shutdown. */
87
+ shutdown(): Promise<void>;
88
+ }
89
+ declare function initAutterServer(options: AutterServerOptions): AutterServer;
90
+ /** Module-level convenience — routes to the active server, or degrades to a
91
+ * span on the global tracer so libraries can call it unconditionally. */
92
+ declare function captureException(error: unknown, attributes?: Attributes): void;
93
+ /** Module-level convenience for warnings/info — see AutterServer.captureMessage. */
94
+ declare function captureMessage(message: string, severity?: AutterSeverity, attributes?: Attributes): void;
95
+
96
+ export { type AutterServer, type AutterServerOptions, type AutterSeverity, type RelayOptions, captureException, captureMessage, createBrowserRelayFetchHandler, createBrowserRelayHandler, initAutterServer, sanitizeBrowserPayload };
package/dist/index.js ADDED
@@ -0,0 +1,351 @@
1
+ // src/relay.ts
2
+ var IpWindow = class {
3
+ constructor(limitPerMinute) {
4
+ this.limitPerMinute = limitPerMinute;
5
+ }
6
+ limitPerMinute;
7
+ windows = /* @__PURE__ */ new Map();
8
+ allow(ip) {
9
+ const start = Math.floor(Date.now() / 6e4) * 6e4;
10
+ const entry = this.windows.get(ip);
11
+ if (!entry || entry.start !== start) {
12
+ this.windows.set(ip, { start, count: 1 });
13
+ if (this.windows.size > 5e4) this.windows.clear();
14
+ return true;
15
+ }
16
+ entry.count += 1;
17
+ return entry.count <= this.limitPerMinute;
18
+ }
19
+ };
20
+ function firstForwardedFor(value) {
21
+ const raw = Array.isArray(value) ? value[0] : value;
22
+ return raw ? (raw.split(",")[0] ?? "").trim() : "";
23
+ }
24
+ var DEFAULT_ENDPOINT = "https://otlp.autter.dev";
25
+ var DEFAULT_MAX_BODY = 64 * 1024;
26
+ var MAX_EVENTS = 50;
27
+ var EVENT_TYPES = /* @__PURE__ */ new Set([
28
+ "exception",
29
+ "unhandled_rejection",
30
+ "message",
31
+ "session_start",
32
+ "track_event"
33
+ ]);
34
+ var SEVERITIES = /* @__PURE__ */ new Set(["fatal", "error", "warning", "info"]);
35
+ function sanitizeBrowserPayload(raw) {
36
+ if (typeof raw !== "object" || raw === null) return null;
37
+ const p = raw;
38
+ if (p.version !== 1) return null;
39
+ if (typeof p.service !== "string" || !p.service) return null;
40
+ if (typeof p.environment !== "string" || !p.environment) return null;
41
+ if (!Array.isArray(p.events) || p.events.length > MAX_EVENTS) return null;
42
+ const events = [];
43
+ for (const rawEvent of p.events) {
44
+ if (typeof rawEvent !== "object" || rawEvent === null) return null;
45
+ const e = rawEvent;
46
+ if (typeof e.type !== "string" || !EVENT_TYPES.has(e.type)) return null;
47
+ if (typeof e.timestamp !== "string") return null;
48
+ events.push({
49
+ type: e.type,
50
+ timestamp: e.timestamp,
51
+ ...typeof e.severity === "string" && SEVERITIES.has(e.severity) ? { severity: e.severity } : {},
52
+ message: typeof e.message === "string" ? e.message.slice(0, 4e3) : "",
53
+ ...typeof e.name === "string" ? { name: e.name.slice(0, 200) } : {},
54
+ ...typeof e.stack === "string" ? { stack: e.stack.slice(0, 32e3) } : {},
55
+ ...typeof e.errorType === "string" ? { errorType: e.errorType.slice(0, 200) } : {},
56
+ ...typeof e.filename === "string" ? { filename: e.filename.split("?")[0].slice(0, 1e3) } : {},
57
+ ...typeof e.line === "number" ? { line: e.line } : {},
58
+ ...typeof e.column === "number" ? { column: e.column } : {},
59
+ ...typeof e.route === "string" ? { route: e.route.split("?")[0].slice(0, 1e3) } : {},
60
+ ...typeof e.context === "object" && e.context !== null ? { context: e.context } : {}
61
+ });
62
+ }
63
+ return {
64
+ version: 1,
65
+ ...typeof p.sessionId === "string" ? { sessionId: p.sessionId.slice(0, 100) } : {},
66
+ service: p.service.slice(0, 200),
67
+ environment: p.environment.slice(0, 100),
68
+ ...typeof p.release === "string" ? { release: p.release.slice(0, 200) } : {},
69
+ events
70
+ };
71
+ }
72
+ function forward(payload, opts) {
73
+ const url = `${(opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "")}/v1/browser`;
74
+ void fetch(url, {
75
+ method: "POST",
76
+ headers: {
77
+ "content-type": "application/json",
78
+ authorization: `Bearer ${opts.apiKey}`
79
+ },
80
+ body: JSON.stringify(payload),
81
+ signal: AbortSignal.timeout(1e4)
82
+ }).catch((err) => {
83
+ (opts.onError ?? ((e) => console.warn("autter relay forward failed:", e)))(
84
+ err
85
+ );
86
+ });
87
+ }
88
+ function createBrowserRelayFetchHandler(opts) {
89
+ const maxBody = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
90
+ const limiter = opts.perIpRateLimit === false ? null : new IpWindow(opts.perIpRateLimit ?? 120);
91
+ return async (request) => {
92
+ if (request.method !== "POST") {
93
+ return new Response(null, { status: 405 });
94
+ }
95
+ if (limiter) {
96
+ const ip = firstForwardedFor(request.headers.get("x-forwarded-for")) || "unknown";
97
+ if (!limiter.allow(ip)) {
98
+ return new Response(JSON.stringify({ error: "rate limit exceeded" }), {
99
+ status: 429
100
+ });
101
+ }
102
+ }
103
+ const text = await request.text();
104
+ if (text.length > maxBody) {
105
+ return new Response(JSON.stringify({ error: "payload too large" }), {
106
+ status: 413
107
+ });
108
+ }
109
+ let raw;
110
+ try {
111
+ raw = JSON.parse(text);
112
+ } catch {
113
+ return new Response(JSON.stringify({ error: "invalid json" }), {
114
+ status: 400
115
+ });
116
+ }
117
+ const payload = sanitizeBrowserPayload(raw);
118
+ if (!payload) {
119
+ return new Response(JSON.stringify({ error: "invalid payload" }), {
120
+ status: 400
121
+ });
122
+ }
123
+ forward(payload, opts);
124
+ return new Response(null, { status: 202 });
125
+ };
126
+ }
127
+ function createBrowserRelayHandler(opts) {
128
+ const maxBody = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
129
+ const limiter = opts.perIpRateLimit === false ? null : new IpWindow(opts.perIpRateLimit ?? 120);
130
+ function respond(res, status, body) {
131
+ res.statusCode = status;
132
+ if (body) {
133
+ res.setHeader("content-type", "application/json");
134
+ res.end(JSON.stringify(body));
135
+ } else {
136
+ res.end();
137
+ }
138
+ }
139
+ function handleParsed(raw, res) {
140
+ const payload = sanitizeBrowserPayload(raw);
141
+ if (!payload) {
142
+ respond(res, 400, { error: "invalid payload" });
143
+ return;
144
+ }
145
+ forward(payload, opts);
146
+ respond(res, 202);
147
+ }
148
+ return (req, res) => {
149
+ if (req.method !== "POST") {
150
+ respond(res, 405);
151
+ return;
152
+ }
153
+ if (limiter) {
154
+ const ip = firstForwardedFor(req.headers["x-forwarded-for"]) || req.socket?.remoteAddress || "unknown";
155
+ if (!limiter.allow(ip)) {
156
+ respond(res, 429, { error: "rate limit exceeded" });
157
+ return;
158
+ }
159
+ }
160
+ if (req.body !== void 0) {
161
+ let raw = req.body;
162
+ if (typeof raw === "string" || Buffer.isBuffer(raw)) {
163
+ try {
164
+ raw = JSON.parse(raw.toString());
165
+ } catch {
166
+ respond(res, 400, { error: "invalid json" });
167
+ return;
168
+ }
169
+ }
170
+ handleParsed(raw, res);
171
+ return;
172
+ }
173
+ const chunks = [];
174
+ let size = 0;
175
+ let aborted = false;
176
+ req.on("data", (chunk) => {
177
+ size += chunk.length;
178
+ if (size > maxBody) {
179
+ aborted = true;
180
+ respond(res, 413, { error: "payload too large" });
181
+ req.destroy();
182
+ return;
183
+ }
184
+ chunks.push(chunk);
185
+ });
186
+ req.on("end", () => {
187
+ if (aborted) return;
188
+ try {
189
+ handleParsed(JSON.parse(Buffer.concat(chunks).toString()), res);
190
+ } catch {
191
+ respond(res, 400, { error: "invalid json" });
192
+ }
193
+ });
194
+ req.on("error", () => {
195
+ if (!aborted) respond(res, 400, { error: "read error" });
196
+ });
197
+ };
198
+ }
199
+
200
+ // src/server.ts
201
+ import { trace, SpanStatusCode } from "@opentelemetry/api";
202
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
203
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
204
+ import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
205
+ import { Resource } from "@opentelemetry/resources";
206
+ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
207
+ import { NodeSDK } from "@opentelemetry/sdk-node";
208
+ import {
209
+ BatchSpanProcessor,
210
+ ParentBasedSampler,
211
+ TraceIdRatioBasedSampler,
212
+ AlwaysOnSampler,
213
+ BasicTracerProvider
214
+ } from "@opentelemetry/sdk-trace-base";
215
+ import {
216
+ ATTR_SERVICE_NAME,
217
+ ATTR_SERVICE_VERSION
218
+ } from "@opentelemetry/semantic-conventions";
219
+ var active = null;
220
+ function initAutterServer(options) {
221
+ if (active) return active;
222
+ const endpoint = (options.endpoint ?? "https://otlp.autter.dev").replace(
223
+ /\/$/,
224
+ ""
225
+ );
226
+ const headers = { authorization: `Bearer ${options.apiKey}` };
227
+ const environment = options.environment ?? process.env.NODE_ENV ?? "production";
228
+ const resource = new Resource({
229
+ [ATTR_SERVICE_NAME]: options.service,
230
+ ...options.release ? { [ATTR_SERVICE_VERSION]: options.release } : {},
231
+ "deployment.environment": environment
232
+ });
233
+ const sdk = new NodeSDK({
234
+ resource,
235
+ sampler: new ParentBasedSampler({
236
+ root: new TraceIdRatioBasedSampler(options.traceSampleRate ?? 0.01)
237
+ }),
238
+ traceExporter: new OTLPTraceExporter({
239
+ url: `${endpoint}/v1/traces`,
240
+ headers
241
+ }),
242
+ metricReader: new PeriodicExportingMetricReader({
243
+ exporter: new OTLPMetricExporter({
244
+ url: `${endpoint}/v1/metrics`,
245
+ headers
246
+ }),
247
+ exportIntervalMillis: options.metricIntervalMs ?? 6e4
248
+ }),
249
+ instrumentations: [
250
+ new HttpInstrumentation(),
251
+ ...options.instrumentations ?? []
252
+ ]
253
+ });
254
+ sdk.start();
255
+ const errorProvider = new BasicTracerProvider({
256
+ resource,
257
+ sampler: new AlwaysOnSampler(),
258
+ spanProcessors: [
259
+ new BatchSpanProcessor(
260
+ new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }),
261
+ { scheduledDelayMillis: 2e3 }
262
+ )
263
+ ]
264
+ });
265
+ const errorTracer = errorProvider.getTracer("autter-errors");
266
+ function captureException2(error, attributes) {
267
+ const isError = error instanceof Error;
268
+ const message = isError ? error.message : String(error);
269
+ const span = errorTracer.startSpan(isError ? error.name : "Error", {
270
+ attributes: { "autter.severity": "error", ...attributes }
271
+ });
272
+ if (isError) {
273
+ span.recordException(error);
274
+ } else {
275
+ span.addEvent("exception", {
276
+ "exception.type": "Error",
277
+ "exception.message": message
278
+ });
279
+ }
280
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
281
+ span.end();
282
+ }
283
+ function captureMessage2(message, severity = "warning", attributes) {
284
+ const stack = new Error().stack?.split("\n").filter((line, i) => i === 0 || !line.includes("captureMessage")).join("\n");
285
+ const span = errorTracer.startSpan("Message", {
286
+ attributes: { "autter.severity": severity, ...attributes }
287
+ });
288
+ span.addEvent("exception", {
289
+ "exception.type": "Message",
290
+ "exception.message": message,
291
+ ...stack ? { "exception.stacktrace": stack } : {},
292
+ "autter.severity": severity
293
+ });
294
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
295
+ span.end();
296
+ }
297
+ if (options.captureGlobalErrors !== false) {
298
+ process.on("uncaughtExceptionMonitor", (err) => {
299
+ captureException2(err, { "autter.unhandled": true });
300
+ void errorProvider.forceFlush().catch(() => {
301
+ });
302
+ });
303
+ }
304
+ const server = {
305
+ captureException: captureException2,
306
+ captureMessage: captureMessage2,
307
+ shutdown: async () => {
308
+ active = null;
309
+ await Promise.allSettled([errorProvider.shutdown(), sdk.shutdown()]);
310
+ }
311
+ };
312
+ active = server;
313
+ return server;
314
+ }
315
+ function captureException(error, attributes) {
316
+ if (active) {
317
+ active.captureException(error, attributes);
318
+ return;
319
+ }
320
+ const span = trace.getTracer("autter-errors").startSpan(error instanceof Error ? error.name : "Error", { attributes });
321
+ if (error instanceof Error) span.recordException(error);
322
+ span.setStatus({
323
+ code: SpanStatusCode.ERROR,
324
+ message: error instanceof Error ? error.message : String(error)
325
+ });
326
+ span.end();
327
+ }
328
+ function captureMessage(message, severity = "warning", attributes) {
329
+ if (active) {
330
+ active.captureMessage(message, severity, attributes);
331
+ return;
332
+ }
333
+ const span = trace.getTracer("autter-errors").startSpan("Message", {
334
+ attributes: { "autter.severity": severity, ...attributes }
335
+ });
336
+ span.addEvent("exception", {
337
+ "exception.type": "Message",
338
+ "exception.message": message,
339
+ "autter.severity": severity
340
+ });
341
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
342
+ span.end();
343
+ }
344
+ export {
345
+ captureException,
346
+ captureMessage,
347
+ createBrowserRelayFetchHandler,
348
+ createBrowserRelayHandler,
349
+ initAutterServer,
350
+ sanitizeBrowserPayload
351
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@autter/runtime-node",
3
+ "version": "0.1.0",
4
+ "description": "Autter Runtime for Node.js: same-origin browser relay handler + curated OpenTelemetry server tracker",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/Autter-dev/autter-runtime.git",
23
+ "directory": "packages/runtime-node"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup src/index.ts --format esm,cjs --dts --target node20 --clean"
27
+ },
28
+ "dependencies": {
29
+ "@opentelemetry/api": "^1.9.0",
30
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.57.1",
31
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.1",
32
+ "@opentelemetry/instrumentation-http": "^0.57.1",
33
+ "@opentelemetry/resources": "^1.30.1",
34
+ "@opentelemetry/sdk-metrics": "^1.30.1",
35
+ "@opentelemetry/sdk-node": "^0.57.1",
36
+ "@opentelemetry/sdk-trace-base": "^1.30.1",
37
+ "@opentelemetry/semantic-conventions": "^1.28.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.10.0",
41
+ "tsup": "^8.3.5",
42
+ "typescript": "^5.7.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "provenance": true
50
+ },
51
+ "homepage": "https://github.com/Autter-dev/autter-runtime#readme",
52
+ "bugs": "https://github.com/Autter-dev/autter-runtime/issues",
53
+ "keywords": [
54
+ "opentelemetry",
55
+ "otlp",
56
+ "error-tracking",
57
+ "telemetry",
58
+ "nodejs",
59
+ "express"
60
+ ]
61
+ }