@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 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 `scope` one of
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
  }
@@ -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
- // Write the rejection/drop counts still buffered (best effort: a
29
- // failed write is logged, never retried).
30
- void drops
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";
@@ -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. */
@@ -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 = [
@@ -48,9 +48,40 @@ export interface PromotionLookup {
48
48
  */
49
49
  export declare function promotionLookups(occurrences: RuntimeOccurrenceInput[], now?: number): PromotionLookup[];
50
50
  /**
51
- * One error per promoted occurrence, in `error_count` only — the same
52
- * counting the trace path applies to errors outside a request (a log record
53
- * names no request the rollups count), so error counters for logger-only and
54
- * edge services don't read 0 and no request is invented.
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 function promotionRollups(occurrences: RuntimeOccurrence[]): RuntimeMetricPoint[];
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[];
@@ -81,38 +81,93 @@ export function promotionLookups(occurrences, now = Date.now()) {
81
81
  return lookups;
82
82
  }
83
83
  /**
84
- * One error per promoted occurrence, in `error_count` only — the same
85
- * counting the trace path applies to errors outside a request (a log record
86
- * names no request the rollups count), so error counters for logger-only and
87
- * edge services don't read 0 and no request is invented.
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 function promotionRollups(occurrences) {
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
- for (const occ of occurrences) {
92
- const bucketAt = new Date(Math.floor(occ.occurredAt.getTime() / 60_000) * 60_000);
93
- const route = normalizeRoute(occ.route);
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.errorCount += 1;
104
- continue;
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
- bucketAt,
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
+ }