@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
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Which request each span of an export batch belongs to, so the span-fed
3
+ * usage rollups (`runtime_metrics_1m`) count one request once.
4
+ *
5
+ * A framework that opens its own SERVER span under the HTTP
6
+ * instrumentation's gives one request two server spans: Next.js's
7
+ * `BaseServer.handleRequest` span is a child of the HTTP server span, and
8
+ * some NestJS and Fastify setups and gRPC gateways serving RPCs in-process do
9
+ * the same. Each server span used to add a request, its latency and its error
10
+ * to the rollups, so every traced request of such an app counted twice.
11
+ *
12
+ * The request is the OUTERMOST server span. Walking up from a span through
13
+ * the parent links present in the batch, its request is the highest SERVER
14
+ * span reached. The walk stops:
15
+ * - at a parent that is not in the batch: the span's own chain ends there,
16
+ * so a server span whose parent is missing is a request of its own, as
17
+ * before. When an exporter's timer splits one request's spans across two
18
+ * batches, the nested server span therefore counts in its batch and the
19
+ * outer one in the other; a batch cannot tell a parent exported elsewhere
20
+ * from a remote caller;
21
+ * - at a parent in another process (service, environment, release and
22
+ * `service.instance.id`): one process serves one request;
23
+ * - where a SERVER or CONSUMER span's parent is a CLIENT or PRODUCER span:
24
+ * that edge is a call over the wire, a request of its own even when a
25
+ * service calls itself.
26
+ */
27
+ export interface RequestSpanLink {
28
+ traceId: string;
29
+ spanId: string;
30
+ parentSpanId: string | null;
31
+ /** Lower-case OTel span kind: "server", "client", "internal", … */
32
+ kind: string;
33
+ /** The process the span was recorded in; spans of different processes
34
+ * are never one request. */
35
+ process: string;
36
+ }
37
+ export interface RequestMembership {
38
+ /** Per span: the batch index of its request's (outermost) server span,
39
+ * or -1 when no server span is on its chain. */
40
+ requestOf: number[];
41
+ /** Per span: how many parent links the walk followed above it (0 at the
42
+ * top of its chain). Nested spans are deeper than their request span. */
43
+ depth: number[];
44
+ }
45
+ export declare function requestMembership(spans: RequestSpanLink[]): RequestMembership;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Which request each span of an export batch belongs to, so the span-fed
3
+ * usage rollups (`runtime_metrics_1m`) count one request once.
4
+ *
5
+ * A framework that opens its own SERVER span under the HTTP
6
+ * instrumentation's gives one request two server spans: Next.js's
7
+ * `BaseServer.handleRequest` span is a child of the HTTP server span, and
8
+ * some NestJS and Fastify setups and gRPC gateways serving RPCs in-process do
9
+ * the same. Each server span used to add a request, its latency and its error
10
+ * to the rollups, so every traced request of such an app counted twice.
11
+ *
12
+ * The request is the OUTERMOST server span. Walking up from a span through
13
+ * the parent links present in the batch, its request is the highest SERVER
14
+ * span reached. The walk stops:
15
+ * - at a parent that is not in the batch: the span's own chain ends there,
16
+ * so a server span whose parent is missing is a request of its own, as
17
+ * before. When an exporter's timer splits one request's spans across two
18
+ * batches, the nested server span therefore counts in its batch and the
19
+ * outer one in the other; a batch cannot tell a parent exported elsewhere
20
+ * from a remote caller;
21
+ * - at a parent in another process (service, environment, release and
22
+ * `service.instance.id`): one process serves one request;
23
+ * - where a SERVER or CONSUMER span's parent is a CLIENT or PRODUCER span:
24
+ * that edge is a call over the wire, a request of its own even when a
25
+ * service calls itself.
26
+ */
27
+ const UNSET = -2;
28
+ function isRemoteCallEdge(child, parent) {
29
+ return (child === "server" || child === "consumer") && (parent === "client" || parent === "producer");
30
+ }
31
+ export function requestMembership(spans) {
32
+ const byKey = new Map();
33
+ spans.forEach((span, i) => {
34
+ if (!span.traceId || !span.spanId)
35
+ return;
36
+ const key = `${span.traceId}\u0000${span.spanId}`;
37
+ if (!byKey.has(key))
38
+ byKey.set(key, i);
39
+ });
40
+ /** The parent the walk may step to, or -1. */
41
+ const parentOf = (i) => {
42
+ const span = spans[i];
43
+ if (!span.traceId || !span.parentSpanId)
44
+ return -1;
45
+ const p = byKey.get(`${span.traceId}\u0000${span.parentSpanId}`);
46
+ if (p === undefined || p === i)
47
+ return -1;
48
+ const parent = spans[p];
49
+ if (parent.process !== span.process)
50
+ return -1;
51
+ if (isRemoteCallEdge(span.kind, parent.kind))
52
+ return -1;
53
+ return p;
54
+ };
55
+ const requestOf = new Array(spans.length).fill(UNSET);
56
+ const depth = new Array(spans.length).fill(0);
57
+ for (let start = 0; start < spans.length; start++) {
58
+ if (requestOf[start] !== UNSET)
59
+ continue;
60
+ // Walk up until a resolved span, the top of the chain, or a cycle
61
+ // (malformed parent links).
62
+ const path = [];
63
+ const onPath = new Set();
64
+ let above = -1;
65
+ let aboveDepth = -1;
66
+ let cur = start;
67
+ for (;;) {
68
+ path.push(cur);
69
+ onPath.add(cur);
70
+ const p = parentOf(cur);
71
+ if (p < 0 || onPath.has(p))
72
+ break;
73
+ if (requestOf[p] !== UNSET) {
74
+ above = requestOf[p];
75
+ aboveDepth = depth[p];
76
+ break;
77
+ }
78
+ cur = p;
79
+ }
80
+ // Resolve top-down: the outermost server span wins.
81
+ for (let k = path.length - 1; k >= 0; k--) {
82
+ const node = path[k];
83
+ if (above === -1 && spans[node].kind === "server")
84
+ above = node;
85
+ aboveDepth += 1;
86
+ requestOf[node] = above;
87
+ depth[node] = aboveDepth;
88
+ }
89
+ }
90
+ return { requestOf, depth };
91
+ }
@@ -8,7 +8,31 @@ export interface SdkSighting {
8
8
  sdkName: string;
9
9
  sdkVersion: string;
10
10
  sdkLanguage: string;
11
+ /** How it arrived (telemetryVia); '' when unknown. */
12
+ via: TelemetryVia;
11
13
  }
