@autter/otlp-ingester 1.7.0 → 1.9.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 +42 -7
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +27 -7
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +41 -1
- package/dist/compat-manifest.json +35 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +8 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.js +49 -10
- package/dist/index.d.ts +4 -1
- package/dist/index.js +14 -2
- package/dist/ingest-drops.d.ts +329 -0
- package/dist/ingest-drops.js +454 -0
- package/dist/log-promotion.d.ts +4 -3
- package/dist/log-promotion.js +5 -5
- package/dist/logs.d.ts +16 -1
- package/dist/logs.js +17 -3
- package/dist/loop-collapse.d.ts +144 -0
- package/dist/loop-collapse.js +326 -0
- package/dist/memory.d.ts +9 -1
- package/dist/memory.js +28 -7
- package/dist/migrations.d.ts +20 -0
- package/dist/migrations.js +46 -1
- package/dist/normalize-browser.d.ts +47 -19
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.js +165 -45
- package/dist/otlp-proto.d.ts +14 -0
- package/dist/otlp-proto.js +25 -0
- package/dist/profiles.d.ts +4 -0
- package/dist/profiles.js +6 -2
- package/dist/request-spans.d.ts +45 -0
- package/dist/request-spans.js +91 -0
- package/dist/sdk-versions.d.ts +39 -2
- package/dist/sdk-versions.js +49 -8
- package/dist/server.d.ts +6 -0
- package/dist/server.js +367 -105
- package/dist/sink.d.ts +19 -2
- package/dist/sink.js +23 -3
- package/dist/stack-frames.d.ts +5 -2
- package/dist/stack-frames.js +55 -9
- package/dist/suppression.d.ts +79 -0
- package/dist/suppression.js +203 -0
- package/dist/symbolicate.d.ts +13 -0
- package/dist/symbolicate.js +22 -0
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { KeyResolver, RateLimiter } from "./auth.js";
|
|
3
|
+
import { countTruncations } from "./context.js";
|
|
4
|
+
import { autterKeyHash, cumulativeHttpHistogramPoints, dataPointsInRequest, dropRoute, IngestDropCounter, PARTIAL_MESSAGES, partialSuccess, recordsInBody, spansInRequest, } from "./ingest-drops.js";
|
|
3
5
|
import { ClickHouseStore } from "./clickhouse.js";
|
|
4
6
|
import { normalizeLatencyHistograms } from "./latency.js";
|
|
5
7
|
import { deriveFields, occurrenceIdFor } from "./fingerprint.js";
|
|
@@ -7,18 +9,20 @@ import { occurrenceIdentity } from "./fingerprint-v3.js";
|
|
|
7
9
|
import { parseStackFrames } from "./stack-frames.js";
|
|
8
10
|
import { SourceMapCache, Symbolicator } from "./symbolicate.js";
|
|
9
11
|
import { logPromotionCandidates, promotionLookups, promotionRollups, } from "./log-promotion.js";
|
|
10
|
-
import {
|
|
12
|
+
import { normalizeBrowserPayload, parseBrowserPayload, } from "./normalize-browser.js";
|
|
11
13
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
12
|
-
import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./otlp-proto.js";
|
|
13
|
-
import { normalizeLogs } from "./logs.js";
|
|
14
|
+
import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest, encodeExportResponse } from "./otlp-proto.js";
|
|
15
|
+
import { MAX_LOG_RECORDS_PER_REQUEST, normalizeLogs } from "./logs.js";
|
|
14
16
|
import { decodeProfile } from "./profiles.js";
|
|
15
|
-
import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
|
|
17
|
+
import { MAX_MEMORY_SAMPLES_PER_REQUEST, normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema, } from "./memory.js";
|
|
16
18
|
import { SOURCE_MAP_MAX_BODY_BYTES, validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
|
|
17
19
|
import { SinkForwarder } from "./sink.js";
|
|
20
|
+
import { LoopCollapser } from "./loop-collapse.js";
|
|
21
|
+
import { applySuppression } from "./suppression.js";
|
|
18
22
|
import { configureRedaction, scrubOccurrence } from "./redact.js";
|
|
19
23
|
import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
|
|
20
24
|
import { MIGRATIONS } from "./migrations.js";
|
|
21
|
-
import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, } from "./sdk-versions.js";
|
|
25
|
+
import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, telemetryVia, } from "./sdk-versions.js";
|
|
22
26
|
/** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
|
|
23
27
|
export const UPLOAD_KEY_ROUTES = ["/v1/sourcemaps", "/v1/sourcemaps/check"];
|
|
24
28
|
/** The 403 an upload key gets on every other authenticated route. */
|
|
@@ -27,6 +31,14 @@ export const UPLOAD_KEY_NOT_ALLOWED = {
|
|
|
27
31
|
detail: "autter_rtu_ keys can only upload source maps (POST /v1/sourcemaps and /v1/sourcemaps/check); " +
|
|
28
32
|
"telemetry needs a server key (autter_rt_…), or a client key (autter_rtc_…) for /v1/browser",
|
|
29
33
|
};
|
|
34
|
+
/** Express 4 drops a rejected handler promise: hand it to the error handler instead of crashing the process. */
|
|
35
|
+
function asyncRoute(handler) {
|
|
36
|
+
return (req, res, next) => {
|
|
37
|
+
handler(req, res, next).catch(next);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Spans one traces request may carry (normalizeTraces' MAX_SPANS_PER_REQUEST). */
|
|
41
|
+
const SPANS_PER_REQUEST = 5000;
|
|
30
42
|
/** Re-report the ingester version at most this often (keeps the row inside
|
|
31
43
|
* its TTL for long-running ingesters). */
|
|
32
44
|
const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -48,6 +60,59 @@ export function createIngesterApp(config) {
|
|
|
48
60
|
const promotionLookupLimiter = new RateLimiter(config.promotionLookupsPerMinute ?? 30);
|
|
49
61
|
const version = ingesterVersion();
|
|
50
62
|
const sdkVersions = new SdkVersionTracker((ctx, rows) => store.insertSdkVersions(ctx, rows));
|
|
63
|
+
// What was refused or not stored, per tenant and minute (EI-27). Written
|
|
64
|
+
// in batches every few seconds, bounded, never retried: it can't slow
|
|
65
|
+
// or fail ingest. Without ClickHouse there is nowhere to count.
|
|
66
|
+
const drops = new IngestDropCounter((rows) => store.insertIngestDrops(rows), { enabled: store.configured });
|
|
67
|
+
drops.start();
|
|
68
|
+
if (sink) {
|
|
69
|
+
sink.onDrop = (drop) => drops.record({
|
|
70
|
+
tenant: { orgId: drop.orgId, repositoryId: drop.repositoryId },
|
|
71
|
+
route: "sink",
|
|
72
|
+
reason: drop.reason,
|
|
73
|
+
records: drop.occurrences,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Loop collapse (1.9.0, loop-collapse.ts): a looping error past its first
|
|
77
|
+
// LOOP_COLLAPSE_AFTER occurrences of the minute is stored as one sample a
|
|
78
|
+
// minute with an exact count. The minute's samples are written here, then
|
|
79
|
+
// forwarded; a failed write is kept and retried by the collapser.
|
|
80
|
+
const collapse = new LoopCollapser(async (ctx, samples) => {
|
|
81
|
+
const occurrences = samples.map((entry) => entry.sample);
|
|
82
|
+
await store.insertOccurrences(ctx, occurrences);
|
|
83
|
+
sink?.enqueue(ctx, occurrences);
|
|
84
|
+
for (const entry of samples) {
|
|
85
|
+
// Payloads not stored: every folded occurrence but the sample itself.
|
|
86
|
+
if (entry.folded > 1) {
|
|
87
|
+
drops.record({ tenant: ctx, route: entry.route, reason: "collapsed", requests: 0, records: entry.folded - 1 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}, { after: config.loopCollapseAfter ?? 10 });
|
|
91
|
+
if (store.configured)
|
|
92
|
+
collapse.start();
|
|
93
|
+
/**
|
|
94
|
+
* Volume control on a request's fingerprinted occurrences (1.9.0): the
|
|
95
|
+
* tenant's suppression rules first, then loop collapse. `kept` is what to
|
|
96
|
+
* store and forward now. `commit()` once the request's writes succeeded
|
|
97
|
+
* (folds and suppressions are counted then), `rollback()` when they
|
|
98
|
+
* failed (the exporter retries, and must find the same windows).
|
|
99
|
+
*/
|
|
100
|
+
function admit(ctx, req, occurrences) {
|
|
101
|
+
const route = dropRoute(req.path);
|
|
102
|
+
const { kept, suppressed } = applySuppression(occurrences, ctx.suppression);
|
|
103
|
+
const plan = collapse.plan(ctx, route, kept);
|
|
104
|
+
return {
|
|
105
|
+
kept: plan.kept,
|
|
106
|
+
commit() {
|
|
107
|
+
plan.commit();
|
|
108
|
+
if (suppressed > 0)
|
|
109
|
+
drops.record({ tenant: ctx, route, reason: "suppressed", requests: 0, records: suppressed });
|
|
110
|
+
},
|
|
111
|
+
rollback() {
|
|
112
|
+
plan.rollback();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
51
116
|
const allMigrationIds = MIGRATIONS.map((migration) => migration.id);
|
|
52
117
|
const compatReport = () => {
|
|
53
118
|
const schema = store.schemaStatus();
|
|
@@ -79,10 +144,54 @@ export function createIngesterApp(config) {
|
|
|
79
144
|
}
|
|
80
145
|
}
|
|
81
146
|
/** Fire-and-forget after a successful ingest: never delays or fails it. */
|
|
82
|
-
function recordSdks(ctx, request) {
|
|
83
|
-
void sdkVersions.observe(ctx, sdkSightingsFromOtlp(request));
|
|
147
|
+
function recordSdks(ctx, request, req) {
|
|
148
|
+
void sdkVersions.observe(ctx, sdkSightingsFromOtlp(request, { userAgent: req.headers["user-agent"] ?? null }));
|
|
84
149
|
void reportIngesterInfo();
|
|
85
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Answer a refusal and count it. The response is written first; then the
|
|
153
|
+
* records in the body are counted (a walk of the parsed JSON, or a
|
|
154
|
+
* wire-format scan of protobuf: never a decode).
|
|
155
|
+
*/
|
|
156
|
+
function refuse(req, res, who, reason, status, body, records) {
|
|
157
|
+
res.status(status).json(body);
|
|
158
|
+
const route = dropRoute(req.path);
|
|
159
|
+
drops.record({
|
|
160
|
+
tenant: who && "orgId" in who ? who : null,
|
|
161
|
+
...(who && "keyHash" in who ? { keyHash: who.keyHash } : {}),
|
|
162
|
+
route,
|
|
163
|
+
reason,
|
|
164
|
+
status,
|
|
165
|
+
records: records ?? recordsInBody(route, req.body, Boolean(req.is("application/x-protobuf"))),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Count a refusal made before any route ran (body-parser errors, unknown
|
|
170
|
+
* routes): resolve the key in the background to find the tenant. Never
|
|
171
|
+
* delays the response, never throws.
|
|
172
|
+
*/
|
|
173
|
+
function countUnrouted(req, reason, status) {
|
|
174
|
+
if (!store.configured)
|
|
175
|
+
return;
|
|
176
|
+
const route = dropRoute(req.path);
|
|
177
|
+
const records = recordsInBody(route, req.body, Boolean(req.is("application/x-protobuf")));
|
|
178
|
+
const key = keys.extractKey(req);
|
|
179
|
+
if (!key) {
|
|
180
|
+
drops.record({ tenant: null, route, reason, status, records });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
keys
|
|
184
|
+
.resolveDetailed(key)
|
|
185
|
+
.then((resolution) => drops.record({
|
|
186
|
+
tenant: resolution.ctx,
|
|
187
|
+
...(resolution.ctx ? {} : { keyHash: autterKeyHash(key) }),
|
|
188
|
+
route,
|
|
189
|
+
reason,
|
|
190
|
+
status,
|
|
191
|
+
records,
|
|
192
|
+
}))
|
|
193
|
+
.catch(() => { });
|
|
194
|
+
}
|
|
86
195
|
const app = express();
|
|
87
196
|
app.disable("x-powered-by");
|
|
88
197
|
// Every response names the ingester version, so SDKs (and the browser
|
|
@@ -100,32 +209,36 @@ export function createIngesterApp(config) {
|
|
|
100
209
|
// parse one. body-parser inflates gzip/deflate and applies the limit to
|
|
101
210
|
// the inflated bytes, so a small compressed body can't expand past it.
|
|
102
211
|
const sourceMapBody = express.json({ limit: SOURCE_MAP_MAX_BODY_BYTES });
|
|
103
|
-
app.post("/v1/sourcemaps", async (req, res, next) => {
|
|
212
|
+
app.post("/v1/sourcemaps", asyncRoute(async (req, res, next) => {
|
|
104
213
|
// On failure the body is never read (Node discards it).
|
|
105
214
|
const ctx = await authenticate(req, res, "sourcemaps");
|
|
106
215
|
if (!ctx)
|
|
107
216
|
return;
|
|
108
|
-
sourceMapBody(req, res,
|
|
217
|
+
sourceMapBody(req, res, (err) => {
|
|
109
218
|
if (err) {
|
|
219
|
+
// 413 / bad JSON: the error handler answers and counts it.
|
|
110
220
|
next(err);
|
|
111
221
|
return;
|
|
112
222
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
223
|
+
void (async () => {
|
|
224
|
+
const sourceMap = validateSourceMap(req.body);
|
|
225
|
+
if (!sourceMap) {
|
|
226
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid source map" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Idempotent: the same release + path replaces the earlier row
|
|
230
|
+
// (ReplacingMergeTree), so CI retries and re-runs are harmless.
|
|
231
|
+
try {
|
|
232
|
+
await store.insertSourceMap(ctx, sourceMap);
|
|
233
|
+
}
|
|
234
|
+
catch (storeErr) {
|
|
235
|
+
storageError(req, res, ctx, storeErr);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
122
238
|
res.status(202).json({ accepted: true });
|
|
123
|
-
}
|
|
124
|
-
catch (storeErr) {
|
|
125
|
-
storageError(res, storeErr);
|
|
126
|
-
}
|
|
239
|
+
})().catch(next);
|
|
127
240
|
});
|
|
128
|
-
});
|
|
241
|
+
}));
|
|
129
242
|
app.use(express.json({
|
|
130
243
|
limit: config.maxBodyBytes,
|
|
131
244
|
type: ["application/json"],
|
|
@@ -160,7 +273,11 @@ export function createIngesterApp(config) {
|
|
|
160
273
|
next();
|
|
161
274
|
});
|
|
162
275
|
app.get("/healthz", async (_req, res) => {
|
|
163
|
-
const sinkStats =
|
|
276
|
+
const sinkStats = {
|
|
277
|
+
...(sink ? { sink: sink.stats() } : {}),
|
|
278
|
+
// Counters only (no tenant data): /healthz is public.
|
|
279
|
+
...(collapse.enabled ? { collapse: collapse.stats() } : {}),
|
|
280
|
+
};
|
|
164
281
|
if (!store.configured) {
|
|
165
282
|
res.status(200).json({ ok: true, clickhouse: "unconfigured", ...sinkStats });
|
|
166
283
|
return;
|
|
@@ -213,26 +330,30 @@ export function createIngesterApp(config) {
|
|
|
213
330
|
*/
|
|
214
331
|
async function authenticate(req, res, surface) {
|
|
215
332
|
// A storage-less ingester must refuse, not accept-and-drop: exporters
|
|
216
|
-
// retry on 503, so telemetry survives a misconfigured deploy.
|
|
333
|
+
// retry on 503, so telemetry survives a misconfigured deploy. Not
|
|
334
|
+
// counted: without ClickHouse there is nowhere to count it.
|
|
217
335
|
if (!store.configured) {
|
|
218
336
|
res.status(503).json({ error: "storage not configured" });
|
|
219
337
|
return null;
|
|
220
338
|
}
|
|
221
339
|
const key = keys.extractKey(req);
|
|
222
340
|
if (!key) {
|
|
223
|
-
res
|
|
341
|
+
refuse(req, res, null, "missing_key", 401, { error: "missing ingest key" });
|
|
224
342
|
return null;
|
|
225
343
|
}
|
|
226
|
-
const
|
|
344
|
+
const resolution = await keys.resolveDetailed(key);
|
|
345
|
+
const ctx = resolution.ctx;
|
|
227
346
|
if (!ctx) {
|
|
228
|
-
|
|
347
|
+
// No tenant: counted against the null tenant, with the key's hash
|
|
348
|
+
// when it is an Autter key (ingest-drops.ts autterKeyHash).
|
|
349
|
+
refuse(req, res, { keyHash: autterKeyHash(key) }, resolution.failure, 401, { error: "invalid ingest key" });
|
|
229
350
|
return null;
|
|
230
351
|
}
|
|
231
352
|
if (ctx.scope === "upload") {
|
|
232
353
|
// CI keys: source map upload and check only. Server rate window
|
|
233
354
|
// (each key has its own).
|
|
234
355
|
if (surface !== "sourcemaps") {
|
|
235
|
-
res
|
|
356
|
+
refuse(req, res, ctx, "upload_key_not_allowed", 403, UPLOAD_KEY_NOT_ALLOWED);
|
|
236
357
|
return null;
|
|
237
358
|
}
|
|
238
359
|
}
|
|
@@ -240,7 +361,7 @@ export function createIngesterApp(config) {
|
|
|
240
361
|
// Publishable keys: browser surface only, origin allow-list, and
|
|
241
362
|
// the tighter rate window.
|
|
242
363
|
if (surface !== "browser") {
|
|
243
|
-
res
|
|
364
|
+
refuse(req, res, ctx, "client_key_not_allowed", 403, {
|
|
244
365
|
error: surface === "sourcemaps"
|
|
245
366
|
? "client keys cannot upload source maps — use an upload key (autter_rtu_…) or a server key"
|
|
246
367
|
: "client keys cannot send OTLP — use a server key",
|
|
@@ -250,11 +371,11 @@ export function createIngesterApp(config) {
|
|
|
250
371
|
const origin = req.headers.origin;
|
|
251
372
|
if (ctx.allowedOrigins.length > 0 &&
|
|
252
373
|
(!origin || !ctx.allowedOrigins.includes(origin))) {
|
|
253
|
-
res
|
|
374
|
+
refuse(req, res, ctx, "origin_not_allowed", 403, { error: "origin not allowed for this key" });
|
|
254
375
|
return null;
|
|
255
376
|
}
|
|
256
377
|
if (!clientRateLimiter.allow(key)) {
|
|
257
|
-
res
|
|
378
|
+
refuse(req, res, ctx, "rate_limited", 429, { error: "rate limit exceeded" });
|
|
258
379
|
return null;
|
|
259
380
|
}
|
|
260
381
|
return ctx;
|
|
@@ -262,11 +383,11 @@ export function createIngesterApp(config) {
|
|
|
262
383
|
else if (ctx.scope !== "server") {
|
|
263
384
|
// KeyResolver never yields another scope; refuse rather than
|
|
264
385
|
// treat an unknown one as a server key.
|
|
265
|
-
res
|
|
386
|
+
refuse(req, res, ctx, "key_scope_not_allowed", 403, { error: "key scope not allowed" });
|
|
266
387
|
return null;
|
|
267
388
|
}
|
|
268
389
|
if (!serverRateLimiter.allow(key)) {
|
|
269
|
-
res
|
|
390
|
+
refuse(req, res, ctx, "rate_limited", 429, { error: "rate limit exceeded" });
|
|
270
391
|
return null;
|
|
271
392
|
}
|
|
272
393
|
return ctx;
|
|
@@ -360,25 +481,51 @@ export function createIngesterApp(config) {
|
|
|
360
481
|
}
|
|
361
482
|
return fingerprinted.filter((o) => !o.traceId || !seen.has(o.traceId));
|
|
362
483
|
}
|
|
363
|
-
function storageError(res, err) {
|
|
484
|
+
function storageError(req, res, ctx, err, records) {
|
|
364
485
|
console.error("clickhouse write failed:", err);
|
|
365
|
-
res
|
|
486
|
+
refuse(req, res, ctx, "storage_unavailable", 503, { error: "storage unavailable, retry later" }, records);
|
|
366
487
|
}
|
|
367
|
-
/**
|
|
368
|
-
* protobuf
|
|
369
|
-
|
|
488
|
+
/**
|
|
489
|
+
* OTLP/HTTP is JSON or protobuf; anything else gets a 415 (OTLP spec)
|
|
490
|
+
* instead of an empty success that stores nothing. A request with no
|
|
491
|
+
* body at all is an empty export, not a wrong type.
|
|
492
|
+
*/
|
|
493
|
+
function unsupportedOtlpBody(req) {
|
|
494
|
+
const match = req.is(["application/json", "application/x-protobuf"]);
|
|
495
|
+
return match === false && req.headers["content-length"] !== "0";
|
|
496
|
+
}
|
|
497
|
+
const UNSUPPORTED_OTLP = { error: "expected application/json or application/x-protobuf" };
|
|
498
|
+
/** Count what an accepted request did not store, or stored cut. */
|
|
499
|
+
function countAccepted(ctx, req, parts) {
|
|
500
|
+
const route = dropRoute(req.path);
|
|
501
|
+
for (const part of parts) {
|
|
502
|
+
if (part.records > 0)
|
|
503
|
+
drops.record({ tenant: ctx, route, reason: part.reason, records: part.records });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* OTLP success responses mirror the request encoding: protobuf for proto
|
|
508
|
+
* clients (an empty message when everything was accepted), JSON
|
|
509
|
+
* otherwise. When some records were not stored, `partial_success` names
|
|
510
|
+
* how many and why (OTLP spec), so collectors and SDKs log it.
|
|
511
|
+
*/
|
|
512
|
+
function otlpSuccess(req, res, signal, partial) {
|
|
370
513
|
if (req.is("application/x-protobuf")) {
|
|
371
|
-
res.status(200).type("application/x-protobuf").end();
|
|
514
|
+
res.status(200).type("application/x-protobuf").end(Buffer.from(encodeExportResponse(partial)));
|
|
372
515
|
return;
|
|
373
516
|
}
|
|
374
|
-
|
|
517
|
+
const rejectedField = signal === "traces" ? "rejectedSpans" : signal === "logs" ? "rejectedLogRecords" : "rejectedDataPoints";
|
|
518
|
+
// int64 is a decimal string in proto3 JSON; OTLP decoders take both.
|
|
519
|
+
res.status(200).json({
|
|
520
|
+
partialSuccess: partial ? { [rejectedField]: String(partial.rejected), errorMessage: partial.errorMessage } : {},
|
|
521
|
+
});
|
|
375
522
|
}
|
|
376
|
-
app.post("/v1/profiles", async (req, res) => {
|
|
523
|
+
app.post("/v1/profiles", asyncRoute(async (req, res) => {
|
|
377
524
|
const ctx = await authenticate(req, res, "otlp");
|
|
378
525
|
if (!ctx)
|
|
379
526
|
return;
|
|
380
527
|
if (!req.is("application/x-pprof") || !Buffer.isBuffer(req.body)) {
|
|
381
|
-
res
|
|
528
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, { error: "expected application/x-pprof" });
|
|
382
529
|
return;
|
|
383
530
|
}
|
|
384
531
|
const service = req.header("x-autter-service")?.trim() ?? "";
|
|
@@ -387,87 +534,112 @@ export function createIngesterApp(config) {
|
|
|
387
534
|
const traceId = req.header("x-autter-trace-id")?.trim() ?? "";
|
|
388
535
|
const instanceId = req.header("x-autter-instance-id")?.trim() ?? "";
|
|
389
536
|
if (!/^[a-zA-Z0-9._-]{1,200}$/.test(service) || environment.length > 100 || release.length > 200 || instanceId.length > 128 || (traceId && !/^[a-f0-9]{32}$/.test(traceId))) {
|
|
390
|
-
res
|
|
537
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid profile metadata" });
|
|
391
538
|
return;
|
|
392
539
|
}
|
|
393
540
|
let samples;
|
|
541
|
+
const stats = { overLimit: 0 };
|
|
394
542
|
try {
|
|
395
|
-
samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId });
|
|
543
|
+
samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId }, stats);
|
|
396
544
|
if (!samples.length)
|
|
397
545
|
throw new Error("empty profile");
|
|
398
546
|
}
|
|
399
547
|
catch {
|
|
400
|
-
res
|
|
548
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid or unsymbolized profile" });
|
|
401
549
|
return;
|
|
402
550
|
}
|
|
403
551
|
try {
|
|
404
552
|
await store.insertProfileSamples(ctx, samples);
|
|
405
|
-
res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
|
|
406
553
|
}
|
|
407
554
|
catch (err) {
|
|
408
|
-
storageError(res, err);
|
|
555
|
+
storageError(req, res, ctx, err, samples.length + stats.overLimit);
|
|
556
|
+
return;
|
|
409
557
|
}
|
|
410
|
-
|
|
558
|
+
res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
|
|
559
|
+
countAccepted(ctx, req, [{ reason: "profile_samples_over_limit", records: stats.overLimit }]);
|
|
560
|
+
}));
|
|
411
561
|
/** Which of these maps does this tenant already have? Lets
|
|
412
562
|
* `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
|
|
413
|
-
app.post("/v1/sourcemaps/check", async (req, res) => {
|
|
563
|
+
app.post("/v1/sourcemaps/check", asyncRoute(async (req, res) => {
|
|
414
564
|
const ctx = await authenticate(req, res, "sourcemaps");
|
|
415
565
|
if (!ctx)
|
|
416
566
|
return;
|
|
417
567
|
const check = validateSourceMapCheck(req.body);
|
|
418
568
|
if (!check) {
|
|
419
|
-
res
|
|
569
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid source map check" });
|
|
420
570
|
return;
|
|
421
571
|
}
|
|
572
|
+
let present;
|
|
422
573
|
try {
|
|
423
|
-
|
|
574
|
+
present = await store.sourceMapsPresent(ctx, check);
|
|
424
575
|
}
|
|
425
576
|
catch (err) {
|
|
426
|
-
storageError(res, err);
|
|
577
|
+
storageError(req, res, ctx, err);
|
|
578
|
+
return;
|
|
427
579
|
}
|
|
428
|
-
|
|
580
|
+
res.status(200).json({ present });
|
|
581
|
+
}));
|
|
429
582
|
/** ECS/Kubernetes event forwarders use the same server key as OTLP. */
|
|
430
|
-
app.post("/v1/platform-events", async (req, res) => {
|
|
583
|
+
app.post("/v1/platform-events", asyncRoute(async (req, res) => {
|
|
431
584
|
const ctx = await authenticate(req, res, "otlp");
|
|
432
585
|
if (!ctx)
|
|
433
586
|
return;
|
|
434
587
|
const parsed = platformEventSchema.safeParse(req.body);
|
|
435
588
|
if (!parsed.success) {
|
|
436
|
-
res
|
|
589
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid platform event" });
|
|
437
590
|
return;
|
|
438
591
|
}
|
|
439
592
|
const event = normalizePlatformEvent(parsed.data);
|
|
440
593
|
if (!event) {
|
|
441
|
-
res
|
|
594
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "event timestamp outside retention window" });
|
|
442
595
|
return;
|
|
443
596
|
}
|
|
444
597
|
try {
|
|
445
598
|
await store.insertPlatformEvent(ctx, event);
|
|
446
|
-
res.status(202).json({ accepted: true });
|
|
447
599
|
}
|
|
448
600
|
catch (err) {
|
|
449
|
-
storageError(res, err);
|
|
601
|
+
storageError(req, res, ctx, err);
|
|
602
|
+
return;
|
|
450
603
|
}
|
|
451
|
-
|
|
452
|
-
|
|
604
|
+
res.status(202).json({ accepted: true });
|
|
605
|
+
}));
|
|
606
|
+
app.post("/v1/traces", asyncRoute(async (req, res) => {
|
|
453
607
|
const ctx = await authenticate(req, res, "otlp");
|
|
454
608
|
if (!ctx)
|
|
455
609
|
return;
|
|
610
|
+
if (unsupportedOtlpBody(req)) {
|
|
611
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
456
614
|
let request;
|
|
457
615
|
if (req.is("application/x-protobuf")) {
|
|
458
616
|
try {
|
|
459
617
|
request = decodeTraceRequest(req.body);
|
|
460
618
|
}
|
|
461
619
|
catch {
|
|
462
|
-
res
|
|
620
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid protobuf payload" });
|
|
463
621
|
return;
|
|
464
622
|
}
|
|
465
623
|
}
|
|
466
624
|
else {
|
|
467
625
|
request = req.body;
|
|
468
626
|
}
|
|
469
|
-
const
|
|
627
|
+
const totalSpans = spansInRequest(request);
|
|
628
|
+
let normalized;
|
|
629
|
+
let truncated;
|
|
630
|
+
try {
|
|
631
|
+
({ result: normalized, truncated } = countTruncations(() => normalizeTraces(request)));
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
// A value the normaliser can't read (a timestamp that is not an
|
|
635
|
+
// integer, a list that is not one): before 1.8.0 this threw out of
|
|
636
|
+
// the handler, an unhandled rejection that stopped the process.
|
|
637
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP traces payload" }, totalSpans);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const { occurrences, occurrenceBatchIndexes, spans, metricPoints, llmCalls } = normalized;
|
|
470
641
|
const fingerprinted = await fingerprintAll(ctx, occurrences, occurrenceBatchIndexes);
|
|
642
|
+
const admitted = admit(ctx, req, fingerprinted);
|
|
471
643
|
// ClickHouse has no cross-table transaction, so these four inserts can
|
|
472
644
|
// partially commit. Recovery boundary: any failure → 503 → the exporter
|
|
473
645
|
// retries the whole batch. Deterministic occurrence ids make the retry
|
|
@@ -478,32 +650,48 @@ export function createIngesterApp(config) {
|
|
|
478
650
|
// ClickHouse reconciliation instead.
|
|
479
651
|
try {
|
|
480
652
|
await Promise.all([
|
|
481
|
-
store.insertOccurrences(ctx,
|
|
653
|
+
store.insertOccurrences(ctx, admitted.kept),
|
|
482
654
|
store.insertSpans(ctx, spans),
|
|
483
655
|
store.insertMetricPoints(ctx, metricPoints),
|
|
484
656
|
store.insertLlmCalls(ctx, llmCalls),
|
|
485
657
|
]);
|
|
486
658
|
}
|
|
487
659
|
catch (err) {
|
|
488
|
-
|
|
660
|
+
admitted.rollback();
|
|
661
|
+
storageError(req, res, ctx, err, totalSpans);
|
|
489
662
|
return;
|
|
490
663
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
664
|
+
admitted.commit();
|
|
665
|
+
sink?.enqueue(ctx, admitted.kept, metricPoints, llmCalls);
|
|
666
|
+
recordSdks(ctx, request, req);
|
|
667
|
+
// normalizeTraces stops at SPANS_PER_REQUEST; the rest were not read.
|
|
668
|
+
const overLimit = Math.max(0, totalSpans - normalized.spanCount);
|
|
669
|
+
otlpSuccess(req, res, "traces", partialSuccess([
|
|
670
|
+
{ rejected: overLimit, message: PARTIAL_MESSAGES.spans(overLimit, SPANS_PER_REQUEST) },
|
|
671
|
+
]));
|
|
672
|
+
countAccepted(ctx, req, [
|
|
673
|
+
{ reason: "spans_over_limit", records: overLimit },
|
|
674
|
+
{ reason: "attributes_truncated", records: truncated },
|
|
675
|
+
]);
|
|
676
|
+
}));
|
|
677
|
+
app.post("/v1/logs", asyncRoute(async (req, res) => {
|
|
496
678
|
const ctx = await authenticate(req, res, "otlp");
|
|
497
679
|
if (!ctx)
|
|
498
680
|
return;
|
|
681
|
+
if (unsupportedOtlpBody(req)) {
|
|
682
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
499
685
|
let logs;
|
|
500
686
|
let logsRequest;
|
|
687
|
+
let truncated;
|
|
688
|
+
const stats = { overLimit: 0 };
|
|
501
689
|
try {
|
|
502
690
|
logsRequest = req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body;
|
|
503
|
-
logs = normalizeLogs(logsRequest);
|
|
691
|
+
({ result: logs, truncated } = countTruncations(() => normalizeLogs(logsRequest, stats)));
|
|
504
692
|
}
|
|
505
693
|
catch {
|
|
506
|
-
res
|
|
694
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP logs payload" });
|
|
507
695
|
return;
|
|
508
696
|
}
|
|
509
697
|
// Logs are diagnostic evidence; exceptions and failed outcomes normally
|
|
@@ -511,55 +699,97 @@ export function createIngesterApp(config) {
|
|
|
511
699
|
// edge SDKs mark span-less error records `autter.capture.mode = "log"`,
|
|
512
700
|
// and those are promoted to occurrences here — see log-promotion.ts.
|
|
513
701
|
const promoted = await promoteLogs(ctx, logs);
|
|
702
|
+
// Rollups count every promoted error, collapsed or suppressed ones too.
|
|
514
703
|
const metricPoints = promotionRollups(promoted);
|
|
704
|
+
const admitted = admit(ctx, req, promoted);
|
|
515
705
|
try {
|
|
516
706
|
await Promise.all([
|
|
517
707
|
store.insertLogs(ctx, logs),
|
|
518
|
-
store.insertOccurrences(ctx,
|
|
708
|
+
store.insertOccurrences(ctx, admitted.kept),
|
|
519
709
|
store.insertMetricPoints(ctx, metricPoints),
|
|
520
710
|
]);
|
|
521
711
|
}
|
|
522
712
|
catch (err) {
|
|
523
|
-
|
|
713
|
+
admitted.rollback();
|
|
714
|
+
storageError(req, res, ctx, err, logs.length + stats.overLimit);
|
|
524
715
|
return;
|
|
525
716
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
717
|
+
admitted.commit();
|
|
718
|
+
sink?.enqueue(ctx, admitted.kept, metricPoints);
|
|
719
|
+
recordSdks(ctx, logsRequest, req);
|
|
720
|
+
otlpSuccess(req, res, "logs", partialSuccess([
|
|
721
|
+
{ rejected: stats.overLimit, message: PARTIAL_MESSAGES.logRecords(stats.overLimit, MAX_LOG_RECORDS_PER_REQUEST) },
|
|
722
|
+
]));
|
|
723
|
+
countAccepted(ctx, req, [
|
|
724
|
+
{ reason: "log_records_over_limit", records: stats.overLimit },
|
|
725
|
+
{ reason: "attributes_truncated", records: truncated },
|
|
726
|
+
]);
|
|
727
|
+
}));
|
|
728
|
+
app.post("/v1/metrics", asyncRoute(async (req, res) => {
|
|
531
729
|
const ctx = await authenticate(req, res, "otlp");
|
|
532
730
|
if (!ctx)
|
|
533
731
|
return;
|
|
732
|
+
if (unsupportedOtlpBody(req)) {
|
|
733
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
534
736
|
let request;
|
|
535
737
|
if (req.is("application/x-protobuf")) {
|
|
536
738
|
try {
|
|
537
739
|
request = decodeMetricsRequest(req.body);
|
|
538
740
|
}
|
|
539
741
|
catch {
|
|
540
|
-
res
|
|
742
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid protobuf payload" });
|
|
541
743
|
return;
|
|
542
744
|
}
|
|
543
745
|
}
|
|
544
746
|
else {
|
|
545
747
|
request = req.body;
|
|
546
748
|
}
|
|
547
|
-
const
|
|
548
|
-
|
|
749
|
+
const memoryStats = { invalid: 0, overLimit: 0 };
|
|
750
|
+
let metricPoints;
|
|
751
|
+
let memorySamples;
|
|
752
|
+
let latency;
|
|
753
|
+
let cumulative;
|
|
754
|
+
try {
|
|
755
|
+
metricPoints = normalizeMetrics(request);
|
|
756
|
+
memorySamples = normalizeMemoryMetrics(request, memoryStats);
|
|
757
|
+
latency = normalizeLatencyHistograms(request);
|
|
758
|
+
cumulative = cumulativeHttpHistogramPoints(request);
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP metrics payload" }, dataPointsInRequest(request));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
549
764
|
try {
|
|
550
|
-
await store.insertLatencyHistograms(ctx,
|
|
765
|
+
await store.insertLatencyHistograms(ctx, latency);
|
|
551
766
|
await store.insertMetricPoints(ctx, metricPoints);
|
|
552
767
|
await store.insertMemorySamples(ctx, memorySamples);
|
|
553
768
|
}
|
|
554
769
|
catch (err) {
|
|
555
|
-
storageError(res, err);
|
|
770
|
+
storageError(req, res, ctx, err, dataPointsInRequest(request));
|
|
556
771
|
return;
|
|
557
772
|
}
|
|
558
773
|
sink?.enqueue(ctx, [], metricPoints);
|
|
559
|
-
recordSdks(ctx, request);
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
774
|
+
recordSdks(ctx, request, req);
|
|
775
|
+
// Metrics the ingester has no use for (anything but HTTP server
|
|
776
|
+
// duration histograms and the memory/GC instruments) are ignored,
|
|
777
|
+
// not rejected: they are neither answered nor counted.
|
|
778
|
+
otlpSuccess(req, res, "metrics", partialSuccess([
|
|
779
|
+
{ rejected: cumulative, message: PARTIAL_MESSAGES.cumulative(cumulative) },
|
|
780
|
+
{ rejected: memoryStats.invalid, message: PARTIAL_MESSAGES.memoryInvalid(memoryStats.invalid) },
|
|
781
|
+
{
|
|
782
|
+
rejected: memoryStats.overLimit,
|
|
783
|
+
message: PARTIAL_MESSAGES.memoryOverLimit(memoryStats.overLimit, MAX_MEMORY_SAMPLES_PER_REQUEST),
|
|
784
|
+
},
|
|
785
|
+
]));
|
|
786
|
+
countAccepted(ctx, req, [
|
|
787
|
+
{ reason: "cumulative_histogram", records: cumulative },
|
|
788
|
+
{ reason: "memory_point_invalid", records: memoryStats.invalid },
|
|
789
|
+
{ reason: "memory_points_over_limit", records: memoryStats.overLimit },
|
|
790
|
+
]);
|
|
791
|
+
}));
|
|
792
|
+
app.post("/v1/browser", asyncRoute(async (req, res) => {
|
|
563
793
|
const ctx = await authenticate(req, res, "browser");
|
|
564
794
|
if (!ctx)
|
|
565
795
|
return;
|
|
@@ -570,57 +800,89 @@ export function createIngesterApp(config) {
|
|
|
570
800
|
body = JSON.parse(body);
|
|
571
801
|
}
|
|
572
802
|
catch {
|
|
573
|
-
res
|
|
803
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid json" }, 0);
|
|
574
804
|
return;
|
|
575
805
|
}
|
|
576
806
|
}
|
|
577
|
-
|
|
807
|
+
// The envelope must be valid; each event is validated on its own and
|
|
808
|
+
// an invalid one is dropped and counted (1.8.0), not the whole beacon.
|
|
809
|
+
const parsed = parseBrowserPayload(body);
|
|
578
810
|
if (!parsed.success) {
|
|
579
|
-
res.
|
|
580
|
-
error: "invalid payload",
|
|
581
|
-
issues: parsed.error.issues.slice(0, 5),
|
|
582
|
-
});
|
|
811
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid payload", issues: parsed.issues }, recordsInBody("/v1/browser", body, false));
|
|
583
812
|
return;
|
|
584
813
|
}
|
|
585
|
-
const {
|
|
814
|
+
const { payload, invalidEvents, overLimitEvents, issues } = parsed.data;
|
|
815
|
+
let normalized;
|
|
816
|
+
let truncated;
|
|
817
|
+
try {
|
|
818
|
+
({ result: normalized, truncated } = countTruncations(() => normalizeBrowserPayload(payload)));
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid payload" }, recordsInBody("/v1/browser", body, false));
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const { occurrences, metricPoints } = normalized;
|
|
586
825
|
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
826
|
+
const admitted = admit(ctx, req, fingerprinted);
|
|
587
827
|
try {
|
|
588
828
|
await Promise.all([
|
|
589
|
-
store.insertOccurrences(ctx,
|
|
829
|
+
store.insertOccurrences(ctx, admitted.kept),
|
|
590
830
|
store.insertMetricPoints(ctx, metricPoints),
|
|
591
831
|
]);
|
|
592
832
|
}
|
|
593
833
|
catch (err) {
|
|
594
|
-
|
|
834
|
+
admitted.rollback();
|
|
835
|
+
storageError(req, res, ctx, err, payload.events.length + invalidEvents + overLimitEvents);
|
|
595
836
|
return;
|
|
596
837
|
}
|
|
597
|
-
|
|
598
|
-
|
|
838
|
+
admitted.commit();
|
|
839
|
+
sink?.enqueue(ctx, admitted.kept, metricPoints);
|
|
840
|
+
if (payload.sdk) {
|
|
599
841
|
void sdkVersions.observe(ctx, [{
|
|
600
|
-
service:
|
|
601
|
-
environment:
|
|
842
|
+
service: payload.service,
|
|
843
|
+
environment: payload.environment,
|
|
602
844
|
sdkName: BROWSER_SDK_NAME,
|
|
603
|
-
sdkVersion:
|
|
845
|
+
sdkVersion: payload.sdk,
|
|
604
846
|
sdkLanguage: "webjs",
|
|
847
|
+
via: telemetryVia({ userAgent: req.headers["user-agent"] ?? null, browserKeyScope: ctx.scope }),
|
|
605
848
|
}]);
|
|
606
849
|
}
|
|
607
850
|
void reportIngesterInfo();
|
|
608
|
-
|
|
851
|
+
const rejected = invalidEvents + overLimitEvents;
|
|
852
|
+
res.status(202).json({ accepted: fingerprinted.length, ...(rejected > 0 ? { rejected, issues } : {}) });
|
|
853
|
+
countAccepted(ctx, req, [
|
|
854
|
+
{ reason: "invalid_event", records: invalidEvents },
|
|
855
|
+
{ reason: "events_over_limit", records: overLimitEvents },
|
|
856
|
+
{ reason: "attributes_truncated", records: truncated },
|
|
857
|
+
]);
|
|
858
|
+
}));
|
|
859
|
+
// A POST to a path nobody serves (a collector endpoint with /v1/traces
|
|
860
|
+
// twice, an exporter on the wrong base path): counted, then Express's
|
|
861
|
+
// usual 404.
|
|
862
|
+
app.use((req, _res, next) => {
|
|
863
|
+
if (req.method === "POST")
|
|
864
|
+
countUnrouted(req, "unknown_route", 404);
|
|
865
|
+
next();
|
|
609
866
|
});
|
|
610
867
|
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
611
|
-
|
|
868
|
+
// They happen before any route ran, so the tenant is resolved in the
|
|
869
|
+
// background to count them.
|
|
870
|
+
app.use((err, req, res, next) => {
|
|
612
871
|
if (res.headersSent)
|
|
613
872
|
return next(err);
|
|
614
873
|
if (err.type === "entity.too.large") {
|
|
615
874
|
res.status(413).json({ error: "payload too large" });
|
|
875
|
+
countUnrouted(req, "payload_too_large", 413);
|
|
616
876
|
return;
|
|
617
877
|
}
|
|
618
878
|
if (err.status && err.status < 500) {
|
|
619
879
|
res.status(err.status).json({ error: "bad request" });
|
|
880
|
+
countUnrouted(req, err.status === 415 ? "unsupported_media_type" : err.type === "request.aborted" ? "request_aborted" : "invalid_payload", err.status);
|
|
620
881
|
return;
|
|
621
882
|
}
|
|
622
883
|
console.error("unhandled error:", err);
|
|
623
884
|
res.status(500).json({ error: "internal error" });
|
|
885
|
+
countUnrouted(req, "internal_error", 500);
|
|
624
886
|
});
|
|
625
|
-
return { app, store, sink, reportIngesterInfo };
|
|
887
|
+
return { app, store, sink, reportIngesterInfo, drops, collapse };
|
|
626
888
|
}
|