@autter/otlp-ingester 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +31 -5
  2. package/dist/auth.d.ts +16 -1
  3. package/dist/auth.js +34 -10
  4. package/dist/clickhouse.d.ts +3 -0
  5. package/dist/clickhouse.js +38 -1
  6. package/dist/compat-manifest.json +37 -0
  7. package/dist/config.d.ts +23 -2
  8. package/dist/config.js +33 -8
  9. package/dist/context.d.ts +5 -0
  10. package/dist/context.js +49 -10
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +8 -2
  13. package/dist/ingest-drops.d.ts +308 -0
  14. package/dist/ingest-drops.js +448 -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/memory.d.ts +9 -1
  20. package/dist/memory.js +28 -7
  21. package/dist/migrations.d.ts +8 -0
  22. package/dist/migrations.js +29 -1
  23. package/dist/normalize-browser.d.ts +53 -25
  24. package/dist/normalize-browser.js +56 -10
  25. package/dist/normalize-otlp.d.ts +6 -0
  26. package/dist/normalize-otlp.js +275 -108
  27. package/dist/otlp-proto.d.ts +14 -0
  28. package/dist/otlp-proto.js +25 -0
  29. package/dist/profiles.d.ts +4 -0
  30. package/dist/profiles.js +6 -2
  31. package/dist/request-spans.d.ts +45 -0
  32. package/dist/request-spans.js +91 -0
  33. package/dist/sdk-versions.d.ts +39 -2
  34. package/dist/sdk-versions.js +49 -8
  35. package/dist/server.d.ts +10 -0
  36. package/dist/server.js +345 -105
  37. package/dist/sink.d.ts +14 -0
  38. package/dist/sink.js +15 -0
  39. package/dist/source-maps-lookup.d.ts +6 -3
  40. package/dist/source-maps-lookup.js +35 -13
  41. package/dist/source-maps.d.ts +6 -2
  42. package/dist/source-maps.js +12 -4
  43. package/dist/stack-frames.d.ts +5 -2
  44. package/dist/stack-frames.js +55 -9
  45. package/dist/symbolicate.d.ts +13 -0
  46. package/dist/symbolicate.js +22 -0
  47. package/dist/trace-dedupe.d.ts +98 -0
  48. package/dist/trace-dedupe.js +385 -0
  49. package/dist/types.d.ts +5 -1
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -31,6 +31,16 @@ works with its default exporter settings.
31
31
  | --- | --- | --- | --- |
32
32
  | `server` (default) | `autter_rt_…` (secret) | all endpoints | 300 req/min |
33
33
  | `client` | `autter_rtc_…` (publishable, safe in frontend bundles) | `/v1/browser` only | origin allow-list, 120 req/min |
34
+ | `upload` (1.7.0+) | `autter_rtu_…` (secret, for CI) | `/v1/sourcemaps` and `/v1/sourcemaps/check` only | 300 req/min |
35
+
36
+ An upload key gets `403 {"error":"upload_key_not_allowed","detail":"autter_rtu_ keys can only upload source maps …"}`
37
+ on every other route, so a CI secret that leaks can upload source maps for
38
+ one repository and nothing else. A static entry without `scope` is an upload
39
+ key when its key starts with `autter_rtu_`, else a server key; an entry (or a
40
+ validator answer) with any other `scope` is ignored rather than treated as a
41
+ server key. Ingesters before 1.7.0 treat every scope but `client` as
42
+ `server`, so a validator must not answer `"scope": "upload"` to them: from
43
+ 1.7.0 every validator request carries `x-autter-ingester-version`.
34
44
 
35
45
  `/v1/browser` answers CORS preflights permissively; real enforcement (key +
36
46
  origin allow-list) happens on the POST. Cross-origin browsers send
@@ -41,12 +51,27 @@ route parses as JSON.
41
51
  AUTTER_INGEST_KEYS='[
42
52
  {"key":"autter_rt_…","orgId":"org1","repositoryId":"repo1"},