14
+ /**
15
+ * How telemetry reached the ingester:
16
+ * - `collector`: forwarded by an OpenTelemetry Collector or a similar agent —
17
+ * its User-Agent says so (any collector distribution: "OpenTelemetry
18
+ * Collector Contrib/0.120.0 (linux/amd64)", ADOT's "AWS OTel Collector",
19
+ * `otelcol-*` builds, Grafana Alloy / Agent, Fluent Bit, Vector), or the
20
+ * resource's `telemetry.distro.name` is an otelcol build (the Autter
21
+ * collector config inserts `otelcol-contrib` for senders that set none);
22
+ * - `direct`: an SDK exporter sent it itself (the `OTel-OTLP-Exporter-<lang>`
23
+ * or Go `OTel Go OTLP …` User-Agent, or an Autter SDK's distro without a
24
+ * collector User-Agent); browser events sent with a client key;
25
+ * - `relay`: browser events forwarded with a server key (the Autter relay);
26
+ * - '': nothing tells (another User-Agent, or none).
27
+ */
28
+ export type TelemetryVia = "collector" | "direct" | "relay" | "";
29
+ export declare function telemetryVia(input: {
30
+ userAgent?: string | null;
31
+ /** The resource's `telemetry.distro.name`, when it has one. */
32
+ distroName?: string | null;
33
+ /** The browser route: decided by the key (client key = the browser itself). */
34
+ browserKeyScope?: "server" | "client" | "upload" | null;
35
+ }): TelemetryVia;
12
36
  interface KeyValue {
13
37
  key?: string;
14
38
  value?: {
@@ -25,8 +49,15 @@ export interface OtlpResourceRequest {
25
49
  resourceMetrics?: ResourceHolder[];
26
50
  resourceLogs?: ResourceHolder[];
27
51
  }
28
- /** SDK identity per OTLP resource. Reads only a handful of resource keys. */
29
- export declare function sdkSightingsFromOtlp(request: OtlpResourceRequest | null | undefined): SdkSighting[];
52
+ /**
53
+ * SDK identity per OTLP resource. Reads only a handful of resource keys.
54
+ * A resource with no SDK identity that came through a collector is still
55
+ * a sighting (empty name and version, `via: "collector"`), so a service fed
56
+ * by a collector receiver with no SDK (Prometheus, file logs) is seen too.
57
+ */
58
+ export declare function sdkSightingsFromOtlp(request: OtlpResourceRequest | null | undefined, transport?: {
59
+ userAgent?: string | null;
60
+ }): SdkSighting[];
30
61
  export declare const BROWSER_SDK_NAME = "@autter/runtime-browser";
31
62
  export interface SdkVersionRow extends SdkSighting {
32
63
  ingesterVersion: string;
@@ -52,6 +83,12 @@ export declare class SdkVersionTracker {
52
83
  /** Fire-and-forget. Returns the write promise (resolved) for tests. */
53
84
  observe(ctx: IngestContext, sightings: SdkSighting[]): Promise<void>;
54
85
  }
86
+ /**
87
+ * Migration 0019 column — shared with the baseline DDL. Not in the sorting
88
+ * key (that can't change on a deployed table): a service+SDK+version row
89
+ * says how it arrived most recently.
90
+ */
91
+ export declare const SDK_VERSION_VIA_COLUMN = "via LowCardinality(String) DEFAULT ''";
55
92
  export declare function sdkVersionTableDDL(db: string, ttlDays?: number): string;
56
93
  /** One row per ingester version (latest report wins); not tenant data. */
57
94
  export declare function ingesterInfoTableDDL(db: string): string;
@@ -12,9 +12,16 @@ import { createRequire } from "node:module";
12
12
  * `opentelemetry-<language>`;
13
13
  * - browser payloads: the `sdk` field sent by `@autter/runtime-browser`.
14
14
  *
15
+ * And how the telemetry arrived (`via`, 1.8.0): `collector` when an
16
+ * OpenTelemetry Collector (or a similar agent) forwarded it, `direct` when
17
+ * an SDK exporter sent it straight to the ingester, `relay` for browser
18
+ * payloads forwarded by a server-side relay; '' when it can't be told. So
19
+ * an Autter SDK behind a collector keeps its own name and is still seen as
20
+ * collector-fed. See `telemetryVia`.
21
+ *
15
22
  * Writes are deduplicated in memory: one row per (tenant, service,
16
- * environment, sdk, version) per `refreshMs`, bounded map, fire-and-forget —
17
- * a failed write is logged and never fails ingest.
23
+ * environment, sdk, version, via) per `refreshMs`, bounded map,
24
+ * fire-and-forget — a failed write is logged and never fails ingest.
18
25
  */
19
26
  let cachedVersion = null;
20
27
  /** This ingester's own version (package.json — present in the npm package
@@ -31,6 +38,20 @@ export function ingesterVersion() {
31
38
  }
32
39
  return cachedVersion;
33
40
  }
41
+ const COLLECTOR_AGENT_RE = /(?:open[ -]?telemetry|\botel)[ -]?collector|^otelcol|^(?:grafana[ -])?alloy\/|grafana[ -]?agent|^fluent[ -]?bit|^vector\//i;
42
+ const COLLECTOR_DISTRO_RE = /^otelcol(?:\b|[-_])|opentelemetry[-_ ]collector/i;
43
+ const SDK_EXPORTER_AGENT_RE = /^OTel[- ]OTLP[- ]Exporter|^OTel \w+ OTLP/i;
44
+ export function telemetryVia(input) {
45
+ const agent = (input.userAgent ?? "").trim().slice(0, 300);
46
+ const distro = (input.distroName ?? "").trim();
47
+ if (COLLECTOR_AGENT_RE.test(agent) || COLLECTOR_DISTRO_RE.test(distro))
48
+ return "collector";
49
+ if (input.browserKeyScope)
50
+ return input.browserKeyScope === "client" ? "direct" : "relay";
51
+ if (SDK_EXPORTER_AGENT_RE.test(agent) || distro.startsWith("@autter/"))
52
+ return "direct";
53
+ return "";
54
+ }
34
55
  const CLEAN = /^[\w.@/+:-]{1,100}$/;
35
56
  function clean(value, max = 100) {
36
57
  if (typeof value !== "string")
@@ -38,8 +59,13 @@ function clean(value, max = 100) {
38
59
  const trimmed = value.trim().slice(0, max);
39
60
  return CLEAN.test(trimmed) ? trimmed : "";
40
61
  }
41
- /** SDK identity per OTLP resource. Reads only a handful of resource keys. */
42
- export function sdkSightingsFromOtlp(request) {
62
+ /**
63
+ * SDK identity per OTLP resource. Reads only a handful of resource keys.
64
+ * A resource with no SDK identity that came through a collector is still
65
+ * a sighting (empty name and version, `via: "collector"`), so a service fed
66
+ * by a collector receiver with no SDK (Prometheus, file logs) is seen too.
67
+ */
68
+ export function sdkSightingsFromOtlp(request, transport = {}) {
43
69
  if (!request || typeof request !== "object")
44
70
  return [];
45
71
  const holders = [
@@ -63,14 +89,20 @@ export function sdkSightingsFromOtlp(request) {
63
89
  if (!service)
64
90
  continue;
65
91
  const language = clean(get["telemetry.sdk.language"]);
66
- let sdkName = clean(get["telemetry.distro.name"]);
92
+ const distroName = clean(get["telemetry.distro.name"]);
93
+ let sdkName = distroName;
67
94
  let sdkVersion = clean(get["telemetry.distro.version"]);
68
95
  if (!sdkName || !sdkVersion) {
69
96
  sdkVersion = clean(get["telemetry.sdk.version"]);
70
97
  sdkName = sdkVersion ? `opentelemetry-${language || "unknown"}` : "";
71
98
  }
72
- if (!sdkName || !sdkVersion)
73
- continue;
99
+ const via = telemetryVia({ userAgent: transport.userAgent, distroName });
100
+ if (!sdkName || !sdkVersion) {
101
+ if (via !== "collector")
102
+ continue;
103
+ sdkName = "";
104
+ sdkVersion = "";
105
+ }
74
106
  out.push({
75
107
  service,
76
108
  environment: clean(get["deployment.environment.name"]) ||
@@ -79,6 +111,7 @@ export function sdkSightingsFromOtlp(request) {
79
111
  sdkName,
80
112
  sdkVersion,
81
113
  sdkLanguage: language,
114
+ via,
82
115
  });
83
116
  }
84
117
  return out;
@@ -123,6 +156,7 @@ export class SdkVersionTracker {
123
156
  sighting.environment,
124
157
  sighting.sdkName,
125
158
  sighting.sdkVersion,
159
+ sighting.via,
126
160
  ].join("\u0000");
127
161
  const last = this.lastWritten.get(key);
128
162
  if (last !== undefined && now - last < this.refreshMs)
@@ -146,12 +180,19 @@ export class SdkVersionTracker {
146
180
  });
147
181
  }
148
182
  }
183
+ /**
184
+ * Migration 0019 column — shared with the baseline DDL. Not in the sorting
185
+ * key (that can't change on a deployed table): a service+SDK+version row
186
+ * says how it arrived most recently.
187
+ */
188
+ export const SDK_VERSION_VIA_COLUMN = "via LowCardinality(String) DEFAULT ''";
149
189
  export function sdkVersionTableDDL(db, ttlDays = 90) {
150
190
  return `CREATE TABLE IF NOT EXISTS ${db}.runtime_sdk_versions (
151
191
  org_id String, repository_id String, service LowCardinality(String),
152
192
  environment LowCardinality(String), sdk_name LowCardinality(String),
153
193
  sdk_version String, sdk_language LowCardinality(String) DEFAULT '',
154
- ingester_version String DEFAULT '', last_seen DateTime('UTC')
194
+ ingester_version String DEFAULT '', last_seen DateTime('UTC'),
195
+ ${SDK_VERSION_VIA_COLUMN}
155
196
  ) ENGINE = ReplacingMergeTree(last_seen)
156
197
  ORDER BY (org_id, repository_id, service, environment, sdk_name, sdk_version)
157
198
  TTL last_seen + INTERVAL ${ttlDays} DAY`;
package/dist/server.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { type Express } from "express";
2
+ import { IngestDropCounter } from "./ingest-drops.js";
2
3
  import { ClickHouseStore } from "./clickhouse.js";
3
4
  import type { IngesterConfig } from "./config.js";
4
5
  import { SinkForwarder } from "./sink.js";
6
+ import { LoopCollapser } from "./loop-collapse.js";
5
7
  export interface IngesterApp {
6
8
  app: Express;
7
9
  store: ClickHouseStore;
@@ -10,6 +12,10 @@ export interface IngesterApp {
10
12
  /** Record this ingester's version + schema level in ClickHouse (for the
11
13
  * Autter dashboard). Called after the boot schema warm-up; throttled. */
12
14
  reportIngesterInfo: () => Promise<void>;
15
+ /** Rejection and drop counts per tenant (runtime_ingest_drops); `stop()` writes what is buffered. */
16
+ drops: IngestDropCounter;
17
+ /** Loop collapse (1.9.0): `stop()` stores every window's sample (call it before `drops.stop()`). */
18
+ collapse: LoopCollapser;
13
19
  }
14
20
  /** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
15
21
  export declare const UPLOAD_KEY_ROUTES: readonly ["/v1/sourcemaps", "/v1/sourcemaps/check"];