@obsunified/collector 2.4.3 → 2.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsunified/collector",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "description": "Plugin-based observability collector runtime with pluggable SQL/blob storage.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "sideEffects": false,
38
38
  "dependencies": {
39
39
  "@bufbuild/protobuf": "^2.11.0",
40
- "@obsunified/pprof-decoder": "2.0.2",
40
+ "@obsunified/pprof-decoder": "2.0.3",
41
41
  "@obsunified/types": "2.4.1"
42
42
  },
43
43
  "peerDependencies": {
@@ -129,6 +129,13 @@ export interface CollectorConfig {
129
129
  * client can never make the host buffer an ingest-sized body.
130
130
  */
131
131
  maxAuthBodyBytes?: number;
132
+ /**
133
+ * Acknowledge that this app runs without `auth` on `/v1/*` (e.g. a local
134
+ * sandbox behind a trusted proxy). It changes nothing about request
135
+ * handling; it only silences the one-time warning logged when the first
136
+ * ingest request reaches an app that has no ingest auth configured.
137
+ */
138
+ allowUnauthenticatedIngest?: boolean;
132
139
  }
133
140
 
134
141
  export class CollectorRuntime implements CollectorPluginContext {
@@ -350,6 +357,21 @@ export const createTelemetryCollectorApp = (
350
357
  } else if (config.auth?.ingest) {
351
358
  // Legacy: explicit ingest-only middleware
352
359
  app.use("/v1/*", config.auth.ingest);
360
+ } else if (!config.allowUnauthenticatedIngest) {
361
+ // No ingest auth: /v1/* accepts anonymous writes. Behaviour is kept
362
+ // for compatibility, but say so loudly — once, on the first ingest
363
+ // request, so merely importing the package (whose default export
364
+ // builds an auth-less app) never logs.
365
+ let warned = false;
366
+ app.use("/v1/*", async (_c, next) => {
367
+ if (!warned) {
368
+ warned = true;
369
+ runtime.logger.warn(
370
+ "[collector] WARNING: /v1/* ingest is UNAUTHENTICATED — this app was built without `auth`, so anyone who can reach it can write telemetry. Pass `auth: { middleware: createIngestAuth() }` (and set INGEST_KEY or create a key in the dashboard), or set `allowUnauthenticatedIngest: true` to acknowledge and silence this warning.",
371
+ );
372
+ }
373
+ await next();
374
+ });
353
375
  }
354
376
 
355
377
  // Dashboard auth (session cookie or programmatic bearer token) — owns /internal/* and /dashboard/* when configured.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * An app built without ingest auth keeps accepting anonymous `/v1/*` writes
3
+ * (back-compat), but logs one loud warning on the first ingest request.
4
+ */
5
+
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import type { CollectorPlugin } from "./collector";
8
+ import { createTelemetryCollectorApp } from "./collector";
9
+ import type { CollectorEnv } from "./env";
10
+ import type { Logger } from "./logger";
11
+
12
+ const okPlugin: CollectorPlugin = {
13
+ name: "ok",
14
+ register(app) {
15
+ app.post("/v1/traces", (c) => c.json({ ok: true }));
16
+ },
17
+ };
18
+
19
+ const spyLogger = () => {
20
+ const warn = vi.fn<Logger["warn"]>();
21
+ const logger: Logger = {
22
+ debug: vi.fn(),
23
+ info: vi.fn(),
24
+ warn,
25
+ error: vi.fn(),
26
+ };
27
+ return { logger, warn };
28
+ };
29
+
30
+ const ingest = (app: ReturnType<typeof createTelemetryCollectorApp>) =>
31
+ app.request("/v1/traces", { method: "POST", body: "{}" }, {} as CollectorEnv);
32
+
33
+ const unauthWarnings = ({ warn }: ReturnType<typeof spyLogger>) =>
34
+ warn.mock.calls.filter(([m]) => String(m).includes("UNAUTHENTICATED"));
35
+
36
+ describe("unauthenticated ingest warning", () => {
37
+ it("warns exactly once and still accepts the request", async () => {
38
+ const spy = spyLogger();
39
+ const { logger } = spy;
40
+ const app = createTelemetryCollectorApp({ plugins: [okPlugin], logger });
41
+ expect(unauthWarnings(spy)).toHaveLength(0); // not at build time
42
+ for (let i = 0; i < 3; i++) {
43
+ expect((await ingest(app)).status).toBe(200);
44
+ }
45
+ expect(unauthWarnings(spy)).toHaveLength(1);
46
+ });
47
+
48
+ it("stays silent when ingest auth is configured", async () => {
49
+ const spy = spyLogger();
50
+ const { logger } = spy;
51
+ const app = createTelemetryCollectorApp({
52
+ plugins: [okPlugin],
53
+ logger,
54
+ auth: { middleware: async (_c, next) => next() },
55
+ });
56
+ expect((await ingest(app)).status).toBe(200);
57
+ expect(unauthWarnings(spy)).toHaveLength(0);
58
+ });
59
+
60
+ it("stays silent with the explicit allowUnauthenticatedIngest opt-in", async () => {
61
+ const spy = spyLogger();
62
+ const { logger } = spy;
63
+ const app = createTelemetryCollectorApp({
64
+ plugins: [okPlugin],
65
+ logger,
66
+ allowUnauthenticatedIngest: true,
67
+ });
68
+ expect((await ingest(app)).status).toBe(200);
69
+ expect(unauthWarnings(spy)).toHaveLength(0);
70
+ });
71
+ });
package/src/index.ts CHANGED
@@ -286,6 +286,7 @@ export const createDefaultCollectorApp = (config?: Partial<CollectorConfig>) =>
286
286
  logger: config?.logger,
287
287
  withChildSpan: config?.withChildSpan,
288
288
  sqlDb: config?.sqlDb,
289
+ allowUnauthenticatedIngest: config?.allowUnauthenticatedIngest,
289
290
  });
