@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 +94 -0
- package/dist/index.cjs +374 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +351 -0
- package/package.json +61 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|