@autter/otlp-ingester 1.8.0 → 1.10.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 +25 -4
- package/dist/auth.js +8 -0
- package/dist/clickhouse.js +4 -1
- package/dist/compat-manifest.json +22 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -4
- package/dist/ingest-drops.d.ts +22 -1
- package/dist/ingest-drops.js +6 -0
- package/dist/log-promotion.d.ts +36 -5
- package/dist/log-promotion.js +76 -21
- package/dist/loop-collapse.d.ts +144 -0
- package/dist/loop-collapse.js +326 -0
- package/dist/migrations.d.ts +12 -0
- package/dist/migrations.js +17 -0
- package/dist/normalize-browser.d.ts +8 -8
- package/dist/server.d.ts +3 -0
- package/dist/server.js +66 -10
- package/dist/sink.d.ts +5 -2
- package/dist/sink.js +8 -3
- package/dist/suppression.d.ts +79 -0
- package/dist/suppression.js +203 -0
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,8 +57,28 @@ 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).
|
|
62
82
|
|
|
63
83
|
### Rejections and drops (1.8.0)
|
|
64
84
|
|
|
@@ -93,8 +113,8 @@ Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILIT
|
|
|
93
113
|
| `CLICKHOUSE_URL` | — | e.g. `http://localhost:8123`; unset = ingest returns 503 |
|
|
94
114
|
| `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` | `default` / empty | |
|
|
95
115
|
| `CLICKHOUSE_DATABASE` | `autter_runtime` | Created automatically |
|
|
96
|
-
| `AUTTER_INGEST_KEYS` | — | JSON: `[{"key":"...","orgId":"...","repositoryId":"..."}]` |
|
|
97
|
-
| `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) |
|
|
98
118
|
| `AUTTER_KEY_VALIDATOR_TOKEN` | — | Bearer token sent to the validator |
|
|
99
119
|
| `AUTTER_SINK_URL` | — | Issue-grouping webhook; at-least-once (`docs/ARCHITECTURE.md`) |
|
|
100
120
|
| `AUTTER_SINK_TOKEN` | — | Bearer token sent to the sink |
|
|
@@ -103,6 +123,7 @@ Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILIT
|
|
|
103
123
|
| `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
|
|
104
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 |
|
|
105
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 |
|
|
106
127
|
| `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
|
|
107
128
|
| `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
|
|
108
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.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
|
}
|
|
@@ -71,11 +77,13 @@ export class KeyResolver {
|
|
|
71
77
|
const body = (await response.json());
|
|
72
78
|
const scope = body.scope === undefined || body.scope === null ? defaultScopeFor(key) : body.scope;
|
|
73
79
|
if (body.orgId && body.repositoryId && isIngestKeyScope(scope)) {
|
|
80
|
+
const suppression = parseSuppressionRules(body.suppression);
|
|
74
81
|
ctx = {
|
|
75
82
|
orgId: body.orgId,
|
|
76
83
|
repositoryId: body.repositoryId,
|
|
77
84
|
scope,
|
|
78
85
|
allowedOrigins: body.allowedOrigins ?? [],
|
|
86
|
+
...(suppression.length ? { suppression } : {}),
|
|
79
87
|
};
|
|
80
88
|
}
|
|
81
89
|
}
|
package/dist/clickhouse.js
CHANGED
|
@@ -9,7 +9,7 @@ import { logTableDDL, requestRollupTableDDL, } from "./logs.js";
|
|
|
9
9
|
import { ingesterInfoTableDDL, sdkVersionTableDDL, } from "./sdk-versions.js";
|
|
10
10
|
import { ingestDropsTableDDL } from "./ingest-drops.js";
|
|
11
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";
|
|
12
|
+
import { MIGRATIONS, OCCURRENCE_CODE_COLUMNS, OCCURRENCE_COLLAPSE_COLUMNS, OCCURRENCE_FRAME_RESOLUTION_COLUMNS, OCCURRENCE_GROUPING_COLUMNS, OCCURRENCE_REQUEST_ID_INDEX, migrationsTableDDL, } from "./migrations.js";
|
|
13
13
|
/**
|
|
14
14
|
* ClickHouse persistence. The schema is idempotent and applied lazily once
|
|
15
15
|
* per process. Every row is keyed by (org_id, repository_id) — tenant
|
|
@@ -113,6 +113,7 @@ export class ClickHouseStore {
|
|
|
113
113
|
${OCCURRENCE_CODE_COLUMNS.join(",\n\t\t\t\t")},
|
|
114
114
|
${OCCURRENCE_GROUPING_COLUMNS.join(",\n\t\t\t\t")},
|
|
115
115
|
${OCCURRENCE_FRAME_RESOLUTION_COLUMNS.join(",\n\t\t\t\t")},
|
|
116
|
+
${OCCURRENCE_COLLAPSE_COLUMNS.join(",\n\t\t\t\t")},
|
|
116
117
|
${OCCURRENCE_REQUEST_ID_INDEX}
|
|
117
118
|
)
|
|
118
119
|
ENGINE = MergeTree
|
|
@@ -421,6 +422,8 @@ export class ClickHouseStore {
|
|
|
421
422
|
fingerprint_scheme: o.fingerprintScheme,
|
|
422
423
|
frames: o.frames?.length ? JSON.stringify(o.frames) : "",
|
|
423
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))) } : {}),
|
|
424
427
|
})),
|
|
425
428
|
});
|
|
426
429
|
}
|
|
@@ -155,6 +155,28 @@
|
|
|
155
155
|
"ingester": "1.8.0",
|
|
156
156
|
"migrations": ["0020-occurrence-frame-resolution"],
|
|
157
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": {}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"id": "edge_request_counts",
|
|
175
|
+
"title": "Edge requests counted from their request summaries",
|
|
176
|
+
"ingester": "1.10.0",
|
|
177
|
+
"migrations": [],
|
|
178
|
+
"route": "/v1/logs",
|
|
179
|
+
"sdks": { "@autter/runtime-edge": "1.3.0" }
|
|
158
180
|
}
|
|
159
181
|
]
|
|
160
182
|
}
|
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/index.d.ts
CHANGED
|
@@ -2,5 +2,7 @@ export { createIngesterApp } from "./server.js";
|
|
|
2
2
|
export { loadConfig } from "./config.js";
|
|
3
3
|
export * from "./compat.js";
|
|
4
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";
|
|
5
7
|
export { DROP_REASONS, IngestDropCounter, type DropKind, type DropReason, type DropRoute, type IngestDrop, type IngestDropRow, } from "./ingest-drops.js";
|
|
6
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, drops } = 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,10 +25,14 @@ async function shutdown(signal) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
server.close(() => {
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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
|
|
31
34
|
.stop()
|
|
35
|
+
.finally(() => drops.stop())
|
|
32
36
|
.finally(() => store.close())
|
|
33
37
|
.finally(() => process.exit(0));
|
|
34
38
|
});
|
|
@@ -40,5 +44,7 @@ export { createIngesterApp } from "./server.js";
|
|
|
40
44
|
export { loadConfig } from "./config.js";
|
|
41
45
|
export * from "./compat.js";
|
|
42
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";
|
|
43
49
|
export { DROP_REASONS, IngestDropCounter, } from "./ingest-drops.js";
|
|
44
50
|
export * from "./types.js";
|
package/dist/ingest-drops.d.ts
CHANGED
|
@@ -19,11 +19,24 @@ import type { IngestContext } from "./types.js";
|
|
|
19
19
|
* - `unforwarded`: stored in ClickHouse, but the sink webhook gave up on
|
|
20
20
|
* the batch; the consumer recovers it by replaying ClickHouse.
|
|
21
21
|
*
|
|
22
|
+
* And two kinds of records left out on purpose (1.9.0, EI-38), counted so
|
|
23
|
+
* the dashboard can explain why stored rows are fewer than records sent:
|
|
24
|
+
* - `collapsed`: a looping error past its first occurrences of the minute
|
|
25
|
+
* (loop-collapse.ts); its payload was not stored, its count is, on the
|
|
26
|
+
* minute's collapsed sample (`collapsed_count`). `records` counts payloads
|
|
27
|
+
* not stored.
|
|
28
|
+
* - `suppressed`: occurrences a suppression rule set by the user dropped or
|
|
29
|
+
* sampled out (suppression.ts).
|
|
30
|
+
*
|
|
31
|
+
* Both are recorded with `requests` 0: no request was refused, and a
|
|
32
|
+
* dashboard before 1.9.0 (which reads an unknown kind as a rejection)
|
|
33
|
+
* finds nothing refused in the last hour.
|
|
34
|
+
*
|
|
22
35
|
* Counting never slows or fails ingest: `record()` is a synchronous map
|
|
23
36
|
* update, rows are written in batches every few seconds, a failed write is
|
|
24
37
|
* logged and dropped (never retried), and the buffer is bounded.
|
|
25
38
|
*/
|
|
26
|
-
export type DropKind = "rejected" | "dropped" | "truncated" | "unforwarded";
|
|
39
|
+
export type DropKind = "rejected" | "dropped" | "truncated" | "unforwarded" | "collapsed" | "suppressed";
|
|
27
40
|
/** Every reason, its kind, and (for rejections) the HTTP status it answers with. */
|
|
28
41
|
export declare const DROP_REASONS: {
|
|
29
42
|
/** No key in `Authorization`, `x-autter-key` or `?key=`. */
|
|
@@ -146,6 +159,14 @@ export declare const DROP_REASONS: {
|
|
|
146
159
|
readonly sink_gave_up: {
|
|
147
160
|
readonly kind: "unforwarded";
|
|
148
161
|
};
|
|
162
|
+
/** Occurrences of a looping error past the first LOOP_COLLAPSE_AFTER of the minute. */
|
|
163
|
+
readonly collapsed: {
|
|
164
|
+
readonly kind: "collapsed";
|
|
165
|
+
};
|
|
166
|
+
/** Occurrences a `drop` rule matched, or a `sample` rule sampled out. */
|
|
167
|
+
readonly suppressed: {
|
|
168
|
+
readonly kind: "suppressed";
|
|
169
|
+
};
|
|
149
170
|
};
|
|
150
171
|
export type DropReason = keyof typeof DROP_REASONS;
|
|
151
172
|
/** Routes counted by name; anything else is "other". `sink` is the webhook. */
|
package/dist/ingest-drops.js
CHANGED
|
@@ -56,6 +56,12 @@ export const DROP_REASONS = {
|
|
|
56
56
|
sink_rejected: { kind: "unforwarded" },
|
|
57
57
|
/** SINK_MAX_ATTEMPTS deliveries failed. */
|
|
58
58
|
sink_gave_up: { kind: "unforwarded" },
|
|
59
|
+
// ── collapsed: counted on the minute's sample, payload not stored (1.9.0) ──
|
|
60
|
+
/** Occurrences of a looping error past the first LOOP_COLLAPSE_AFTER of the minute. */
|
|
61
|
+
collapsed: { kind: "collapsed" },
|
|
62
|
+
// ── suppressed: a suppression rule set by the user left them out (1.9.0) ──
|
|
63
|
+
/** Occurrences a `drop` rule matched, or a `sample` rule sampled out. */
|
|
64
|
+
suppressed: { kind: "suppressed" },
|
|
59
65
|
};
|
|
60
66
|
/** Routes counted by name; anything else is "other". `sink` is the webhook. */
|
|
61
67
|
export const DROP_ROUTES = [
|
package/dist/log-promotion.d.ts
CHANGED
|
@@ -48,9 +48,40 @@ export interface PromotionLookup {
|
|
|
48
48
|
*/
|
|
49
49
|
export declare function promotionLookups(occurrences: RuntimeOccurrenceInput[], now?: number): PromotionLookup[];
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
51
|
+
* `autter.request.counted_by = "summary"`: the request this record belongs to
|
|
52
|
+
* is counted in the usage rollups by its request summary (the record of
|
|
53
|
+
* `autter.operation.kind = "request"` carrying the same mark), because no
|
|
54
|
+
* other feed sees it. `@autter/runtime-edge` (1.3.0+) puts it on every
|
|
55
|
+
* record of a request: an edge function has no trace pipeline and no
|
|
56
|
+
* `http.server.duration` histogram, so without it edge services had error
|
|
57
|
+
* counts and no requests. Nothing else sets it: Node and Next.js summaries
|
|
58
|
+
* never carry it (their requests are counted by the histogram or the spans),
|
|
59
|
+
* so a request is counted by its summary only when its sender says the
|
|
60
|
+
* summary is the one count it gets.
|
|
55
61
|
*/
|
|
56
|
-
export declare
|
|
62
|
+
export declare const COUNTED_BY_ATTRIBUTE = "autter.request.counted_by";
|
|
63
|
+
/** On a counted summary: an error of the request was reported (an
|
|
64
|
+
* `autter.capture.mode = "log"` record), which makes it a failed request. */
|
|
65
|
+
export declare const ERROR_REPORTED_ATTRIBUTE = "autter.request.error_reported";
|
|
66
|
+
export declare function countedBySummary(attributes: Record<string, unknown> | null | undefined): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The usage rollups (`runtime_metrics_1m`) a /v1/logs batch adds, counted the
|
|
69
|
+
* way the trace path counts (normalize-otlp.ts): `request_count` counts
|
|
70
|
+
* requests only, `error_count` counts failed requests once and errors outside
|
|
71
|
+
* a counted request one each.
|
|
72
|
+
*
|
|
73
|
+
* - A request summary marked counted (`countedBySummary`) is one request:
|
|
74
|
+
* `request_count` 1, its duration, on its route and minute; `error_count` 1
|
|
75
|
+
* when it failed — outcome `failed`, a status ≥ 500, or an error reported
|
|
76
|
+
* in it (`autter.request.error_reported`), as an error occurrence recorded
|
|
77
|
+
* in a request fails it on the trace path.
|
|
78
|
+
* - A promoted occurrence (logger-only or edge error record) adds
|
|
79
|
+
* `error_count` 1 — unless its record is marked counted: its request's
|
|
80
|
+
* summary already counts that failure once, however many errors it had.
|
|
81
|
+
*
|
|
82
|
+
* The decision is per record, never by matching records across a batch, so
|
|
83
|
+
* a request whose records an exporter splits over two batches is still
|
|
84
|
+
* counted once. Unmarked summaries add nothing (they are counted elsewhere,
|
|
85
|
+
* or by no one, as before).
|
|
86
|
+
*/
|
|
87
|
+
export declare function logRollups(rows: RuntimeLogRecord[], promoted: RuntimeOccurrence[]): RuntimeMetricPoint[];
|
package/dist/log-promotion.js
CHANGED
|
@@ -81,38 +81,93 @@ export function promotionLookups(occurrences, now = Date.now()) {
|
|
|
81
81
|
return lookups;
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
84
|
+
* `autter.request.counted_by = "summary"`: the request this record belongs to
|
|
85
|
+
* is counted in the usage rollups by its request summary (the record of
|
|
86
|
+
* `autter.operation.kind = "request"` carrying the same mark), because no
|
|
87
|
+
* other feed sees it. `@autter/runtime-edge` (1.3.0+) puts it on every
|
|
88
|
+
* record of a request: an edge function has no trace pipeline and no
|
|
89
|
+
* `http.server.duration` histogram, so without it edge services had error
|
|
90
|
+
* counts and no requests. Nothing else sets it: Node and Next.js summaries
|
|
91
|
+
* never carry it (their requests are counted by the histogram or the spans),
|
|
92
|
+
* so a request is counted by its summary only when its sender says the
|
|
93
|
+
* summary is the one count it gets.
|
|
88
94
|
*/
|
|
89
|
-
export
|
|
95
|
+
export const COUNTED_BY_ATTRIBUTE = "autter.request.counted_by";
|
|
96
|
+
/** On a counted summary: an error of the request was reported (an
|
|
97
|
+
* `autter.capture.mode = "log"` record), which makes it a failed request. */
|
|
98
|
+
export const ERROR_REPORTED_ATTRIBUTE = "autter.request.error_reported";
|
|
99
|
+
export function countedBySummary(attributes) {
|
|
100
|
+
return attributes?.[COUNTED_BY_ATTRIBUTE] === "summary";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The usage rollups (`runtime_metrics_1m`) a /v1/logs batch adds, counted the
|
|
104
|
+
* way the trace path counts (normalize-otlp.ts): `request_count` counts
|
|
105
|
+
* requests only, `error_count` counts failed requests once and errors outside
|
|
106
|
+
* a counted request one each.
|
|
107
|
+
*
|
|
108
|
+
* - A request summary marked counted (`countedBySummary`) is one request:
|
|
109
|
+
* `request_count` 1, its duration, on its route and minute; `error_count` 1
|
|
110
|
+
* when it failed — outcome `failed`, a status ≥ 500, or an error reported
|
|
111
|
+
* in it (`autter.request.error_reported`), as an error occurrence recorded
|
|
112
|
+
* in a request fails it on the trace path.
|
|
113
|
+
* - A promoted occurrence (logger-only or edge error record) adds
|
|
114
|
+
* `error_count` 1 — unless its record is marked counted: its request's
|
|
115
|
+
* summary already counts that failure once, however many errors it had.
|
|
116
|
+
*
|
|
117
|
+
* The decision is per record, never by matching records across a batch, so
|
|
118
|
+
* a request whose records an exporter splits over two batches is still
|
|
119
|
+
* counted once. Unmarked summaries add nothing (they are counted elsewhere,
|
|
120
|
+
* or by no one, as before).
|
|
121
|
+
*/
|
|
122
|
+
export function logRollups(rows, promoted) {
|
|
90
123
|
const rollups = new Map();
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const key = [
|
|
95
|
-
occ.service,
|
|
96
|
-
occ.environment,
|
|
97
|
-
occ.release ?? "",
|
|
98
|
-
route,
|
|
99
|
-
bucketAt.getTime(),
|
|
100
|
-
].join(" ");
|
|
124
|
+
const add = (point) => {
|
|
125
|
+
const { at, ...rest } = point;
|
|
126
|
+
const bucketAt = new Date(Math.floor(at.getTime() / 60_000) * 60_000);
|
|
127
|
+
const key = [rest.service, rest.environment, rest.release ?? "", rest.route, bucketAt.getTime()].join(" ");
|
|
101
128
|
const existing = rollups.get(key);
|
|
102
129
|
if (existing) {
|
|
103
|
-
existing.
|
|
104
|
-
|
|
130
|
+
existing.requestCount += rest.requestCount;
|
|
131
|
+
existing.errorCount += rest.errorCount;
|
|
132
|
+
existing.durationSumMs += rest.durationSumMs;
|
|
133
|
+
return;
|
|
105
134
|
}
|
|
106
|
-
rollups.set(key, {
|
|
135
|
+
rollups.set(key, { ...rest, bucketAt, sessionCount: 0 });
|
|
136
|
+
};
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
if (row.kind !== "request" || !countedBySummary(row.attributes))
|
|
139
|
+
continue;
|
|
140
|
+
const failed = row.outcome === "failed" ||
|
|
141
|
+
row.statusCode >= 500 ||
|
|
142
|
+
row.attributes[ERROR_REPORTED_ATTRIBUTE] === true;
|
|
143
|
+
add({
|
|
144
|
+
service: row.service,
|
|
145
|
+
environment: row.environment,
|
|
146
|
+
release: row.release || null,
|
|
147
|
+
// Already normalized (logs.ts), the key promoted errors use too.
|
|
148
|
+
route: row.route,
|
|
149
|
+
at: row.occurredAt,
|
|
150
|
+
requestCount: 1,
|
|
151
|
+
errorCount: failed ? 1 : 0,
|
|
152
|
+
durationSumMs: row.durationMs,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
for (const occ of promoted) {
|
|
156
|
+
// Its request's summary counts this failure.
|
|
157
|
+
if (countedBySummary(occ.attributes))
|
|
158
|
+
continue;
|
|
159
|
+
// One error of its own, in `error_count` only — what the trace path
|
|
160
|
+
// does for an error outside a request: the record names no request
|
|
161
|
+
// the rollups count, so no request is invented.
|
|
162
|
+
add({
|
|
107
163
|
service: occ.service,
|
|
108
164
|
environment: occ.environment,
|
|
109
165
|
release: occ.release,
|
|
110
|
-
route,
|
|
111
|
-
|
|
166
|
+
route: normalizeRoute(occ.route),
|
|
167
|
+
at: occ.occurredAt,
|
|
112
168
|
requestCount: 0,
|
|
113
169
|
errorCount: 1,
|
|
114
170
|
durationSumMs: 0,
|
|
115
|
-
sessionCount: 0,
|
|
116
171
|
});
|
|
117
172
|
}
|
|
118
173
|
return [...rollups.values()];
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { DropRoute } from "./ingest-drops.js";
|
|
2
|
+
import type { IngestContext, RuntimeOccurrence } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Loop collapse (1.9.0, EI-38): an error that repeats in a loop is stored
|
|
5
|
+
* as its first N occurrences of the minute plus ONE sample that carries the
|
|
6
|
+
* exact count of the rest, so a loop of 10,000 events a minute costs N + 1
|
|
7
|
+
* rows a minute, not 10,000, and only duplicate payloads are lost.
|
|
8
|
+
*
|
|
9
|
+
* Identical: same tenant, ingest route and arrival minute, and the same
|
|
10
|
+
* - identity: legacy `fingerprint` and every `fingerprints` candidate;
|
|
11
|
+
* - service, environment, release, source, severity, `expected`;
|
|
12
|
+
* - error type and declared error code;
|
|
13
|
+
* - normalised message (`messageNormalized`: ids, numbers and quoted
|
|
14
|
+
* strings templated out) and normalised top frames (`topFrames`);
|
|
15
|
+
* - session id: an error hitting many sessions is volume, not a loop, and
|
|
16
|
+
* each session must still count as a user.
|
|
17
|
+
* Trace, request and occurrence ids, route, timestamps, attributes and
|
|
18
|
+
* breadcrumbs may differ: they are what a loop varies.
|
|
19
|
+
*
|
|
20
|
+
* Per window (that identity in one arrival minute): the first `after`
|
|
21
|
+
* occurrences (LOOP_COLLAPSE_AFTER, default 10) are stored and forwarded as
|
|
22
|
+
* they arrive. Every later one is folded: not stored on its own, counted.
|
|
23
|
+
* When the minute is over, the window's newest folded occurrence is stored
|
|
24
|
+
* and forwarded as the sample with `collapsedCount` = folded − 1, so the
|
|
25
|
+
* sample stands for every folded occurrence: stored rows + Σ collapsed_count
|
|
26
|
+
* is exactly the number of occurrences accepted. Its time is the newest
|
|
27
|
+
* folded one, so an issue's last-seen stays exact; its weight lands in the
|
|
28
|
+
* minute it arrived in.
|
|
29
|
+
*
|
|
30
|
+
* Exactness under retries: a request's folds are reserved when it is planned
|
|
31
|
+
* and only counted once its ClickHouse writes succeed (`commit`); a request
|
|
32
|
+
* that fails (503, the exporter retries) gives them back (`rollback`). Ids
|
|
33
|
+
* already seen in the window are recognised, so a batch retried after a lost
|
|
34
|
+
* 2xx is not counted twice. A window is flushed only once no request still
|
|
35
|
+
* holds it (or, as a safety net, five minutes after its minute); a fold that
|
|
36
|
+
* commits after that is carried into the next flush. A failed flush write is
|
|
37
|
+
* kept and retried every tick. Shutdown (SIGTERM) flushes every window, so a
|
|
38
|
+
* deploy loses nothing; only a crash loses the folded counts of the minute in
|
|
39
|
+
* progress.
|
|
40
|
+
*
|
|
41
|
+
* Memory is bounded (safety, not a work cap): windows live for their minute
|
|
42
|
+
* and are cleared on the next flush; past `maxWindows` windows a minute, or
|
|
43
|
+
* `maxSamples` held samples, occurrences of new identities pass through
|
|
44
|
+
* uncollapsed (stored as usual, never lost), and past `maxTrackedIds` folded
|
|
45
|
+
* ids a minute, retry recognition stops for new folds.
|
|
46
|
+
*/
|
|
47
|
+
export interface LoopCollapseOptions {
|
|
48
|
+
/** Occurrences stored per window before folding starts; 0 turns collapse off. */
|
|
49
|
+
after: number;
|
|
50
|
+
/** Windows (identities) per minute. Default 100,000. */
|
|
51
|
+
maxWindows?: number;
|
|
52
|
+
/** Samples held across all windows until they are flushed. Default 5,000. */
|
|
53
|
+
maxSamples?: number;
|
|
54
|
+
/** Folded occurrence ids remembered per minute, for retry recognition. Default 500,000. */
|
|
55
|
+
maxTrackedIds?: number;
|
|
56
|
+
/** How often due windows are flushed. Default 5 s. */
|
|
57
|
+
flushIntervalMs?: number;
|
|
58
|
+
/** A window still held by a request is flushed anyway this long after its minute. Default 4 min. */
|
|
59
|
+
forceAfterMs?: number;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
log?: (message: string) => void;
|
|
62
|
+
}
|
|
63
|
+
/** One window's sample, ready to store: `sample.collapsedCount` = folded − 1. */
|
|
64
|
+
export interface CollapsedSample {
|
|
65
|
+
route: DropRoute;
|
|
66
|
+
sample: RuntimeOccurrence;
|
|
67
|
+
/** Occurrences the sample stands for (itself included). */
|
|
68
|
+
folded: number;
|
|
69
|
+
}
|
|
70
|
+
/** Store and forward one tenant's samples. A rejection keeps them for the next tick. */
|
|
71
|
+
export type CollapseFlush = (ctx: IngestContext, samples: CollapsedSample[]) => Promise<void>;
|
|
72
|
+
export interface CollapsePlan {
|
|
73
|
+
/** Occurrences to store and forward now. */
|
|
74
|
+
kept: RuntimeOccurrence[];
|
|
75
|
+
/** Occurrences folded into a window's sample by this request. */
|
|
76
|
+
folded: number;
|
|
77
|
+
/** Occurrences recognised as already counted (a retried batch); not stored again. */
|
|
78
|
+
repeated: number;
|
|
79
|
+
/** The request's writes succeeded: count its folds. */
|
|
80
|
+
commit(): void;
|
|
81
|
+
/** The request failed: give its reservations back. */
|
|
82
|
+
rollback(): void;
|
|
83
|
+
}
|
|
84
|
+
export interface LoopCollapseStats {
|
|
85
|
+
windows: number;
|
|
86
|
+
heldSamples: number;
|
|
87
|
+
/** Occurrences folded (committed) since start. */
|
|
88
|
+
folded: number;
|
|
89
|
+
/** Samples stored since start. */
|
|
90
|
+
samplesFlushed: number;
|
|
91
|
+
/** Occurrences that passed through uncollapsed because a bound was reached. */
|
|
92
|
+
overflow: number;
|
|
93
|
+
/** Flush writes that failed (their samples were kept and retried). */
|
|
94
|
+
failedFlushes: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The identity two occurrences must share to fold together (see above),
|
|
98
|
+
* hashed: the raw parts can run to kilobytes (frames, messages).
|
|
99
|
+
*/
|
|
100
|
+
export declare function collapseIdentity(o: RuntimeOccurrence): string;
|
|
101
|
+
export declare class LoopCollapser {
|
|
102
|
+
private readonly flushSink;
|
|
103
|
+
private readonly minutes;
|
|
104
|
+
/** Folds committed after their window was flushed, and failed flushes. */
|
|
105
|
+
private carry;
|
|
106
|
+
private heldSamples;
|
|
107
|
+
private trackedIds;
|
|
108
|
+
private timer;
|
|
109
|
+
private flushing;
|
|
110
|
+
private lastFailureLog;
|
|
111
|
+
private stats_;
|
|
112
|
+
private readonly after;
|
|
113
|
+
private readonly maxWindows;
|
|
114
|
+
private readonly maxSamples;
|
|
115
|
+
private readonly maxTrackedIds;
|
|
116
|
+
private readonly flushIntervalMs;
|
|
117
|
+
private readonly forceAfterMs;
|
|
118
|
+
private readonly now;
|
|
119
|
+
private readonly log;
|
|
120
|
+
constructor(flushSink: CollapseFlush, options: LoopCollapseOptions);
|
|
121
|
+
get enabled(): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Split a request's occurrences into those stored now and those folded
|
|
124
|
+
* into their window's sample. Synchronous; never throws (on an internal
|
|
125
|
+
* error every occurrence is kept).
|
|
126
|
+
*/
|
|
127
|
+
plan(ctx: IngestContext, route: DropRoute, occurrences: RuntimeOccurrence[]): CollapsePlan;
|
|
128
|
+
private planWindows;
|
|
129
|
+
/** Flush due windows every flushIntervalMs (unref'd). */
|
|
130
|
+
start(): void;
|
|
131
|
+
/** Stop the timer and flush every window, the current minute's too (shutdown). */
|
|
132
|
+
stop(): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Store the samples of every window whose minute is over and that no
|
|
135
|
+
* request still holds, plus carried folds and earlier failed writes.
|
|
136
|
+
* `all`: every window, current minute included. One flush at a time;
|
|
137
|
+
* never rejects.
|
|
138
|
+
*/
|
|
139
|
+
flush(all?: boolean): Promise<void>;
|
|
140
|
+
private collectDue;
|
|
141
|
+
private flushDue;
|
|
142
|
+
private releaseSlot;
|
|
143
|
+
stats(): LoopCollapseStats;
|
|
144
|
+
}
|