290
291
 
291
292
  export default createTelemetryCollectorApp(allPlugins);
@@ -66,6 +66,37 @@ describe("AlertsStore.deleteRule", () => {
66
66
  });
67
67
  });
68
68
 
69
+ describe("AlertsStore.listEvaluations project scoping", () => {
70
+ it("does not return another project's evaluation history for a known rule id", async () => {
71
+ const db = createMigratedSqliteDb();
72
+ const projects = new ProjectsStore(db);
73
+ const alphaProject = await projects.createProject({ name: "A", slug: "a" });
74
+ const betaProject = await projects.createProject({ name: "B", slug: "b" });
75
+ const store = new AlertsStore(db);
76
+ const beta = await store.createRule(
77
+ betaProject.id,
78
+ input({ name: "beta" }),
79
+ );
80
+ await store.recordEvaluation(beta.id, beta.projectId, 5, "firing", true);
81
+
82
+ const own = await store.listEvaluations({
83
+ ruleId: beta.id,
84
+ projectId: betaProject.id,
85
+ hours: 24,
86
+ });
87
+ expect(own.evaluations).toHaveLength(1);
88
+
89
+ const foreign = await store.listEvaluations({
90
+ ruleId: beta.id,
91
+ projectId: alphaProject.id,
92
+ hours: 24,
93
+ });
94
+ expect(foreign.evaluations).toEqual([]);
95
+ expect(foreign.evidenceReferences).toBeUndefined();
96
+ db.close();
97
+ });
98
+ });
99
+
69
100
  describe("AlertsStore.validateInput webhook targets", () => {
70
101
  const store = new AlertsStore(createMigratedSqliteDb());
71
102
 
@@ -719,12 +719,23 @@ export class AlertsStore {
719
719
  const cutoff = new Date(
720
720
  Date.now() - params.hours * 60 * 60 * 1000,
721
721
  ).toISOString();
722
- const rs = await this.db
723
- .prepare(
724
- `SELECT * FROM alert_evaluations WHERE rule_id = ? AND evaluated_at >= ? ORDER BY evaluated_at DESC LIMIT ?`,
725
- )
726
- .bind(params.ruleId, cutoff, Math.min(params.limit ?? 500, 1000))
727
- .all<AlertEvaluationRow>();
722
+ const limit = Math.min(params.limit ?? 500, 1000);
723
+ // Scope by project whenever the caller knows it (every HTTP route does)
724
+ // so a rule id from another project cannot read its evaluation history.
725
+ // `projectId` stays optional on this public store API for back-compat.
726
+ const rs = params.projectId
727
+ ? await this.db
728
+ .prepare(
729
+ `SELECT * FROM alert_evaluations WHERE rule_id = ? AND project_id = ? AND evaluated_at >= ? ORDER BY evaluated_at DESC LIMIT ?`,
730
+ )
731
+ .bind(params.ruleId, params.projectId, cutoff, limit)
732
+ .all<AlertEvaluationRow>()
733
+ : await this.db
734
+ .prepare(
735
+ `SELECT * FROM alert_evaluations WHERE rule_id = ? AND evaluated_at >= ? ORDER BY evaluated_at DESC LIMIT ?`,
736
+ )
737
+ .bind(params.ruleId, cutoff, limit)
738
+ .all<AlertEvaluationRow>();
728
739
  const rows = rs.results ?? [];
729
740
  const projectId = params.projectId ?? rows[0]?.project_id;
730
741
  const rule = projectId
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { sha256Hex } from "./hash";
3
3
  import { displayPrefix, ProjectsStore } from "./projects-store";
4
4
  import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
@@ -60,3 +60,46 @@ describe("bootstrap key display prefix", () => {
60
60
  db.close();
61
61
  });
62
62
  });
63
+
64
+ describe("ProjectsStore.bootstrapEnvKey rotation", () => {
65
+ it("warns (without revoking) when INGEST_KEY changes while the old bootstrap key is active", async () => {
66
+ const db = createMigratedSqliteDb();
67
+ const store = new ProjectsStore(db);
68
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
69
+ try {
70
+ await store.bootstrapEnvKey("first-bootstrap-key-0001");
71
+ await store.bootstrapEnvKey("first-bootstrap-key-0001"); // idempotent
72
+ expect(warn).not.toHaveBeenCalled();
73
+
74
+ await store.bootstrapEnvKey("second-bootstrap-key-0002");
75
+ expect(warn).toHaveBeenCalledTimes(1);
76
+ const message = String(warn.mock.calls[0]?.[0]);
77
+ expect(message).toContain("INGEST_KEY changed");
78
+ expect(message).toContain("revoke");
79
+ expect(message).not.toContain("first-bootstrap-key-0001");
80
+
81
+ // Nothing is auto-revoked: both keys still authenticate.
82
+ for (const key of [
83
+ "first-bootstrap-key-0001",
84
+ "second-bootstrap-key-0002",
85
+ ]) {
86
+ expect(
87
+ (await store.findByKeyHash(await sha256Hex(key)))?.projectId,
88
+ ).toBe("default");
89
+ }
90
+
91
+ // Once the old one is revoked, a restart with the new key is quiet.
92
+ const old = (await store.listKeys("default")).find(
93
+ (k) => k.name === "bootstrap" && k.keyPrefix.startsWith("first"),
94
+ );
95
+ expect(old).toBeDefined();
96
+ await store.revokeKey(old?.id ?? "", "default");
97
+ warn.mockClear();
98
+ await store.bootstrapEnvKey("second-bootstrap-key-0002");
99
+ expect(warn).not.toHaveBeenCalled();
100
+ } finally {
101
+ warn.mockRestore();
102
+ db.close();
103
+ }
104
+ });
105
+ });
@@ -215,6 +215,15 @@ export class ProjectsStore {
215
215
  return;
216
216
  }
217
217
 
218
+ // A different, still-active bootstrap key means INGEST_KEY was rotated.
219
+ // The old key is deliberately NOT revoked here: other instances (or
220
+ // clients mid-rollout) may still be using it. Tell the operator instead.
221
+ const previous = await this.db
222
+ .prepare(
223
+ `SELECT id, created_at FROM ingest_keys WHERE project_id = 'default' AND name = 'bootstrap' AND revoked_at IS NULL`,
224
+ )
225
+ .all<{ id: string; created_at: string }>();
226
+
218
227
  const id = randomHex(16);
219
228
  const createdAt = new Date().toISOString();
220
229
  await this.db
@@ -223,5 +232,17 @@ export class ProjectsStore {
223
232
  )
224
233
  .bind(id, keyHash, keyPrefix, createdAt)
225
234
  .run();
235
+
236
+ const stale = previous.results ?? [];
237
+ if (stale.length > 0) {
238
+ console.warn(
239
+ `[projects-store] INGEST_KEY changed: registered a new bootstrap ingest key (${keyPrefix}…) but ${stale.length} earlier bootstrap key(s) are still active and keep accepting ingest: ${stale
240
+ // Ids only: legacy rows may still hold a long key_prefix.
241
+ .map((row) => `id ${row.id} (created ${row.created_at})`)
242
+ .join(
243
+ ", ",
244
+ )}. Once every client uses the new key, revoke the old one in the dashboard (Projects → default → Keys) or with DELETE /internal/projects/default/keys/<keyId>.`,
245
+ );
246
+ }
226
247
  }
