@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/README.md
CHANGED
|
@@ -57,8 +57,41 @@ AUTTER_INGEST_KEYS='[
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
The validator webhook may return the same extra fields:
|
|
60
|
-
`{ orgId, repositoryId, scope?, allowedOrigins? }`, with
|
|
61
|
-
`server`, `client`, `upload
|
|
60
|
+
`{ orgId, repositoryId, scope?, allowedOrigins?, suppression? }`, with
|
|
61
|
+
`scope` one of `server`, `client`, `upload`, and `suppression` the
|
|
62
|
+
repository's suppression rules (1.9.0, below); a static entry takes
|
|
63
|
+
`"suppression": [ … ]` too.
|
|
64
|
+
|
|
65
|
+
### Volume control (1.9.0)
|
|
66
|
+
|
|
67
|
+
- **Loop collapse.** Per tenant and identity (fingerprints, service,
|
|
68
|
+
environment, release, source, severity, error type and code, normalised
|
|
69
|
+
message and top frames, session), the first `LOOP_COLLAPSE_AFTER`
|
|
70
|
+
occurrences of a minute are stored as usual; the rest of the minute is
|
|
71
|
+
stored as ONE sample whose `collapsed_count` makes the count exact, and
|
|
72
|
+
forwarded to the sink with `count`. A loop of 10,000 a minute is 11 rows,
|
|
73
|
+
not 10,000. Shutdown stores the minute in progress.
|
|
74
|
+
- **Suppression rules** set per repository in Autter: `drop` or `sample`
|
|
75
|
+
(keep `sampleRate`, never a fatal one) the occurrences whose error type,
|
|
76
|
+
message, service, environment, route, release or source match. Delivered
|
|
77
|
+
with the key, applied before anything is stored.
|
|
78
|
+
|
|
79
|
+
Folded and suppressed occurrences are counted in `runtime_ingest_drops`
|
|
80
|
+
(`collapsed`, `suppressed`); request and error-rate rollups still count
|
|
81
|
+
every error. Details: [docs/ARCHITECTURE.md § Volume control](../../docs/ARCHITECTURE.md#volume-control).
|
|
82
|
+
|
|
83
|
+
### Rejections and drops (1.8.0)
|
|
84
|
+
|
|
85
|
+
Everything the ingester refuses (401, 403, 404, 413, 415, 429, 400, 503) or
|
|
86
|
+
does not store from an accepted request (spans past 5,000, log records past
|
|
87
|
+
2,000, cumulative HTTP histograms, invalid browser events, …) is counted per
|
|
88
|
+
repository, minute, route and reason in `runtime_ingest_drops`; keys that
|
|
89
|
+
don't resolve count against an empty tenant with the sha256 of the key when
|
|
90
|
+
it is an Autter key. OTLP answers name dropped records in `partial_success`
|
|
91
|
+
(JSON and protobuf), so collectors log them. Counts are written every 5 s in
|
|
92
|
+
one batch, bounded in memory, and a failed write is logged and dropped:
|
|
93
|
+
counting never slows or fails ingest. Reasons and queries:
|
|
94
|
+
[docs/ARCHITECTURE.md § Rejections and drops](../../docs/ARCHITECTURE.md#rejections-and-drops).
|
|
62
95
|
|
|
63
96
|
### Version compatibility
|
|
64
97
|
|
|
@@ -67,9 +100,10 @@ its ClickHouse schema state, and which features from
|
|
|
67
100
|
`src/compat-manifest.json` it supports. Add
|
|
68
101
|
`?features=operation_logging&sdk=@autter/runtime-node@1.3.0` to get the evaluated
|
|
69
102
|
incompatibilities. Every response carries `x-autter-ingester-version`. The
|
|
70
|
-
ingester records the SDK name and version each service sends with
|
|
71
|
-
|
|
72
|
-
|
|
103
|
+
ingester records the SDK name and version each service sends with, and
|
|
104
|
+
whether it came through an OpenTelemetry Collector (`runtime_sdk_versions`,
|
|
105
|
+
`via` from 1.8.0), and its own version (`runtime_ingester_info`), and the
|
|
106
|
+
Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILITY.md).
|
|
73
107
|
|
|
74
108
|
## Configuration
|
|
75
109
|
|
|
@@ -79,8 +113,8 @@ the Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIB
|
|
|
79
113
|
| `CLICKHOUSE_URL` | — | e.g. `http://localhost:8123`; unset = ingest returns 503 |
|
|
80
114
|
| `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` | `default` / empty | |
|
|
81
115
|
| `CLICKHOUSE_DATABASE` | `autter_runtime` | Created automatically |
|
|
82
|
-
| `AUTTER_INGEST_KEYS` | — | JSON: `[{"key":"...","orgId":"...","repositoryId":"..."}]` |
|
|
83
|
-
| `AUTTER_KEY_VALIDATOR_URL` | — | Webhook: `POST {key}` → `{orgId, repositoryId}` (60 s cache) |
|
|
116
|
+
| `AUTTER_INGEST_KEYS` | — | JSON: `[{"key":"...","orgId":"...","repositoryId":"..."}]` (optional `scope`, `allowedOrigins`, `suppression`) |
|
|
117
|
+
| `AUTTER_KEY_VALIDATOR_URL` | — | Webhook: `POST {key}` → `{orgId, repositoryId, scope?, allowedOrigins?, suppression?}` (60 s cache) |
|
|
84
118
|
| `AUTTER_KEY_VALIDATOR_TOKEN` | — | Bearer token sent to the validator |
|
|
85
119
|
| `AUTTER_SINK_URL` | — | Issue-grouping webhook; at-least-once (`docs/ARCHITECTURE.md`) |
|
|
86
120
|
| `AUTTER_SINK_TOKEN` | — | Bearer token sent to the sink |
|
|
@@ -89,6 +123,7 @@ the Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIB
|
|
|
89
123
|
| `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
|
|
90
124
|
| `SOURCE_MAP_CACHE_MB` | `64` | Memory budget (decoded size, function scope tables included) for parsed source maps used to symbolicate browser frames at ingest; least recently used maps are evicted, and a map that needs more than half of it is used without being kept |
|
|
91
125
|
| `MAX_BODY_BYTES` | `1048576` | Request body cap |
|
|
126
|
+
| `LOOP_COLLAPSE_AFTER` | `10` | Identical occurrences stored per minute before the rest of the minute is collapsed into one sample with an exact count; `0` turns loop collapse off |
|
|
92
127
|
| `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
|
|
93
128
|
| `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
|
|
94
129
|
| `PROMOTION_LOOKUPS_PER_MINUTE` | `30` | Per-tenant ClickHouse dedupe lookups for `/v1/logs` error promotion; over budget, records are promoted with in-batch dedupe only |
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { Request } from "express";
|
|
2
2
|
import { type IngesterConfig } from "./config.js";
|
|
3
3
|
import type { IngestContext } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Why a key did not resolve: `unknown_key` — not a static key and the
|
|
6
|
+
* validator did not accept it; `key_unverified` — the validator could not
|
|
7
|
+
* answer (unreachable, timed out or a 5xx) and no earlier answer was cached.
|
|
8
|
+
*/
|
|
9
|
+
export type KeyFailure = "unknown_key" | "key_unverified";
|
|
10
|
+
export type KeyResolution = {
|
|
11
|
+
ctx: IngestContext;
|
|
12
|
+
failure?: undefined;
|
|
13
|
+
} | {
|
|
14
|
+
ctx: null;
|
|
15
|
+
failure: KeyFailure;
|
|
16
|
+
};
|
|
4
17
|
export declare class KeyResolver {
|
|
5
18
|
private readonly config;
|
|
6
19
|
private readonly staticKeys;
|
|
@@ -8,6 +21,8 @@ export declare class KeyResolver {
|
|
|
8
21
|
constructor(config: IngesterConfig);
|
|
9
22
|
extractKey(req: Request): string | null;
|
|
10
23
|
resolve(key: string): Promise<IngestContext | null>;
|
|
24
|
+
/** `resolve`, plus why a key that did not resolve failed (for drop counts). */
|
|
25
|
+
resolveDetailed(key: string): Promise<KeyResolution>;
|
|
11
26
|
}
|
|
12
27
|
/** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
|
|
13
28
|
export declare class RateLimiter {
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { INGESTER_VERSION_HEADER } from "./compat.js";
|
|
2
2
|
import { defaultScopeFor, isIngestKeyScope } from "./config.js";
|
|
3
3
|
import { ingesterVersion } from "./sdk-versions.js";
|
|
4
|
+
import { parseSuppressionRules } from "./suppression.js";
|
|
4
5
|
const VALIDATOR_CACHE_TTL_MS = 60_000;
|
|
5
6
|
export class KeyResolver {
|
|
6
7
|
config;
|
|
@@ -12,11 +13,16 @@ export class KeyResolver {
|
|
|
12
13
|
const scope = entry.scope ?? defaultScopeFor(entry.key);
|
|
13
14
|
if (!isIngestKeyScope(scope))
|
|
14
15
|
continue;
|
|
16
|
+
const suppression = parseSuppressionRules(entry.suppression);
|
|
17
|
+
if (Array.isArray(entry.suppression) && suppression.length < entry.suppression.length) {
|
|
18
|
+
console.error(`AUTTER_INGEST_KEYS: ${entry.suppression.length - suppression.length} invalid suppression rule(s) for repository ${entry.repositoryId} ignored`);
|
|
19
|
+
}
|
|
15
20
|
this.staticKeys.set(entry.key, {
|
|
16
21
|
orgId: entry.orgId,
|
|
17
22
|
repositoryId: entry.repositoryId,
|
|
18
23
|
scope,
|
|
19
24
|
allowedOrigins: entry.allowedOrigins ?? [],
|
|
25
|
+
...(suppression.length ? { suppression } : {}),
|
|
20
26
|
});
|
|
21
27
|
}
|
|
22
28
|
}
|
|
@@ -36,15 +42,20 @@ export class KeyResolver {
|
|
|
36
42
|
return null;
|
|
37
43
|
}
|
|
38
44
|
async resolve(key) {
|
|
45
|
+
return (await this.resolveDetailed(key)).ctx;
|
|
46
|
+
}
|
|
47
|
+
/** `resolve`, plus why a key that did not resolve failed (for drop counts). */
|
|
48
|
+
async resolveDetailed(key) {
|
|
39
49
|
const staticCtx = this.staticKeys.get(key);
|
|
40
50
|
if (staticCtx)
|
|
41
|
-
return staticCtx;
|
|
51
|
+
return { ctx: staticCtx };
|
|
42
52
|
if (!this.config.keyValidatorUrl)
|
|
43
|
-
return null;
|
|
53
|
+
return { ctx: null, failure: "unknown_key" };
|
|
44
54
|
const cached = this.cache.get(key);
|
|
45
55
|
if (cached && cached.expiresAt > Date.now())
|
|
46
|
-
return cached
|
|
56
|
+
return resolution(cached);
|
|
47
57
|
let ctx = null;
|
|
58
|
+
let failure = "unknown_key";
|
|
48
59
|
try {
|
|
49
60
|
const response = await fetch(this.config.keyValidatorUrl, {
|
|
50
61
|
method: "POST",
|
|
@@ -66,32 +77,41 @@ export class KeyResolver {
|
|
|
66
77
|
const body = (await response.json());
|
|
67
78
|
const scope = body.scope === undefined || body.scope === null ? defaultScopeFor(key) : body.scope;
|
|
68
79
|
if (body.orgId && body.repositoryId && isIngestKeyScope(scope)) {
|
|
80
|
+
const suppression = parseSuppressionRules(body.suppression);
|
|
69
81
|
ctx = {
|
|
70
82
|
orgId: body.orgId,
|
|
71
83
|
repositoryId: body.repositoryId,
|
|
72
84
|
scope,
|
|
73
85
|
allowedOrigins: body.allowedOrigins ?? [],
|
|
86
|
+
...(suppression.length ? { suppression } : {}),
|
|
74
87
|
};
|
|
75
88
|
}
|
|
76
89
|
}
|
|
90
|
+
else if (response.status >= 500) {
|
|
91
|
+
failure = "key_unverified";
|
|
92
|
+
}
|
|
77
93
|
}
|
|
78
94
|
catch {
|
|
79
95
|
// Validator unreachable: fail closed for unknown keys, but reuse a
|
|
80
96
|
// stale cache entry if we have one so transient validator outages
|
|
81
97
|
// don't drop telemetry from known-good keys.
|
|
82
98
|
if (cached)
|
|
83
|
-
return cached
|
|
84
|
-
return null;
|
|
99
|
+
return resolution(cached);
|
|
100
|
+
return { ctx: null, failure: "key_unverified" };
|
|
85
101
|
}
|
|
86
|
-
|
|
102
|
+
const entry = { ctx, expiresAt: Date.now() + VALIDATOR_CACHE_TTL_MS, ...(ctx ? {} : { failure }) };
|
|
103
|
+
this.cache.set(key, entry);
|
|
87
104
|
if (this.cache.size > 10_000) {
|
|
88
105
|
const oldest = this.cache.keys().next().value;
|
|
89
106
|
if (oldest !== undefined)
|
|
90
107
|
this.cache.delete(oldest);
|
|
91
108
|
}
|
|
92
|
-
return
|
|
109
|
+
return resolution(entry);
|
|
93
110
|
}
|
|
94
111
|
}
|
|
112
|
+
function resolution(entry) {
|
|
113
|
+
return entry.ctx ? { ctx: entry.ctx } : { ctx: null, failure: entry.failure ?? "unknown_key" };
|
|
114
|
+
}
|
|
95
115
|
/** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
|
|
96
116
|
export class RateLimiter {
|
|
97
117
|
limitPerMinute;
|
package/dist/clickhouse.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type SourceMapCheck, type ValidSourceMap } from "./source-maps.js";
|
|
|
6
6
|
import { type SourceMapKey } from "./source-maps-lookup.js";
|
|
7
7
|
import { type RuntimeLogRecord } from "./logs.js";
|
|
8
8
|
import { type SdkVersionRow } from "./sdk-versions.js";
|
|
9
|
+
import { type IngestDropRow } from "./ingest-drops.js";
|
|
9
10
|
import type { SchemaStatus } from "./compat.js";
|
|
10
11
|
import { type Migration } from "./migrations.js";
|
|
11
12
|
import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
|
|
@@ -54,6 +55,8 @@ export declare class ClickHouseStore {
|
|
|
54
55
|
applied: string[];
|
|
55
56
|
};
|
|
56
57
|
insertSdkVersions(ctx: IngestContext, rows: SdkVersionRow[]): Promise<void>;
|
|
58
|
+
/** Rejection and drop counts (ingest-drops.ts); rows carry their own tenant. */
|
|
59
|
+
insertIngestDrops(rows: IngestDropRow[]): Promise<void>;
|
|
57
60
|
/** Record this ingester's version and schema level (global, not tenant data). */
|
|
58
61
|
insertIngesterInfo(info: {
|
|
59
62
|
version: string;
|
package/dist/clickhouse.js
CHANGED
|
@@ -7,7 +7,9 @@ import { attributesWithBreadcrumbs } from "./breadcrumbs.js";
|
|
|
7
7
|
import { lookupSourceMap, presentSourceMaps } from "./source-maps-lookup.js";
|
|
8
8
|
import { logTableDDL, requestRollupTableDDL, } from "./logs.js";
|
|
9
9
|
import { ingesterInfoTableDDL, sdkVersionTableDDL, } from "./sdk-versions.js";
|
|
10
|
-
import {
|
|
10
|
+
import { ingestDropsTableDDL } from "./ingest-drops.js";
|
|
11
|
+
import { inAppFrameResolution } from "./symbolicate.js";
|
|
12
|
+
import { MIGRATIONS, OCCURRENCE_CODE_COLUMNS, OCCURRENCE_COLLAPSE_COLUMNS, OCCURRENCE_FRAME_RESOLUTION_COLUMNS, OCCURRENCE_GROUPING_COLUMNS, OCCURRENCE_REQUEST_ID_INDEX, migrationsTableDDL, } from "./migrations.js";
|
|
11
13
|
/**
|
|
12
14
|
* ClickHouse persistence. The schema is idempotent and applied lazily once
|
|
13
15
|
* per process. Every row is keyed by (org_id, repository_id) — tenant
|
|
@@ -22,6 +24,14 @@ const INSERT_SETTINGS = {
|
|
|
22
24
|
/** Source maps above this go in with a plain insert (below ClickHouse's
|
|
23
25
|
* default async_insert_max_data_size of 10 MiB). */
|
|
24
26
|
const ASYNC_INSERT_MAX_MAP_CHARS = 8 * 1024 * 1024;
|
|
27
|
+
/** Migration 0020 columns: browser occurrences only (server frames are not symbolicated). */
|
|
28
|
+
function occurrenceFrameResolution(o) {
|
|
29
|
+
const counts = o.source === "browser" ? inAppFrameResolution(o.frames) : { resolved: 0, unresolved: 0 };
|
|
30
|
+
return {
|
|
31
|
+
in_app_frames_resolved: Math.min(65_535, counts.resolved),
|
|
32
|
+
in_app_frames_unresolved: Math.min(65_535, counts.unresolved),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
25
35
|
/** "YYYY-MM-DD hh:mm:ss.sss" (UTC) — the DateTime64 query-parameter form. */
|
|
26
36
|
function chDateTime(date) {
|
|
27
37
|
return date.toISOString().replace("T", " ").replace("Z", "");
|
|
@@ -74,6 +84,7 @@ export class ClickHouseStore {
|
|
|
74
84
|
requestRollupTableDDL(db),
|
|
75
85
|
sdkVersionTableDDL(db, metricsTtlDays),
|
|
76
86
|
ingesterInfoTableDDL(db),
|
|
87
|
+
ingestDropsTableDDL(db),
|
|
77
88
|
`CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
|
|
78
89
|
org_id String,
|
|
79
90
|
repository_id String,
|
|
@@ -101,6 +112,8 @@ export class ClickHouseStore {
|
|
|
101
112
|
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
|
102
113
|
${OCCURRENCE_CODE_COLUMNS.join(",\n\t\t\t\t")},
|
|
103
114
|
${OCCURRENCE_GROUPING_COLUMNS.join(",\n\t\t\t\t")},
|
|
115
|
+
${OCCURRENCE_FRAME_RESOLUTION_COLUMNS.join(",\n\t\t\t\t")},
|
|
116
|
+
${OCCURRENCE_COLLAPSE_COLUMNS.join(",\n\t\t\t\t")},
|
|
104
117
|
${OCCURRENCE_REQUEST_ID_INDEX}
|
|
105
118
|
)
|
|
106
119
|
ENGINE = MergeTree
|
|
@@ -317,6 +330,30 @@ export class ClickHouseStore {
|
|
|
317
330
|
sdk_language: row.sdkLanguage,
|
|
318
331
|
ingester_version: row.ingesterVersion,
|
|
319
332
|
last_seen: row.seenAt.toISOString(),
|
|
333
|
+
via: row.via,
|
|
334
|
+
})),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** Rejection and drop counts (ingest-drops.ts); rows carry their own tenant. */
|
|
338
|
+
async insertIngestDrops(rows) {
|
|
339
|
+
if (!rows.length || !this.configured)
|
|
340
|
+
return;
|
|
341
|
+
await this.ensureSchema();
|
|
342
|
+
await this.getClient().insert({
|
|
343
|
+
table: this.table("runtime_ingest_drops"),
|
|
344
|
+
format: "JSONEachRow",
|
|
345
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
346
|
+
values: rows.map((row) => ({
|
|
347
|
+
org_id: row.orgId,
|
|
348
|
+
repository_id: row.repositoryId,
|
|
349
|
+
minute: row.minute.toISOString(),
|
|
350
|
+
route: row.route,
|
|
351
|
+
reason: row.reason,
|
|
352
|
+
kind: row.kind,
|
|
353
|
+
status: row.status,
|
|
354
|
+
key_hash: row.keyHash,
|
|
355
|
+
requests: row.requests,
|
|
356
|
+
records: row.records,
|
|
320
357
|
})),
|
|
321
358
|
});
|
|
322
359
|
}
|
|
@@ -352,6 +389,7 @@ export class ClickHouseStore {
|
|
|
352
389
|
format: "JSONEachRow",
|
|
353
390
|
clickhouse_settings: INSERT_SETTINGS,
|
|
354
391
|
values: occurrences.map((o) => ({
|
|
392
|
+
...occurrenceFrameResolution(o),
|
|
355
393
|
org_id: ctx.orgId,
|
|
356
394
|
repository_id: ctx.repositoryId,
|
|
357
395
|
occurrence_id: o.occurrenceId,
|
|
@@ -384,6 +422,8 @@ export class ClickHouseStore {
|
|
|
384
422
|
fingerprint_scheme: o.fingerprintScheme,
|
|
385
423
|
frames: o.frames?.length ? JSON.stringify(o.frames) : "",
|
|
386
424
|
fingerprints: (o.fingerprints ?? []).map((candidate) => candidate.fingerprint),
|
|
425
|
+
// Migration 0021: only collapsed samples name the column.
|
|
426
|
+
...(o.collapsedCount ? { collapsed_count: Math.min(0xffff_ffff, Math.max(0, Math.round(o.collapsedCount))) } : {}),
|
|
387
427
|
})),
|
|
388
428
|
});
|
|
389
429
|
}
|
|
@@ -134,6 +134,41 @@
|
|
|
134
134
|
"migrations": ["0017-source-maps-debug-id"],
|
|
135
135
|
"route": "/v1/sourcemaps/check",
|
|
136
136
|
"sdks": {}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"id": "ingest_drop_counts",
|
|
140
|
+
"title": "Rejected and dropped records per repository",
|
|
141
|
+
"ingester": "1.8.0",
|
|
142
|
+
"migrations": ["0018-runtime-ingest-drops"],
|
|
143
|
+
"sdks": {}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"id": "telemetry_sender_via",
|
|
147
|
+
"title": "How each service's telemetry arrives (SDK, OpenTelemetry Collector or browser relay)",
|
|
148
|
+
"ingester": "1.8.0",
|
|
149
|
+
"migrations": ["0019-sdk-versions-via"],
|
|
150
|
+
"sdks": {}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "frame_resolution_counts",
|
|
154
|
+
"title": "Resolved and unresolved browser frames per occurrence",
|
|
155
|
+
"ingester": "1.8.0",
|
|
156
|
+
"migrations": ["0020-occurrence-frame-resolution"],
|
|
157
|
+
"sdks": {}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"id": "ingest_suppression",
|
|
161
|
+
"title": "Suppression rules: drop or sample matching errors at ingest",
|
|
162
|
+
"ingester": "1.9.0",
|
|
163
|
+
"migrations": [],
|
|
164
|
+
"sdks": {}
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"id": "loop_collapse",
|
|
168
|
+
"title": "Loop collapse: a looping error stored as one sample a minute with an exact count",
|
|
169
|
+
"ingester": "1.9.0",
|
|
170
|
+
"migrations": ["0021-occurrence-collapsed-count"],
|
|
171
|
+
"sdks": {}
|
|
137
172
|
}
|
|
138
173
|
]
|
|
139
174
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -23,6 +23,13 @@ export interface StaticIngestKey {
|
|
|
23
23
|
scope?: IngestKeyScope;
|
|
24
24
|
/** client keys: exact origins allowed to send (e.g. https://app.example.com). */
|
|
25
25
|
allowedOrigins?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* Suppression rules for this key's repository (1.9.0, suppression.ts):
|
|
28
|
+
* `[{ "id": "…", "action": "drop" | "sample", "sampleRate"?: 0.1,
|
|
29
|
+
* "filters": [{ "key": "errorType", "op": "equals", "value": "…" }] }]`.
|
|
30
|
+
* Invalid rules are left out (logged once at startup).
|
|
31
|
+
*/
|
|
32
|
+
suppression?: unknown[];
|
|
26
33
|
}
|
|
27
34
|
export interface IngesterConfig {
|
|
28
35
|
port: number;
|
|
@@ -77,7 +84,15 @@ export interface IngesterConfig {
|
|
|
77
84
|
* A map that needs more than half of it is used without being kept.
|
|
78
85
|
*/
|
|
79
86
|
sourceMapCacheMb?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Loop collapse (1.9.0, loop-collapse.ts): identical occurrences stored
|
|
89
|
+
* per minute before the rest are folded into one sample with an exact
|
|
90
|
+
* count (LOOP_COLLAPSE_AFTER, default 10; 0 turns it off).
|
|
91
|
+
*/
|
|
92
|
+
loopCollapseAfter?: number;
|
|
80
93
|
}
|
|
94
|
+
/** LOOP_COLLAPSE_AFTER: a whole number ≥ 0 (0 = off); anything else is the default, 10. */
|
|
95
|
+
export declare function loopCollapseAfterEnv(raw: string | undefined): number;
|
|
81
96
|
/** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
|
|
82
97
|
* repositoryId, or with an unknown scope, are dropped (a typo in `scope`
|
|
83
98
|
* must not turn a restricted key into a server key). */
|
package/dist/config.js
CHANGED
|
@@ -12,6 +12,13 @@ export function isIngestKeyScope(value) {
|
|
|
12
12
|
export function defaultScopeFor(key) {
|
|
13
13
|
return key.startsWith(UPLOAD_KEY_PREFIX) ? "upload" : "server";
|
|
14
14
|
}
|
|
15
|
+
/** LOOP_COLLAPSE_AFTER: a whole number ≥ 0 (0 = off); anything else is the default, 10. */
|
|
16
|
+
export function loopCollapseAfterEnv(raw) {
|
|
17
|
+
if (raw === undefined || raw.trim() === "")
|
|
18
|
+
return 10;
|
|
19
|
+
const value = Number(raw);
|
|
20
|
+
return Number.isInteger(value) && value >= 0 ? value : 10;
|
|
21
|
+
}
|
|
15
22
|
function intEnv(name, fallback) {
|
|
16
23
|
const raw = process.env[name];
|
|
17
24
|
if (!raw)
|
|
@@ -92,6 +99,7 @@ export function loadConfig() {
|
|
|
92
99
|
redactValuePatterns: patternListEnv("AUTTER_REDACT_VALUE_PATTERNS"),
|
|
93
100
|
redactKeyPatterns: patternListEnv("AUTTER_REDACT_KEY_PATTERNS"),
|
|
94
101
|
sourceMapCacheMb: intEnv("SOURCE_MAP_CACHE_MB", 64),
|
|
102
|
+
loopCollapseAfter: loopCollapseAfterEnv(process.env.LOOP_COLLAPSE_AFTER),
|
|
95
103
|
};
|
|
96
104
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(config.clickhouseDatabase)) {
|
|
97
105
|
throw new Error(`Invalid CLICKHOUSE_DATABASE name: ${config.clickhouseDatabase}`);
|
package/dist/context.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Run a synchronous normaliser and return what it cut along with its result. */
|
|
2
|
+
export declare function countTruncations<T>(normalise: () => T): {
|
|
3
|
+
result: T;
|
|
4
|
+
truncated: number;
|
|
5
|
+
};
|
|
1
6
|
/** Bounded, defensive privacy boundary for custom telemetry from any OTLP SDK. */
|
|
2
7
|
export declare function sanitizeRuntimeContext(input: unknown): Record<string, unknown>;
|
|
3
8
|
export interface OtlpValue {
|
package/dist/context.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { isSensitiveKey, scrubText } from "./redact.js";
|
|
2
2
|
import { BREADCRUMBS_ATTRIBUTE } from "./breadcrumbs.js";
|
|
3
|
+
/**
|
|
4
|
+
* Attribute values the bounds below cut or left out (a string past its
|
|
5
|
+
* length cap, list items past 64, map keys past 128, nesting past 6, the
|
|
6
|
+
* 512-value budget), counted since the process started. The normalisers are
|
|
7
|
+
* synchronous, so the difference across one call is that call's own count:
|
|
8
|
+
* see `countTruncations`. Privacy rewrites (redaction, query stripping) are
|
|
9
|
+
* not truncation and are not counted.
|
|
10
|
+
*/
|
|
11
|
+
let truncatedValues = 0;
|
|
12
|
+
/** Run a synchronous normaliser and return what it cut along with its result. */
|
|
13
|
+
export function countTruncations(normalise) {
|
|
14
|
+
const before = truncatedValues;
|
|
15
|
+
const result = normalise();
|
|
16
|
+
return { result, truncated: truncatedValues - before };
|
|
17
|
+
}
|
|
3
18
|
/** Bounded, defensive privacy boundary for custom telemetry from any OTLP SDK. */
|
|
4
19
|
export function sanitizeRuntimeContext(input) {
|
|
5
20
|
let budget = 512;
|
|
@@ -15,8 +30,10 @@ export function sanitizeRuntimeContext(input) {
|
|
|
15
30
|
return cut <= 0 ? url : url.slice(0, url.length - rest.length + cut);
|
|
16
31
|
});
|
|
17
32
|
const visit = (value, key, depth) => {
|
|
18
|
-
if (--budget < 0 || depth > 6)
|
|
33
|
+
if (--budget < 0 || depth > 6) {
|
|
34
|
+
truncatedValues += 1;
|
|
19
35
|
return "[truncated]";
|
|
36
|
+
}
|
|
20
37
|
if (/__proto__|constructor|prototype/i.test(key))
|
|
21
38
|
return undefined;
|
|
22
39
|
const usage =
|
|
@@ -44,6 +61,8 @@ export function sanitizeRuntimeContext(input) {
|
|
|
44
61
|
// Cut before scrubbing so scrub work is bounded by the stored size;
|
|
45
62
|
// the slack keeps a secret straddling the cut whole for the scrubber.
|
|
46
63
|
const limit = /stack/i.test(key) ? 32000 : 2048;
|
|
64
|
+
if (text.length > limit)
|
|
65
|
+
truncatedValues += 1;
|
|
47
66
|
return scrub(text.slice(0, limit + 512)).slice(0, limit);
|
|
48
67
|
}
|
|
49
68
|
if (typeof value === "number")
|
|
@@ -55,11 +74,19 @@ export function sanitizeRuntimeContext(input) {
|
|
|
55
74
|
if (seen.has(value))
|
|
56
75
|
return "[circular]";
|
|
57
76
|
seen.add(value);
|
|
58
|
-
if (Array.isArray(value))
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
if (value.length > 64)
|
|
79
|
+
truncatedValues += value.length - 64;
|
|
59
80
|
return value.slice(0, 64).map((v) => visit(v, key, depth + 1));
|
|
81
|
+
}
|
|
60
82
|
const result = {};
|
|
61
|
-
|
|
83
|
+
const entries = Object.entries(value);
|
|
84
|
+
if (entries.length > 128)
|
|
85
|
+
truncatedValues += entries.length - 128;
|
|
86
|
+
for (const [k, v] of entries.slice(0, 128)) {
|
|
62
87
|
const safe = visit(v, k, depth + 1);
|
|
88
|
+
if (k.length > 200)
|
|
89
|
+
truncatedValues += 1;
|
|
63
90
|
if (safe !== undefined)
|
|
64
91
|
result[k.slice(0, 200)] = safe;
|
|
65
92
|
}
|
|
@@ -72,8 +99,12 @@ export function sanitizeRuntimeContext(input) {
|
|
|
72
99
|
}
|
|
73
100
|
export function decodeOtlpAttributes(attributes) {
|
|
74
101
|
const decode = (v, depth = 0) => {
|
|
75
|
-
if (!v
|
|
102
|
+
if (!v)
|
|
103
|
+
return undefined;
|
|
104
|
+
if (depth > 6) {
|
|
105
|
+
truncatedValues += 1;
|
|
76
106
|
return undefined;
|
|
107
|
+
}
|
|
77
108
|
if (v.stringValue !== undefined)
|
|
78
109
|
return v.stringValue;
|
|
79
110
|
if (v.intValue !== undefined)
|
|
@@ -82,17 +113,25 @@ export function decodeOtlpAttributes(attributes) {
|
|
|
82
113
|
return v.doubleValue;
|
|
83
114
|
if (v.boolValue !== undefined)
|
|
84
115
|
return v.boolValue;
|
|
85
|
-
if (v.arrayValue)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
if (v.arrayValue) {
|
|
117
|
+
const values = v.arrayValue.values ?? [];
|
|
118
|
+
if (values.length > 64)
|
|
119
|
+
truncatedValues += values.length - 64;
|
|
120
|
+
return values.slice(0, 64).map((value) => decode(value, depth + 1));
|
|
121
|
+
}
|
|
122
|
+
if (v.kvlistValue) {
|
|
123
|
+
const values = v.kvlistValue.values ?? [];
|
|
124
|
+
if (values.length > 128)
|
|
125
|
+
truncatedValues += values.length - 128;
|
|
126
|
+
return Object.fromEntries(values
|
|
91
127
|
.slice(0, 128)
|
|
92
128
|
.filter((a) => a.key && !/^(?:__proto__|constructor|prototype)$/.test(a.key))
|
|
93
129
|
.map((a) => [a.key, decode(a.value, depth + 1)]));
|
|
130
|
+
}
|
|
94
131
|
return undefined;
|
|
95
132
|
};
|
|
133
|
+
if (Array.isArray(attributes) && attributes.length > 128)
|
|
134
|
+
truncatedValues += attributes.length - 128;
|
|
96
135
|
return sanitizeRuntimeContext(Object.fromEntries((attributes ?? [])
|
|
97
136
|
.slice()
|
|
98
137
|
.sort((a, b) => Number(/^autter\.(?:operation|event)\./.test(b.key ?? "")) -
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { createIngesterApp } from "./server.js";
|
|
2
2
|
export { loadConfig } from "./config.js";
|
|
3
3
|
export * from "./compat.js";
|
|
4
|
-
export { SinkForwarder, type SinkStats, type SinkTuning } from "./sink.js";
|
|
4
|
+
export { SinkForwarder, type SinkDrop, type SinkStats, type SinkTuning } from "./sink.js";
|
|
5
|
+
export { collapseIdentity, LoopCollapser, type CollapsedSample, type CollapsePlan, type LoopCollapseOptions, type LoopCollapseStats, } from "./loop-collapse.js";
|
|
6
|
+
export { applySuppression, globMatch, parseSuppressionRules, SUPPRESSION_OPERATORS, SUPPRESSION_PROPERTIES, type SuppressionAction, type SuppressionFilter, type SuppressionOperator, type SuppressionProperty, type SuppressionRule, } from "./suppression.js";
|
|
7
|
+
export { DROP_REASONS, IngestDropCounter, type DropKind, type DropReason, type DropRoute, type IngestDrop, type IngestDropRow, } from "./ingest-drops.js";
|
|
5
8
|
export * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { loadConfig } from "./config.js";
|
|
|
2
2
|
import { createIngesterApp } from "./server.js";
|
|
3
3
|
import { ingesterVersion } from "./sdk-versions.js";
|
|
4
4
|
const config = loadConfig();
|
|
5
|
-
const { app, store, sink, reportIngesterInfo } = createIngesterApp(config);
|
|
5
|
+
const { app, store, sink, reportIngesterInfo, drops, collapse } = createIngesterApp(config);
|
|
6
6
|
const server = app.listen(config.port, () => {
|
|
7
7
|
console.log(`autter otlp-ingester ${ingesterVersion()} listening on :${config.port} ` +
|
|
8
8
|
`(clickhouse: ${config.clickhouseUrl ? "configured" : "NOT configured"})`);
|
|
@@ -25,7 +25,16 @@ async function shutdown(signal) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
server.close(() => {
|
|
28
|
-
|
|
28
|
+
// Store every loop-collapse window's sample (the minute in progress
|
|
29
|
+
// too), so a deploy loses no folded count; the sink is stopped, so
|
|
30
|
+
// the consumer picks them up from ClickHouse. Then write the
|
|
31
|
+
// rejection/drop counts still buffered (best effort: a failed write
|
|
32
|
+
// is logged, never retried).
|
|
33
|
+
void collapse
|
|
34
|
+
.stop()
|
|
35
|
+
.finally(() => drops.stop())
|
|
36
|
+
.finally(() => store.close())
|
|
37
|
+
.finally(() => process.exit(0));
|
|
29
38
|
});
|
|
30
39
|
setTimeout(() => process.exit(1), 10_000).unref();
|
|
31
40
|
}
|
|
@@ -35,4 +44,7 @@ export { createIngesterApp } from "./server.js";
|
|
|
35
44
|
export { loadConfig } from "./config.js";
|
|
36
45
|
export * from "./compat.js";
|
|
37
46
|
export { SinkForwarder } from "./sink.js";
|
|
47
|
+
export { collapseIdentity, LoopCollapser, } from "./loop-collapse.js";
|
|
48
|
+
export { applySuppression, globMatch, parseSuppressionRules, SUPPRESSION_OPERATORS, SUPPRESSION_PROPERTIES, } from "./suppression.js";
|
|
49
|
+
export { DROP_REASONS, IngestDropCounter, } from "./ingest-drops.js";
|
|
38
50
|
export * from "./types.js";
|