@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/README.md +7 -3
- package/dist/auth.js +1 -1
- package/dist/{chunk-2FALTDCE.js → chunk-VSOZJO6Y.js} +12 -1
- package/dist/chunk-VSOZJO6Y.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +227 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/framework/collector.ts +22 -0
- package/src/framework/ingest-auth-warning.test.ts +71 -0
- package/src/index.ts +1 -0
- package/src/lib/alerts-store.test.ts +31 -0
- package/src/lib/alerts-store.ts +17 -6
- package/src/lib/projects-store.test.ts +44 -1
- package/src/lib/projects-store.ts +21 -0
- package/src/lib/webhook-url.test.ts +49 -0
- package/src/lib/webhook-url.ts +72 -14
- package/src/otlp/decode/logs.ts +30 -2
- package/src/otlp/decode/metrics.ts +67 -2
- package/src/otlp/decode/traces.ts +57 -4
- package/src/otlp/decode/values.ts +64 -10
- package/src/otlp/decode.test.ts +305 -0
- package/src/otlp/decode.ts +9 -3
- package/src/plugins/autosre-routes.ts +9 -0
- package/src/plugins/logs-receiver.ts +8 -8
- package/src/plugins/metrics-receiver.ts +10 -9
- package/src/plugins/otlp-receiver.ts +10 -26
- package/src/plugins/profile-routes.test.ts +22 -0
- package/src/plugins/profile-routes.ts +11 -2
- package/src/plugins/replay-query-routes.test.ts +63 -1
- package/src/plugins/replay-query-routes.ts +40 -9
- package/dist/chunk-2FALTDCE.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsunified/collector",
|
|
3
|
-
"version": "2.4.
|
|
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.
|
|
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
|
|
package/src/lib/alerts-store.ts
CHANGED
|
@@ -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
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
.
|
|
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.
|
package/src/lib/webhook-url.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
if (
|
|
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
|
package/src/otlp/decode/logs.ts
CHANGED
|
@@ -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 = (
|