43
53
  {"key":"autter_rtc_…","orgId":"org1","repositoryId":"repo1",
44
- "scope":"client","allowedOrigins":["https://app.example.com"]}
54
+ "scope":"client","allowedOrigins":["https://app.example.com"]},
55
+ {"key":"autter_rtu_…","orgId":"org1","repositoryId":"repo1","scope":"upload"}
45
56
  ]'
46
57
  ```
47
58
 
48
59
  The validator webhook may return the same extra fields:
49
- `{ orgId, repositoryId, scope?, allowedOrigins? }`.
60
+ `{ orgId, repositoryId, scope?, allowedOrigins? }`, with `scope` one of
61
+ `server`, `client`, `upload`.
62
+
63
+ ### Rejections and drops (1.8.0)
64
+
65
+ Everything the ingester refuses (401, 403, 404, 413, 415, 429, 400, 503) or
66
+ does not store from an accepted request (spans past 5,000, log records past
67
+ 2,000, cumulative HTTP histograms, invalid browser events, …) is counted per
68
+ repository, minute, route and reason in `runtime_ingest_drops`; keys that
69
+ don't resolve count against an empty tenant with the sha256 of the key when
70
+ it is an Autter key. OTLP answers name dropped records in `partial_success`
71
+ (JSON and protobuf), so collectors log them. Counts are written every 5 s in
72
+ one batch, bounded in memory, and a failed write is logged and dropped:
73
+ counting never slows or fails ingest. Reasons and queries:
74
+ [docs/ARCHITECTURE.md § Rejections and drops](../../docs/ARCHITECTURE.md#rejections-and-drops).
50
75
 
51
76
  ### Version compatibility
52
77
 
@@ -55,9 +80,10 @@ its ClickHouse schema state, and which features from
55
80
  `src/compat-manifest.json` it supports. Add
56
81
  `?features=operation_logging&sdk=@autter/runtime-node@1.3.0` to get the evaluated
57
82
  incompatibilities. Every response carries `x-autter-ingester-version`. The
58
- ingester records the SDK name and version each service sends with
59
- (`runtime_sdk_versions`) and its own version (`runtime_ingester_info`), and
60
- the Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILITY.md).
83
+ ingester records the SDK name and version each service sends with, and
84
+ whether it came through an OpenTelemetry Collector (`runtime_sdk_versions`,
85
+ `via` from 1.8.0), and its own version (`runtime_ingester_info`), and the
86
+ Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILITY.md).
61
87
 
62
88
  ## Configuration
63
89
 
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  import type { Request } from "express";
2
- import type { IngesterConfig } from "./config.js";
2
+ import { type IngesterConfig } from "./config.js";
3
3
  import type { IngestContext } from "./types.js";
4
+ /**
5
+ * Why a key did not resolve: `unknown_key` — not a static key and the
6
+ * validator did not accept it; `key_unverified` — the validator could not
7
+ * answer (unreachable, timed out or a 5xx) and no earlier answer was cached.
8
+ */
9
+ export type KeyFailure = "unknown_key" | "key_unverified";
10
+ export type KeyResolution = {
11
+ ctx: IngestContext;
12
+ failure?: undefined;
13
+ } | {
14
+ ctx: null;
15
+ failure: KeyFailure;
16
+ };
4
17
  export declare class KeyResolver {
5
18
  private readonly config;
6
19
  private readonly staticKeys;
@@ -8,6 +21,8 @@ export declare class KeyResolver {
8
21
  constructor(config: IngesterConfig);
9
22
  extractKey(req: Request): string | null;
10
23
  resolve(key: string): Promise<IngestContext | null>;
24
+ /** `resolve`, plus why a key that did not resolve failed (for drop counts). */
25
+ resolveDetailed(key: string): Promise<KeyResolution>;
11
26
  }
12
27
  /** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
13
28
  export declare class RateLimiter {
package/dist/auth.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { INGESTER_VERSION_HEADER } from "./compat.js";
2
+ import { defaultScopeFor, isIngestKeyScope } from "./config.js";
3
+ import { ingesterVersion } from "./sdk-versions.js";
1
4
  const VALIDATOR_CACHE_TTL_MS = 60_000;
2
5
  export class KeyResolver {
3
6
  config;
@@ -6,10 +9,13 @@ export class KeyResolver {
6
9
  constructor(config) {
7
10
  this.config = config;
8
11
  for (const entry of config.ingestKeys) {
12
+ const scope = entry.scope ?? defaultScopeFor(entry.key);
13
+ if (!isIngestKeyScope(scope))
14
+ continue;
9
15
  this.staticKeys.set(entry.key, {
10
16
  orgId: entry.orgId,
11
17
  repositoryId: entry.repositoryId,
12
- scope: entry.scope ?? "server",
18
+ scope,
13
19
  allowedOrigins: entry.allowedOrigins ?? [],
14
20
  });
15
21
  }
@@ -30,20 +36,30 @@ export class KeyResolver {
30
36
  return null;
31
37
  }
32
38
  async resolve(key) {
39
+ return (await this.resolveDetailed(key)).ctx;
40
+ }
41
+ /** `resolve`, plus why a key that did not resolve failed (for drop counts). */
42
+ async resolveDetailed(key) {
33
43
  const staticCtx = this.staticKeys.get(key);
34
44
  if (staticCtx)
35
- return staticCtx;
45
+ return { ctx: staticCtx };
36
46
  if (!this.config.keyValidatorUrl)
37
- return null;
47
+ return { ctx: null, failure: "unknown_key" };
38
48
  const cached = this.cache.get(key);
39
49
  if (cached && cached.expiresAt > Date.now())
40
- return cached.ctx;
50
+ return resolution(cached);
41
51
  let ctx = null;
52
+ let failure = "unknown_key";
42
53
  try {
43
54
  const response = await fetch(this.config.keyValidatorUrl, {
44
55
  method: "POST",
45
56
  headers: {
46
57
  "content-type": "application/json",
58
+ // Lets the validator tell which scopes this ingester enforces:
59
+ // before 1.7.0 any scope but "client" meant a server key, so an
60
+ // upload key must not be answered as "upload" to an ingester
61
+ // that doesn't send this header (or sends an older version).
62
+ [INGESTER_VERSION_HEADER]: ingesterVersion(),
47
63
  ...(this.config.keyValidatorToken
48
64
  ? { authorization: `Bearer ${this.config.keyValidatorToken}` }
49
65
  : {}),
@@ -53,33 +69,41 @@ export class KeyResolver {
53
69
  });
54
70
  if (response.ok) {
55
71
  const body = (await response.json());
56
- if (body.orgId && body.repositoryId) {
72
+ const scope = body.scope === undefined || body.scope === null ? defaultScopeFor(key) : body.scope;
73
+ if (body.orgId && body.repositoryId && isIngestKeyScope(scope)) {
57
74
  ctx = {
58
75
  orgId: body.orgId,
59
76
  repositoryId: body.repositoryId,
60
- scope: body.scope ?? "server",
77
+ scope,
61
78
  allowedOrigins: body.allowedOrigins ?? [],
62
79
  };
63
80
  }
64
81
  }
82
+ else if (response.status >= 500) {
83
+ failure = "key_unverified";
84
+ }
65
85
  }
66
86
  catch {
67
87
  // Validator unreachable: fail closed for unknown keys, but reuse a
68
88
  // stale cache entry if we have one so transient validator outages
69
89
  // don't drop telemetry from known-good keys.
70
90
  if (cached)
71
- return cached.ctx;
72
- return null;
91
+ return resolution(cached);
92
+ return { ctx: null, failure: "key_unverified" };
73
93
  }
74
- this.cache.set(key, { ctx, expiresAt: Date.now() + VALIDATOR_CACHE_TTL_MS });
94
+ const entry = { ctx, expiresAt: Date.now() + VALIDATOR_CACHE_TTL_MS, ...(ctx ? {} : { failure }) };
95
+ this.cache.set(key, entry);
75
96
  if (this.cache.size > 10_000) {
76
97
  const oldest = this.cache.keys().next().value;
77
98
  if (oldest !== undefined)
78
99
  this.cache.delete(oldest);
79
100
  }
80
- return ctx;
101
+ return resolution(entry);
81
102
  }
82
103
  }
104
+ function resolution(entry) {
105
+ return entry.ctx ? { ctx: entry.ctx } : { ctx: null, failure: entry.failure ?? "unknown_key" };
106
+ }
83
107
  /** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
84
108
  export class RateLimiter {
85
109
  limitPerMinute;
@@ -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_FRAME_RESOLUTION_COLUMNS, OCCURRENCE_GROUPING_COLUMNS, OCCURRENCE_REQUEST_ID_INDEX, migrationsTableDDL, } from "./migrations.js";
11
13
  /**
12
14
  * ClickHouse persistence. The schema is idempotent and applied lazily once
13
15
  * per process. Every row is keyed by (org_id, repository_id) — tenant
@@ -22,6 +24,14 @@ const INSERT_SETTINGS = {
22
24
  /** Source maps above this go in with a plain insert (below ClickHouse's
23
25
  * default async_insert_max_data_size of 10 MiB). */
24
26
  const ASYNC_INSERT_MAX_MAP_CHARS = 8 * 1024 * 1024;
27
+ /** Migration 0020 columns: browser occurrences only (server frames are not symbolicated). */
28
+ function occurrenceFrameResolution(o) {
29
+ const counts = o.source === "browser" ? inAppFrameResolution(o.frames) : { resolved: 0, unresolved: 0 };
30
+ return {
31
+ in_app_frames_resolved: Math.min(65_535, counts.resolved),
32
+ in_app_frames_unresolved: Math.min(65_535, counts.unresolved),
33
+ };
34
+ }
25
35
  /** "YYYY-MM-DD hh:mm:ss.sss" (UTC) — the DateTime64 query-parameter form. */
26
36
  function chDateTime(date) {
27
37
  return date.toISOString().replace("T", " ").replace("Z", "");
@@ -74,6 +84,7 @@ export class ClickHouseStore {
74
84
  requestRollupTableDDL(db),
75
85
  sdkVersionTableDDL(db, metricsTtlDays),
76
86
  ingesterInfoTableDDL(db),
87
+ ingestDropsTableDDL(db),
77
88
  `CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
