@autter/otlp-ingester 1.6.1 → 1.8.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 +31 -5
- package/dist/auth.d.ts +16 -1
- package/dist/auth.js +34 -10
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +38 -1
- package/dist/compat-manifest.json +37 -0
- package/dist/config.d.ts +23 -2
- package/dist/config.js +33 -8
- package/dist/context.d.ts +5 -0
- package/dist/context.js +49 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/ingest-drops.d.ts +308 -0
- package/dist/ingest-drops.js +448 -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/memory.d.ts +9 -1
- package/dist/memory.js +28 -7
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.js +29 -1
- package/dist/normalize-browser.d.ts +53 -25
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.d.ts +6 -0
- package/dist/normalize-otlp.js +275 -108
- 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 +10 -0
- package/dist/server.js +345 -105
- package/dist/sink.d.ts +14 -0
- package/dist/sink.js +15 -0
- package/dist/source-maps-lookup.d.ts +6 -3
- package/dist/source-maps-lookup.js +35 -13
- package/dist/source-maps.d.ts +6 -2
- package/dist/source-maps.js +12 -4
- package/dist/stack-frames.d.ts +5 -2
- package/dist/stack-frames.js +55 -9
- package/dist/symbolicate.d.ts +13 -0
- package/dist/symbolicate.js +22 -0
- package/dist/trace-dedupe.d.ts +98 -0
- package/dist/trace-dedupe.js +385 -0
- package/dist/types.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,16 @@ works with its default exporter settings.
|
|
|
31
31
|
| --- | --- | --- | --- |
|
|
32
32
|
| `server` (default) | `autter_rt_…` (secret) | all endpoints | 300 req/min |
|
|
33
33
|
| `client` | `autter_rtc_…` (publishable, safe in frontend bundles) | `/v1/browser` only | origin allow-list, 120 req/min |
|
|
34
|
+
| `upload` (1.7.0+) | `autter_rtu_…` (secret, for CI) | `/v1/sourcemaps` and `/v1/sourcemaps/check` only | 300 req/min |
|
|
35
|
+
|
|
36
|
+
An upload key gets `403 {"error":"upload_key_not_allowed","detail":"autter_rtu_ keys can only upload source maps …"}`
|
|
37
|
+
on every other route, so a CI secret that leaks can upload source maps for
|
|
38
|
+
one repository and nothing else. A static entry without `scope` is an upload
|
|
39
|
+
key when its key starts with `autter_rtu_`, else a server key; an entry (or a
|
|
40
|
+
validator answer) with any other `scope` is ignored rather than treated as a
|
|
41
|
+
server key. Ingesters before 1.7.0 treat every scope but `client` as
|
|
42
|
+
`server`, so a validator must not answer `"scope": "upload"` to them: from
|
|
43
|
+
1.7.0 every validator request carries `x-autter-ingester-version`.
|
|
34
44
|
|
|
35
45
|
`/v1/browser` answers CORS preflights permissively; real enforcement (key +
|
|
36
46
|
origin allow-list) happens on the POST. Cross-origin browsers send
|
|
@@ -41,12 +51,27 @@ route parses as JSON.
|
|
|
41
51
|
AUTTER_INGEST_KEYS='[
|
|
42
52
|
{"key":"autter_rt_…","orgId":"org1","repositoryId":"repo1"},
|
|
43
53
|
{"key":"autter_rtc_…","orgId":"org1","repositoryId":"repo1",
|
|
44
|
-
"scope":"client","allowedOrigins":["https://app.example.com"]}
|
|
54
|
+
"scope":"client","allowedOrigins":["https://app.example.com"]},
|
|
55
|
+
{"key":"autter_rtu_…","orgId":"org1","repositoryId":"repo1","scope":"upload"}
|
|
45
56
|
]'
|
|
46
57
|
```
|
|
47
58
|
|
|
48
59
|
The validator webhook may return the same extra fields:
|
|
49
|
-
`{ orgId, repositoryId, scope?, allowedOrigins? }
|
|
60
|
+
`{ orgId, repositoryId, scope?, allowedOrigins? }`, with `scope` one of
|
|
61
|
+
`server`, `client`, `upload`.
|
|
62
|
+
|
|
63
|
+
### Rejections and drops (1.8.0)
|
|
64
|
+
|
|
65
|
+
Everything the ingester refuses (401, 403, 404, 413, 415, 429, 400, 503) or
|
|
66
|
+
does not store from an accepted request (spans past 5,000, log records past
|
|
67
|
+
2,000, cumulative HTTP histograms, invalid browser events, …) is counted per
|
|
68
|
+
repository, minute, route and reason in `runtime_ingest_drops`; keys that
|
|
69
|
+
don't resolve count against an empty tenant with the sha256 of the key when
|
|
70
|
+
it is an Autter key. OTLP answers name dropped records in `partial_success`
|
|
71
|
+
(JSON and protobuf), so collectors log them. Counts are written every 5 s in
|
|
72
|
+
one batch, bounded in memory, and a failed write is logged and dropped:
|
|
73
|
+
counting never slows or fails ingest. Reasons and queries:
|
|
74
|
+
[docs/ARCHITECTURE.md § Rejections and drops](../../docs/ARCHITECTURE.md#rejections-and-drops).
|
|
50
75
|
|
|
51
76
|
### Version compatibility
|
|
52
77
|
|
|
@@ -55,9 +80,10 @@ its ClickHouse schema state, and which features from
|
|
|
55
80
|
`src/compat-manifest.json` it supports. Add
|
|
56
81
|
`?features=operation_logging&sdk=@autter/runtime-node@1.3.0` to get the evaluated
|
|
57
82
|
incompatibilities. Every response carries `x-autter-ingester-version`. The
|
|
58
|
-
ingester records the SDK name and version each service sends with
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
ingester records the SDK name and version each service sends with, and
|
|
84
|
+
whether it came through an OpenTelemetry Collector (`runtime_sdk_versions`,
|
|
85
|
+
`via` from 1.8.0), and its own version (`runtime_ingester_info`), and the
|
|
86
|
+
Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILITY.md).
|
|
61
87
|
|
|
62
88
|
## Configuration
|
|
63
89
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { Request } from "express";
|
|
2
|
-
import type
|
|
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,3 +1,6 @@
|
|
|
1
|
+
import { INGESTER_VERSION_HEADER } from "./compat.js";
|
|
2
|
+
import { defaultScopeFor, isIngestKeyScope } from "./config.js";
|
|
3
|
+
import { ingesterVersion } from "./sdk-versions.js";
|
|
1
4
|
const VALIDATOR_CACHE_TTL_MS = 60_000;
|
|
2
5
|
export class KeyResolver {
|
|
3
6
|
config;
|
|
@@ -6,10 +9,13 @@ export class KeyResolver {
|
|
|
6
9
|
constructor(config) {
|
|
7
10
|
this.config = config;
|
|
8
11
|
for (const entry of config.ingestKeys) {
|
|
12
|
+
const scope = entry.scope ?? defaultScopeFor(entry.key);
|
|
13
|
+
if (!isIngestKeyScope(scope))
|
|
14
|
+
continue;
|
|
9
15
|
this.staticKeys.set(entry.key, {
|
|
10
16
|
orgId: entry.orgId,
|
|
11
17
|
repositoryId: entry.repositoryId,
|
|
12
|
-
scope
|
|
18
|
+
scope,
|
|
13
19
|
allowedOrigins: entry.allowedOrigins ?? [],
|
|
14
20
|
});
|
|
15
21
|
}
|
|
@@ -30,20 +36,30 @@ export class KeyResolver {
|
|
|
30
36
|
return null;
|
|
31
37
|
}
|
|
32
38
|
async resolve(key) {
|
|
39
|
+
return (await this.resolveDetailed(key)).ctx;
|
|
40
|
+
}
|
|
41
|
+
/** `resolve`, plus why a key that did not resolve failed (for drop counts). */
|
|
42
|
+
async resolveDetailed(key) {
|
|
33
43
|
const staticCtx = this.staticKeys.get(key);
|
|
34
44
|
if (staticCtx)
|
|
35
|
-
return staticCtx;
|
|
45
|
+
return { ctx: staticCtx };
|
|
36
46
|
if (!this.config.keyValidatorUrl)
|
|
37
|
-
return null;
|
|
47
|
+
return { ctx: null, failure: "unknown_key" };
|
|
38
48
|
const cached = this.cache.get(key);
|
|
39
49
|
if (cached && cached.expiresAt > Date.now())
|
|
40
|
-
return cached
|
|
50
|
+
return resolution(cached);
|
|
41
51
|
let ctx = null;
|
|
52
|
+
let failure = "unknown_key";
|
|
42
53
|
try {
|
|
43
54
|
const response = await fetch(this.config.keyValidatorUrl, {
|
|
44
55
|
method: "POST",
|
|
45
56
|
headers: {
|
|
46
57
|
"content-type": "application/json",
|
|
58
|
+
// Lets the validator tell which scopes this ingester enforces:
|
|
59
|
+
// before 1.7.0 any scope but "client" meant a server key, so an
|
|
60
|
+
// upload key must not be answered as "upload" to an ingester
|
|
61
|
+
// that doesn't send this header (or sends an older version).
|
|
62
|
+
[INGESTER_VERSION_HEADER]: ingesterVersion(),
|
|
47
63
|
...(this.config.keyValidatorToken
|
|
48
64
|
? { authorization: `Bearer ${this.config.keyValidatorToken}` }
|
|
49
65
|
: {}),
|
|
@@ -53,33 +69,41 @@ export class KeyResolver {
|
|
|
53
69
|
});
|
|
54
70
|
if (response.ok) {
|
|
55
71
|
const body = (await response.json());
|
|
56
|
-
|
|
72
|
+
const scope = body.scope === undefined || body.scope === null ? defaultScopeFor(key) : body.scope;
|
|
73
|
+
if (body.orgId && body.repositoryId && isIngestKeyScope(scope)) {
|
|
57
74
|
ctx = {
|
|
58
75
|
orgId: body.orgId,
|
|
59
76
|
repositoryId: body.repositoryId,
|
|
60
|
-
scope
|
|
77
|
+
scope,
|
|
61
78
|
allowedOrigins: body.allowedOrigins ?? [],
|
|
62
79
|
};
|
|
63
80
|
}
|
|
64
81
|
}
|
|
82
|
+
else if (response.status >= 500) {
|
|
83
|
+
failure = "key_unverified";
|
|
84
|
+
}
|
|
65
85
|
}
|
|
66
86
|
catch {
|
|
67
87
|
// Validator unreachable: fail closed for unknown keys, but reuse a
|
|
68
88
|
// stale cache entry if we have one so transient validator outages
|
|
69
89
|
// don't drop telemetry from known-good keys.
|
|
70
90
|
if (cached)
|
|
71
|
-
return cached
|
|
72
|
-
return null;
|
|
91
|
+
return resolution(cached);
|
|
92
|
+
return { ctx: null, failure: "key_unverified" };
|
|
73
93
|
}
|
|
74
|
-
|
|
94
|
+
const entry = { ctx, expiresAt: Date.now() + VALIDATOR_CACHE_TTL_MS, ...(ctx ? {} : { failure }) };
|
|
95
|
+
this.cache.set(key, entry);
|
|
75
96
|
if (this.cache.size > 10_000) {
|
|
76
97
|
const oldest = this.cache.keys().next().value;
|
|
77
98
|
if (oldest !== undefined)
|
|
78
99
|
this.cache.delete(oldest);
|
|
79
100
|
}
|
|
80
|
-
return
|
|
101
|
+
return resolution(entry);
|
|
81
102
|
}
|
|
82
103
|
}
|
|
104
|
+
function resolution(entry) {
|
|
105
|
+
return entry.ctx ? { ctx: entry.ctx } : { ctx: null, failure: entry.failure ?? "unknown_key" };
|
|
106
|
+
}
|
|
83
107
|
/** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
|
|
84
108
|
export class RateLimiter {
|
|
85
109
|
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_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,7 @@ 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")},
|
|
104
116
|
${OCCURRENCE_REQUEST_ID_INDEX}
|
|
105
117
|
)
|
|
106
118
|
ENGINE = MergeTree
|
|
@@ -317,6 +329,30 @@ export class ClickHouseStore {
|
|
|
317
329
|
sdk_language: row.sdkLanguage,
|
|
318
330
|
ingester_version: row.ingesterVersion,
|
|
319
331
|
last_seen: row.seenAt.toISOString(),
|
|
332
|
+
via: row.via,
|
|
333
|
+
})),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** Rejection and drop counts (ingest-drops.ts); rows carry their own tenant. */
|
|
337
|
+
async insertIngestDrops(rows) {
|
|
338
|
+
if (!rows.length || !this.configured)
|
|
339
|
+
return;
|
|
340
|
+
await this.ensureSchema();
|
|
341
|
+
await this.getClient().insert({
|
|
342
|
+
table: this.table("runtime_ingest_drops"),
|
|
343
|
+
format: "JSONEachRow",
|
|
344
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
345
|
+
values: rows.map((row) => ({
|
|
346
|
+
org_id: row.orgId,
|
|
347
|
+
repository_id: row.repositoryId,
|
|
348
|
+
minute: row.minute.toISOString(),
|
|
349
|
+
route: row.route,
|
|
350
|
+
reason: row.reason,
|
|
351
|
+
kind: row.kind,
|
|
352
|
+
status: row.status,
|
|
353
|
+
key_hash: row.keyHash,
|
|
354
|
+
requests: row.requests,
|
|
355
|
+
records: row.records,
|
|
320
356
|
})),
|
|
321
357
|
});
|
|
322
358
|
}
|
|
@@ -352,6 +388,7 @@ export class ClickHouseStore {
|
|
|
352
388
|
format: "JSONEachRow",
|
|
353
389
|
clickhouse_settings: INSERT_SETTINGS,
|
|
354
390
|
values: occurrences.map((o) => ({
|
|
391
|
+
...occurrenceFrameResolution(o),
|
|
355
392
|
org_id: ctx.orgId,
|
|
356
393
|
repository_id: ctx.repositoryId,
|
|
357
394
|
occurrence_id: o.occurrenceId,
|
|
@@ -118,6 +118,43 @@
|
|
|
118
118
|
"ingester": "1.6.0",
|
|
119
119
|
"migrations": [],
|
|
120
120
|
"sdks": { "@autter/runtime-browser": "1.6.0", "@autter/runtime-next": "1.6.0", "@autter/runtime-node": "1.6.0" }
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "sourcemap_upload_keys",
|
|
124
|
+
"title": "Upload-only keys for source maps (autter_rtu_)",
|
|
125
|
+
"ingester": "1.7.0",
|
|
126
|
+
"migrations": [],
|
|
127
|
+
"route": "/v1/sourcemaps",
|
|
128
|
+
"sdks": {}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "source_map_verify",
|
|
132
|
+
"title": "Source map check by debug id (autter-runtime sourcemaps verify)",
|
|
133
|
+
"ingester": "1.7.0",
|
|
134
|
+
"migrations": ["0017-source-maps-debug-id"],
|
|
135
|
+
"route": "/v1/sourcemaps/check",
|
|
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": {}
|
|
121
158
|
}
|
|
122
159
|
]
|
|
123
160
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
import type { IngestKeyScope } from "./types.js";
|
|
2
|
+
export declare const INGEST_KEY_SCOPES: readonly IngestKeyScope[];
|
|
3
|
+
/** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
|
|
4
|
+
export declare const UPLOAD_KEY_PREFIX = "autter_rtu_";
|
|
5
|
+
export declare function isIngestKeyScope(value: unknown): value is IngestKeyScope;
|
|
6
|
+
/**
|
|
7
|
+
* The scope of a key whose static entry or validator answer names none:
|
|
8
|
+
* "upload" for an `autter_rtu_…` key (its prefix promises no more than
|
|
9
|
+
* that), else "server" as before 1.7.0.
|
|
10
|
+
*/
|
|
11
|
+
export declare function defaultScopeFor(key: string): IngestKeyScope;
|
|
1
12
|
export interface StaticIngestKey {
|
|
2
13
|
key: string;
|
|
3
14
|
orgId: string;
|
|
4
15
|
repositoryId: string;
|
|
5
|
-
/**
|
|
6
|
-
|
|
16
|
+
/**
|
|
17
|
+
* "server" = secret backend key, every route; "client" = publishable
|
|
18
|
+
* browser key, `/v1/browser` only; "upload" = CI key, `/v1/sourcemaps`
|
|
19
|
+
* and `/v1/sourcemaps/check` only. Default: "upload" for an
|
|
20
|
+
* `autter_rtu_…` key, else "server". An entry with any other value is
|
|
21
|
+
* ignored (never treated as a server key).
|
|
22
|
+
*/
|
|
23
|
+
scope?: IngestKeyScope;
|
|
7
24
|
/** client keys: exact origins allowed to send (e.g. https://app.example.com). */
|
|
8
25
|
allowedOrigins?: string[];
|
|
9
26
|
}
|
|
@@ -61,4 +78,8 @@ export interface IngesterConfig {
|
|
|
61
78
|
*/
|
|
62
79
|
sourceMapCacheMb?: number;
|
|
63
80
|
}
|
|
81
|
+
/** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
|
|
82
|
+
* repositoryId, or with an unknown scope, are dropped (a typo in `scope`
|
|
83
|
+
* must not turn a restricted key into a server key). */
|
|
84
|
+
export declare function parseIngestKeys(raw: string | undefined): StaticIngestKey[];
|
|
64
85
|
export declare function loadConfig(): IngesterConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
export const INGEST_KEY_SCOPES = ["server", "client", "upload"];
|
|
2
|
+
/** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
|
|
3
|
+
export const UPLOAD_KEY_PREFIX = "autter_rtu_";
|
|
4
|
+
export function isIngestKeyScope(value) {
|
|
5
|
+
return typeof value === "string" && INGEST_KEY_SCOPES.includes(value);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The scope of a key whose static entry or validator answer names none:
|
|
9
|
+
* "upload" for an `autter_rtu_…` key (its prefix promises no more than
|
|
10
|
+
* that), else "server" as before 1.7.0.
|
|
11
|
+
*/
|
|
12
|
+
export function defaultScopeFor(key) {
|
|
13
|
+
return key.startsWith(UPLOAD_KEY_PREFIX) ? "upload" : "server";
|
|
14
|
+
}
|
|
1
15
|
function intEnv(name, fallback) {
|
|
2
16
|
const raw = process.env[name];
|
|
3
17
|
if (!raw)
|
|
@@ -19,22 +33,33 @@ function patternListEnv(name) {
|
|
|
19
33
|
new RegExp(source);
|
|
20
34
|
return parsed;
|
|
21
35
|
}
|
|
22
|
-
|
|
36
|
+
/** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
|
|
37
|
+
* repositoryId, or with an unknown scope, are dropped (a typo in `scope`
|
|
38
|
+
* must not turn a restricted key into a server key). */
|
|
39
|
+
export function parseIngestKeys(raw) {
|
|
23
40
|
if (!raw)
|
|
24
41
|
return [];
|
|
42
|
+
let parsed;
|
|
25
43
|
try {
|
|
26
|
-
|
|
27
|
-
if (!Array.isArray(parsed))
|
|
28
|
-
return [];
|
|
29
|
-
return parsed.filter((entry) => entry &&
|
|
30
|
-
typeof entry.key === "string" &&
|
|
31
|
-
typeof entry.orgId === "string" &&
|
|
32
|
-
typeof entry.repositoryId === "string");
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
33
45
|
}
|
|
34
46
|
catch {
|
|
35
47
|
console.error("AUTTER_INGEST_KEYS is not valid JSON — ignoring");
|
|
36
48
|
return [];
|
|
37
49
|
}
|
|
50
|
+
if (!Array.isArray(parsed))
|
|
51
|
+
return [];
|
|
52
|
+
const keys = [];
|
|
53
|
+
parsed.forEach((entry, index) => {
|
|
54
|
+
if (!entry || typeof entry.key !== "string" || typeof entry.orgId !== "string" || typeof entry.repositoryId !== "string")
|
|
55
|
+
return;
|
|
56
|
+
if (entry.scope !== undefined && !isIngestKeyScope(entry.scope)) {
|
|
57
|
+
console.error(`AUTTER_INGEST_KEYS[${index}] has scope ${JSON.stringify(entry.scope)}; expected one of ${INGEST_KEY_SCOPES.join(", ")} — ignoring this key`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
keys.push({ ...entry, scope: entry.scope ?? defaultScopeFor(entry.key) });
|
|
61
|
+
});
|
|
62
|
+
return keys;
|
|
38
63
|
}
|
|
39
64
|
export function loadConfig() {
|
|
40
65
|
const config = {
|
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,6 @@
|
|
|
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 { DROP_REASONS, IngestDropCounter, type DropKind, type DropReason, type DropRoute, type IngestDrop, type IngestDropRow, } from "./ingest-drops.js";
|
|
5
6
|
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 } = 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,12 @@ async function shutdown(signal) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
server.close(() => {
|
|
28
|
-
|
|
28
|
+
// Write the rejection/drop counts still buffered (best effort: a
|
|
29
|
+
// failed write is logged, never retried).
|
|
30
|
+
void drops
|
|
31
|
+
.stop()
|
|
32
|
+
.finally(() => store.close())
|
|
33
|
+
.finally(() => process.exit(0));
|
|
29
34
|
});
|
|
30
35
|
setTimeout(() => process.exit(1), 10_000).unref();
|
|
31
36
|
}
|
|
@@ -35,4 +40,5 @@ export { createIngesterApp } from "./server.js";
|
|
|
35
40
|
export { loadConfig } from "./config.js";
|
|
36
41
|
export * from "./compat.js";
|
|
37
42
|
export { SinkForwarder } from "./sink.js";
|
|
43
|
+
export { DROP_REASONS, IngestDropCounter, } from "./ingest-drops.js";
|
|
38
44
|
export * from "./types.js";
|