227
248
  }
@@ -53,6 +53,41 @@ describe("assertSafeWebhookUrl", () => {
53
53
  }
54
54
  });
55
55
 
56
+ it("rejects IPv4 embedded in IPv6 (compatible, mapped, NAT64, 6to4)", () => {
57
+ for (const url of [
58
+ "http://[::7f00:1]/hook",
59
+ "http://[::127.0.0.1]/hook",
60
+ "http://[::a9fe:a9fe]/hook",
61
+ "http://[::ffff:7f00:1]/hook",
62
+ "http://[0:0:0:0:0:ffff:127.0.0.1]/hook",
63
+ "http://[64:ff9b::7f00:1]/hook",
64
+ "http://[64:ff9b::169.254.169.254]/hook",
65
+ "http://[2002:7f00:1::]/hook",
66
+ "http://[2002:a00:1::1]/hook",
67
+ "http://[::]/hook",
68
+ ]) {
69
+ expect(() => assertSafeWebhookUrl(url), url).toThrow(WebhookUrlError);
70
+ // The documented opt-out still unlocks every one of them.
71
+ expect(
72
+ () => assertSafeWebhookUrl(url, { allowPrivateNetworks: true }),
73
+ url,
74
+ ).not.toThrow();
75
+ }
76
+ });
77
+
78
+ it("keeps public IPv6 and public embedded IPv4 reachable", () => {
79
+ for (const url of [
80
+ "https://[2606:4700:4700::1111]/hook",
81
+ "https://[64:ff9b::808:808]/hook",
82
+ "https://[2002:808:808::1]/hook",
83
+ "https://[::ffff:8.8.8.8]/hook",
84
+ "https://198.20.0.1/hook",
85
+ "https://198.17.255.1/hook",
86
+ ]) {
87
+ expect(() => assertSafeWebhookUrl(url), url).not.toThrow();
88
+ }
89
+ });
90
+
56
91
  it("allows private hosts only with the explicit opt-in", () => {
57
92
  const url = "http://alertmanager.internal:9093/api/v2/alerts";
58
93
  expect(() => assertSafeWebhookUrl(url)).toThrow(WebhookUrlError);
@@ -107,6 +142,20 @@ describe("redactWebhookUrl", () => {
107
142
  expect(
108
143
  await resolveWebhookHost("v6.example", async () => ["fd00::1"]),
109
144
  ).toBe("private");
145
+ for (const answer of [
146
+ "::ffff:127.0.0.1",
147
+ "64:ff9b::a00:1",
148
+ "2002:a9fe:a9fe::",
149
+ "fe80::1%eth0",
150
+ ]) {
151
+ expect(
152
+ await resolveWebhookHost("v6.example", async () => [answer]),
153
+ answer,
154
+ ).toBe("private");
155
+ }
156
+ expect(
157
+ await resolveWebhookHost("v6.example", async () => ["2606:4700::1111"]),
158
+ ).toBe("public");
110
159
  // IP literals were judged already by the literal check.
111
160
  expect(await resolveWebhookHost("10.0.0.8", lookup)).toBe("public");
112
161
  // No answer is never "safe": the delivery fetch would resolve on its own.
@@ -38,25 +38,83 @@ const isPrivateIpv4 = (o: number[]): boolean =>
38
38
  (o[0] === 192 && o[1] === 168) ||
39
39
  o[0] >= 224; // multicast, reserved, broadcast
40
40
 
41
+ /**
42
+ * Expand an IPv6 literal (no brackets, optional `%zone`) into its eight
43
+ * 16-bit groups. Accepts a trailing dotted IPv4 tail (`::127.0.0.1`).
44
+ * Returns null for anything that is not a well-formed address.
45
+ */
46
+ const parseIpv6 = (literal: string): number[] | null => {
47
+ let text = literal.replace(/%.*$/, "");
48
+ const tail = /:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
49
+ let tailGroups: number[] = [];
50
+ if (tail) {
51
+ const v4 = parseIpv4(tail[1]);
52
+ if (!v4) return null;
53
+ tailGroups = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]];
54
+ text = text.slice(0, -tail[1].length);
55
+ // "::1.2.3.4" leaves "::", "64:ff9b::1.2.3.4" leaves "64:ff9b::",
56
+ // "::ffff:1.2.3.4" leaves "::ffff:"; drop the dangling separator
57
+ // unless it is part of "::".
58
+ if (text.endsWith(":") && !text.endsWith("::")) text = text.slice(0, -1);
59
+ }
60
+ const halves = text.split("::");
61
+ if (halves.length > 2) return null;
62
+ const toGroups = (part: string): number[] | null => {
63
+ if (part === "") return [];
64
+ const groups: number[] = [];
65
+ for (const g of part.split(":")) {
66
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
67
+ groups.push(Number.parseInt(g, 16));
68
+ }
69
+ return groups;
70
+ };
71
+ const head = toGroups(halves[0]);
72
+ const rest = halves.length === 2 ? toGroups(halves[1]) : [];
73
+ if (!head || !rest) return null;
74
+ const explicit = head.length + rest.length + tailGroups.length;
75
+ if (halves.length === 1) {
76
+ return explicit === 8 ? [...head, ...tailGroups] : null;
77
+ }
78
+ if (explicit > 7) return null;
79
+ return [
80
+ ...head,
81
+ ...new Array<number>(8 - explicit).fill(0),
82
+ ...rest,
83
+ ...tailGroups,
84
+ ];
85
+ };
86
+
87
+ const embeddedIpv4 = (hi: number, lo: number): number[] => [
88
+ hi >> 8,
89
+ hi & 0xff,
90
+ lo >> 8,
91
+ lo & 0xff,
92
+ ];
93
+
41
94
  const isPrivateIpv6 = (host: string): boolean => {
42
95
  // URL.hostname keeps the brackets for IPv6 literals.
43
96
  const literal = host.replace(/^\[|\]$/g, "").toLowerCase();
44
- if (literal === "::" || literal === "::1") return true;
45
- // IPv4-mapped (::ffff:a.b.c.d). The WHATWG URL parser serializes the
46
- // embedded address as two hex groups (::ffff:a00:1), so accept both.
47
- const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(literal);
48
- if (dotted) {
49
- const v4 = parseIpv4(dotted[1]);
50
- return v4 ? isPrivateIpv4(v4) : true;
97
+ const g = parseIpv6(literal);
98
+ if (!g) return true; // not a parseable address: fail closed
99
+ const zeroPrefix = (n: number) => g.slice(0, n).every((x) => x === 0);
100
+ // :: (unspecified), ::1 (loopback) and the deprecated IPv4-compatible
101
+ // ::a.b.c.d form (which the WHATWG URL parser serializes as ::7f00:1).
102
+ if (zeroPrefix(6)) {
103
+ if (g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
104
+ return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
105
+ }
106
+ // IPv4-mapped ::ffff:a.b.c.d (serialized as ::ffff:a00:1).
107
+ if (zeroPrefix(5) && g[5] === 0xffff) {
108
+ return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
51
109
  }
52
- const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(literal);
53
- if (hex) {
54
- const hi = Number.parseInt(hex[1], 16);
55
- const lo = Number.parseInt(hex[2], 16);
56
- return isPrivateIpv4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]);
110
+ // NAT64 well-known prefix 64:ff9b::/96 — the gateway forwards to the
111
+ // embedded IPv4 address.
112
+ if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) {
113
+ return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
57
114
  }
58
- const first = Number.parseInt(literal.split(":")[0] || "0", 16);
59
- if (Number.isNaN(first)) return true;
115
+ // 6to4 2002::/16 carries the IPv4 relay address in groups 1-2.
116
+ if (g[0] === 0x2002) return isPrivateIpv4(embeddedIpv4(g[1], g[2]));
117
+ const first = g[0];
60
118
  if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
61
119
  if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
62
120
  if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
@@ -41,7 +41,26 @@ export interface DecodedLogRecord {
41
41
  * storage. Resource `service.name` and scope name are denormalized onto each
42
42
  * record so downstream code doesn't need to traverse the hierarchy.
43
43
  */
44
- export const decodeLogsRequest = (body: ReadBodyResult): DecodedLogRecord[] => {
44
+ export const decodeLogsRequest = (body: ReadBodyResult): DecodedLogRecord[] =>
45
+ decodeLogsRequestCapped(body, Number.POSITIVE_INFINITY).records;
46
+
47
+ export interface CappedLogsRequest {
48
+ /** First `maxRecords` records, in wire order. */
49
+ records: DecodedLogRecord[];
50
+ /** Records beyond the cap that were counted but never adapted. */
51
+ rejected: number;
52
+ }
53
+
54
+ /**
55
+ * Like {@link decodeLogsRequest}, but stops adapting once `maxRecords`
56
+ * records have been produced, so an oversized batch never gets a second,
57
+ * adapted in-memory copy. Output is identical to decoding everything and
58
+ * slicing the first `maxRecords`.
59
+ */
60
+ export const decodeLogsRequestCapped = (
61
+ body: ReadBodyResult,
62
+ maxRecords: number,
63
+ ): CappedLogsRequest => {
45
64
  let msg: ExportLogsServiceRequest;
46
65
  try {
47
66
  msg =
@@ -56,16 +75,25 @@ export const decodeLogsRequest = (body: ReadBodyResult): DecodedLogRecord[] => {
56
75
  }
57
76
 
58
77
  const out: DecodedLogRecord[] = [];
78
+ let rejected = 0;
59
79
  for (const rl of msg.resourceLogs) {
80
+ if (out.length >= maxRecords) {
81
+ for (const sl of rl.scopeLogs) rejected += sl.logRecords.length;
82
+ continue;
83
+ }
60
84
  const serviceName = extractServiceName(rl.resource?.attributes);
61
85
  for (const sl of rl.scopeLogs) {
62
86
  const loggerName = sl.scope?.name || null;
63
87
  for (const log of sl.logRecords) {
88
+ if (out.length >= maxRecords) {
89
+ rejected++;
90
+ continue;
91
+ }
64
92
  out.push(adaptLogRecord(log, serviceName, loggerName));
65
93
  }
66
94
  }
67
95
  }
68
- return out;
96
+ return { records: out, rejected };
69
97
  };
70
98
 
71
99
  const adaptLogRecord = (
@@ -78,7 +78,26 @@ export interface DecodedMetricPoint {
78
78
 
79
79
  export const decodeMetricsRequest = (
80
80
  body: ReadBodyResult,
81
- ): DecodedMetricPoint[] => {
81
+ ): DecodedMetricPoint[] =>
82
+ decodeMetricsRequestCapped(body, Number.POSITIVE_INFINITY).points;
83
+
84
+ export interface CappedMetricsRequest {
85
+ /** First `maxPoints` points, in wire order. */
86
+ points: DecodedMetricPoint[];
87
+ /** Points beyond the cap that were counted but never adapted. */
88
+ rejected: number;
89
+ }
90
+
91
+ /**
92
+ * Like {@link decodeMetricsRequest}, but stops adapting once `maxPoints`
93
+ * points have been produced, so an oversized batch never gets a second,
94
+ * adapted in-memory copy. Output is identical to decoding everything and
95
+ * slicing the first `maxPoints`.
96
+ */
97
+ export const decodeMetricsRequestCapped = (
98
+ body: ReadBodyResult,
99
+ maxPoints: number,
100
+ ): CappedMetricsRequest => {
82
101
  let msg: ExportMetricsServiceRequest;
83
102
  try {
84
103
  msg =
@@ -96,7 +115,20 @@ export const decodeMetricsRequest = (
96
115
  }
97
116
 
98
117
  const out: DecodedMetricPoint[] = [];
118
+ let rejected = 0;
99
119
  for (const rm of msg.resourceMetrics) {
120
+ if (out.length >= maxPoints) {
121
+ for (const sm of rm.scopeMetrics) rejected += countPoints(sm);
122
+ continue;
123
+ }
124
+ const remaining = maxPoints - out.length;
125
+ const total = rm.scopeMetrics.reduce((n, sm) => n + countPoints(sm), 0);
126
+ if (total > remaining) {
127
+ // Trim this resource's data points in place (wire order) so the
128
+ // adapters below only ever build `remaining` points.
129
+ rejected += total - remaining;
130
+ trimScopeMetrics(rm.scopeMetrics, remaining);
131
+ }
100
132
  const resourceAttrs = rm.resource?.attributes ?? [];
101
133
  const resourceAttrsJson = resourceAttrs.length
102
134
  ? JSON.stringify(keyValuesToRecord(resourceAttrs))
@@ -106,7 +138,40 @@ export const decodeMetricsRequest = (
106
138
  addMetrics(out, rm, sm, serviceName, resourceAttrsJson);
107
139
  }
108
140
  }
109
- return out;
141
+ return { points: out, rejected };
142
+ };
143
+
144
+ /** Data-point count of the metric kinds `addMetrics` emits. */
145
+ const metricPointCount = (metric: Metric): number => {
146
+ switch (metric.data.case) {
147
+ case "gauge":
148
+ case "sum":
149
+ case "histogram":
150
+ case "exponentialHistogram":
151
+ case "summary":
152
+ return metric.data.value.dataPoints.length;
153
+ default:
154
+ return 0;
155
+ }
156
+ };
157
+
158
+ const countPoints = (sm: ScopeMetrics): number =>
159
+ sm.metrics.reduce((n, m) => n + metricPointCount(m), 0);
160
+
161
+ /** Keep only the first `budget` emitted data points, in wire order. */
162
+ const trimScopeMetrics = (scopeMetrics: ScopeMetrics[], budget: number) => {
163
+ let left = budget;
164
+ for (const sm of scopeMetrics) {
165
+ for (const metric of sm.metrics) {
166
+ const n = metricPointCount(metric);
167
+ if (n === 0) continue;
168
+ if (n > left) {
169
+ const data = metric.data.value as { dataPoints: unknown[] };
170
+ data.dataPoints = data.dataPoints.slice(0, left);
171
+ }
172
+ left -= Math.min(n, left);
173
+ }
174
+ }
110
175
  };
111
176
 
112
177
  const addMetrics = (