78
89
  org_id String,
79
90
  repository_id String,
@@ -101,6 +112,7 @@ export class ClickHouseStore {
101
112
  ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
102
113
  ${OCCURRENCE_CODE_COLUMNS.join(",\n\t\t\t\t")},
103
114
  ${OCCURRENCE_GROUPING_COLUMNS.join(",\n\t\t\t\t")},
115
+ ${OCCURRENCE_FRAME_RESOLUTION_COLUMNS.join(",\n\t\t\t\t")},
104
116
  ${OCCURRENCE_REQUEST_ID_INDEX}
105
117
  )
106
118
  ENGINE = MergeTree
@@ -317,6 +329,30 @@ export class ClickHouseStore {
317
329
  sdk_language: row.sdkLanguage,
318
330
  ingester_version: row.ingesterVersion,
319
331
  last_seen: row.seenAt.toISOString(),
332
+ via: row.via,
333
+ })),
334
+ });
335
+ }
336
+ /** Rejection and drop counts (ingest-drops.ts); rows carry their own tenant. */
337
+ async insertIngestDrops(rows) {
338
+ if (!rows.length || !this.configured)
339
+ return;
340
+ await this.ensureSchema();
341
+ await this.getClient().insert({
342
+ table: this.table("runtime_ingest_drops"),
343
+ format: "JSONEachRow",
344
+ clickhouse_settings: INSERT_SETTINGS,
345
+ values: rows.map((row) => ({
346
+ org_id: row.orgId,
347
+ repository_id: row.repositoryId,
348
+ minute: row.minute.toISOString(),
349
+ route: row.route,
350
+ reason: row.reason,
351
+ kind: row.kind,
352
+ status: row.status,
353
+ key_hash: row.keyHash,
354
+ requests: row.requests,
355
+ records: row.records,
320
356
  })),
321
357
  });
322
358
  }
