@autter/otlp-ingester 1.7.0 → 1.9.0

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