@@ -352,6 +388,7 @@ export class ClickHouseStore {
352
388
  format: "JSONEachRow",
353
389
  clickhouse_settings: INSERT_SETTINGS,
354
390
  values: occurrences.map((o) => ({
391
+ ...occurrenceFrameResolution(o),
355
392
  org_id: ctx.orgId,
356
393
  repository_id: ctx.repositoryId,
357
394
  occurrence_id: o.occurrenceId,
@@ -118,6 +118,43 @@
118
118
  "ingester": "1.6.0",
119
119
  "migrations": [],
120
120
  "sdks": { "@autter/runtime-browser": "1.6.0", "@autter/runtime-next": "1.6.0", "@autter/runtime-node": "1.6.0" }
121
+ },
122
+ {
123
+ "id": "sourcemap_upload_keys",
124
+ "title": "Upload-only keys for source maps (autter_rtu_)",
125
+ "ingester": "1.7.0",
126
+ "migrations": [],
127
+ "route": "/v1/sourcemaps",
128
+ "sdks": {}
129
+ },
130
+ {
131
+ "id": "source_map_verify",
132
+ "title": "Source map check by debug id (autter-runtime sourcemaps verify)",
133
+ "ingester": "1.7.0",
134
+ "migrations": ["0017-source-maps-debug-id"],
135
+ "route": "/v1/sourcemaps/check",
136
+ "sdks": {}
137
+ },
138
+ {
139
+ "id": "ingest_drop_counts",
140
+ "title": "Rejected and dropped records per repository",
141
+ "ingester": "1.8.0",
142
+ "migrations": ["0018-runtime-ingest-drops"],
143
+ "sdks": {}
144
+ },
145
+ {
146
+ "id": "telemetry_sender_via",
147
+ "title": "How each service's telemetry arrives (SDK, OpenTelemetry Collector or browser relay)",
148
+ "ingester": "1.8.0",
149
+ "migrations": ["0019-sdk-versions-via"],
150
+ "sdks": {}
151
+ },
152
+ {
153
+ "id": "frame_resolution_counts",
154
+ "title": "Resolved and unresolved browser frames per occurrence",
155
+ "ingester": "1.8.0",
156
+ "migrations": ["0020-occurrence-frame-resolution"],
157
+ "sdks": {}
121
158
  }
122
159
  ]
123
160
  }
package/dist/config.d.ts CHANGED
@@ -1,9 +1,26 @@
1
+ import type { IngestKeyScope } from "./types.js";
2
+ export declare const INGEST_KEY_SCOPES: readonly IngestKeyScope[];
3
+ /** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
4
+ export declare const UPLOAD_KEY_PREFIX = "autter_rtu_";
5
+ export declare function isIngestKeyScope(value: unknown): value is IngestKeyScope;
6
+ /**
7
+ * The scope of a key whose static entry or validator answer names none:
8
+ * "upload" for an `autter_rtu_…` key (its prefix promises no more than
9
+ * that), else "server" as before 1.7.0.
10
+ */
11
+ export declare function defaultScopeFor(key: string): IngestKeyScope;
1
12
  export interface StaticIngestKey {
2
13
  key: string;
3
14
  orgId: string;
4
15
  repositoryId: string;
5
- /** "server" (default) = secret backend key; "client" = publishable browser key. */
6
- scope?: "client" | "server";
16
+ /**
17
+ * "server" = secret backend key, every route; "client" = publishable
18
+ * browser key, `/v1/browser` only; "upload" = CI key, `/v1/sourcemaps`
19
+ * and `/v1/sourcemaps/check` only. Default: "upload" for an
20
+ * `autter_rtu_…` key, else "server". An entry with any other value is
21
+ * ignored (never treated as a server key).
22
+ */
23
+ scope?: IngestKeyScope;
7
24
  /** client keys: exact origins allowed to send (e.g. https://app.example.com). */
8
25
  allowedOrigins?: string[];
9
26
  }
@@ -61,4 +78,8 @@ export interface IngesterConfig {
61
78
  */
62
79
  sourceMapCacheMb?: number;
63
80
  }
81
+ /** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
82
+ * repositoryId, or with an unknown scope, are dropped (a typo in `scope`
83
+ * must not turn a restricted key into a server key). */
84
+ export declare function parseIngestKeys(raw: string | undefined): StaticIngestKey[];
64
85
  export declare function loadConfig(): IngesterConfig;
package/dist/config.js CHANGED
@@ -1,3 +1,17 @@
1
+ export const INGEST_KEY_SCOPES = ["server", "client", "upload"];
2
+ /** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
3
+ export const UPLOAD_KEY_PREFIX = "autter_rtu_";
4
+ export function isIngestKeyScope(value) {
5
+ return typeof value === "string" && INGEST_KEY_SCOPES.includes(value);
6
+ }
7
+ /**
8
+ * The scope of a key whose static entry or validator answer names none:
9
+ * "upload" for an `autter_rtu_…` key (its prefix promises no more than
10
+ * that), else "server" as before 1.7.0.
11
+ */
12
+ export function defaultScopeFor(key) {
13
+ return key.startsWith(UPLOAD_KEY_PREFIX) ? "upload" : "server";
14
+ }
1
15
  function intEnv(name, fallback) {
2
16
  const raw = process.env[name];
3
17
  if (!raw)
@@ -19,22 +33,33 @@ function patternListEnv(name) {
19
33
  new RegExp(source);
20
34
  return parsed;
21
35
  }
22
- function parseIngestKeys(raw) {
36
+ /** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
37
+ * repositoryId, or with an unknown scope, are dropped (a typo in `scope`
38
+ * must not turn a restricted key into a server key). */
39
+ export function parseIngestKeys(raw) {
23
40
  if (!raw)
24
41
  return [];
42
+ let parsed;
25
43
  try {
26
- const parsed = JSON.parse(raw);
27
- if (!Array.isArray(parsed))
28
- return [];
29
- return parsed.filter((entry) => entry &&
30
- typeof entry.key === "string" &&
31
- typeof entry.orgId === "string" &&
32
- typeof entry.repositoryId === "string");
44
+ parsed = JSON.parse(raw);
33
45
  }
34
46
  catch {
35
47
  console.error("AUTTER_INGEST_KEYS is not valid JSON — ignoring");
36
48
  return [];
37
49
  }
50
+ if (!Array.isArray(parsed))
51
+ return [];
52
+ const keys = [];
53
+ parsed.forEach((entry, index) => {
54
+ if (!entry || typeof entry.key !== "string" || typeof entry.orgId !== "string" || typeof entry.repositoryId !== "string")
55
+ return;
56
+ if (entry.scope !== undefined && !isIngestKeyScope(entry.scope)) {
57
+ console.error(`AUTTER_INGEST_KEYS[${index}] has scope ${JSON.stringify(entry.scope)}; expected one of ${INGEST_KEY_SCOPES.join(", ")} — ignoring this key`);
58
+ return;
59
+ }
60
+ keys.push({ ...entry, scope: entry.scope ?? defaultScopeFor(entry.key) });
61
+ });
62
+ return keys;
38
63
  }
39
64
  export function loadConfig() {
40
65
  const config = {
package/dist/context.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /** Run a synchronous normaliser and return what it cut along with its result. */
2
+ export declare function countTruncations<T>(normalise: () => T): {
3
+ result: T;
4
+ truncated: number;
5
+ };
1
6
  /** Bounded, defensive privacy boundary for custom telemetry from any OTLP SDK. */
2
7
  export declare function sanitizeRuntimeContext(input: unknown): Record<string, unknown>;
3
8
  export interface OtlpValue {
package/dist/context.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { isSensitiveKey, scrubText } from "./redact.js";
2
2
  import { BREADCRUMBS_ATTRIBUTE } from "./breadcrumbs.js";
3
+ /**
4
+ * Attribute values the bounds below cut or left out (a string past its
5
+ * length cap, list items past 64, map keys past 128, nesting past 6, the
6
+ * 512-value budget), counted since the process started. The normalisers are
7
+ * synchronous, so the difference across one call is that call's own count:
8
+ * see `countTruncations`. Privacy rewrites (redaction, query stripping) are
9
+ * not truncation and are not counted.
10
+ */
11
+ let truncatedValues = 0;
12
+ /** Run a synchronous normaliser and return what it cut along with its result. */
13
+ export function countTruncations(normalise) {
14
+ const before = truncatedValues;
15
+ const result = normalise();
16
+ return { result, truncated: truncatedValues - before };
17
+ }
3
18
  /** Bounded, defensive privacy boundary for custom telemetry from any OTLP SDK. */
4
19
  export function sanitizeRuntimeContext(input) {
5
20
  let budget = 512;
@@ -15,8 +30,10 @@ export function sanitizeRuntimeContext(input) {
15
30
  return cut <= 0 ? url : url.slice(0, url.length - rest.length + cut);
16
31
  });
17
32
  const visit = (value, key, depth) => {
18
- if (--budget < 0 || depth > 6)
33
+ if (--budget < 0 || depth > 6) {
34
+ truncatedValues += 1;
19
35
  return "[truncated]";
36
+ }
20
37
  if (/__proto__|constructor|prototype/i.test(key))
21
38
  return undefined;
22
39
  const usage =
@@ -44,6 +61,8 @@ export function sanitizeRuntimeContext(input) {
44
61
  // Cut before scrubbing so scrub work is bounded by the stored size;
45
62
  // the slack keeps a secret straddling the cut whole for the scrubber.
46
63
  const limit = /stack/i.test(key) ? 32000 : 2048;
64
+ if (text.length > limit)
65
+ truncatedValues += 1;
47
66
  return scrub(text.slice(0, limit + 512)).slice(0, limit);
48
67
  }
49
68
  if (typeof value === "number")
@@ -55,11 +74,19 @@ export function sanitizeRuntimeContext(input) {
55
74
  if (seen.has(value))
56
75
  return "[circular]";
57
76
  seen.add(value);
58
- if (Array.isArray(value))
77
+ if (Array.isArray(value)) {
78
+ if (value.length > 64)
79
+ truncatedValues += value.length - 64;
59
80
  return value.slice(0, 64).map((v) => visit(v, key, depth + 1));
81
+ }
60
82
  const result = {};
61
- 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,6 @@
1
1
  export { createIngesterApp } from "./server.js";
2
2
  export { loadConfig } from "./config.js";
3
3
  export * from "./compat.js";
4
- export { SinkForwarder, type SinkStats, type SinkTuning } from "./sink.js";
4
+ export { SinkForwarder, type SinkDrop, type SinkStats, type SinkTuning } from "./sink.js";
5
+ export { DROP_REASONS, IngestDropCounter, type DropKind, type DropReason, type DropRoute, type IngestDrop, type IngestDropRow, } from "./ingest-drops.js";
5
6
  export * from "./types.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { loadConfig } from "./config.js";
2
2
  import { createIngesterApp } from "./server.js";
3
3
  import { ingesterVersion } from "./sdk-versions.js";
4
4
  const config = loadConfig();
5
- const { app, store, sink, reportIngesterInfo } = createIngesterApp(config);
5
+ const { app, store, sink, reportIngesterInfo, drops } = createIngesterApp(config);
6
6
  const server = app.listen(config.port, () => {
7
7
  console.log(`autter otlp-ingester ${ingesterVersion()} listening on :${config.port} ` +
8
8
  `(clickhouse: ${config.clickhouseUrl ? "configured" : "NOT configured"})`);
@@ -25,7 +25,12 @@ async function shutdown(signal) {
25
25
  }
26
26
  }
27
27
  server.close(() => {
28
- void store.close().finally(() => process.exit(0));
28
+ // Write the rejection/drop counts still buffered (best effort: a
29
+ // failed write is logged, never retried).
30
+ void drops
31
+ .stop()
32
+ .finally(() => store.close())
33
+ .finally(() => process.exit(0));
29
34
  });
30
35
  setTimeout(() => process.exit(1), 10_000).unref();
31
36
  }
@@ -35,4 +40,5 @@ export { createIngesterApp } from "./server.js";
35
40
  export { loadConfig } from "./config.js";
36
41
  export * from "./compat.js";
37
42
  export { SinkForwarder } from "./sink.js";
43
+ export { DROP_REASONS, IngestDropCounter, } from "./ingest-drops.js";
38
44
  export * from "./types.js";