@superblocksteam/sdk 2.0.149 → 2.0.150-next.1
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/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/dependency-install-classifier.d.mts +3 -1
- package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -1
- package/dist/cli-replacement/dependency-install-classifier.mjs +16 -5
- package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +73 -14
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +4 -0
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.mjs +130 -12
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/cli-replacement/install-packages.npm-registry.test.mjs +153 -0
- package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -1
- package/dist/cli-replacement/npm-install-summary.d.mts +30 -0
- package/dist/cli-replacement/npm-install-summary.d.mts.map +1 -0
- package/dist/cli-replacement/npm-install-summary.mjs +67 -0
- package/dist/cli-replacement/npm-install-summary.mjs.map +1 -0
- package/dist/cli-replacement/npm-install-summary.test.d.mts +2 -0
- package/dist/cli-replacement/npm-install-summary.test.d.mts.map +1 -0
- package/dist/cli-replacement/npm-install-summary.test.mjs +70 -0
- package/dist/cli-replacement/npm-install-summary.test.mjs.map +1 -0
- package/dist/cli-replacement/npm-install-timing.d.mts +16 -0
- package/dist/cli-replacement/npm-install-timing.d.mts.map +1 -0
- package/dist/cli-replacement/npm-install-timing.mjs +104 -0
- package/dist/cli-replacement/npm-install-timing.mjs.map +1 -0
- package/dist/cli-replacement/npm-install-timing.test.d.mts +2 -0
- package/dist/cli-replacement/npm-install-timing.test.d.mts.map +1 -0
- package/dist/cli-replacement/npm-install-timing.test.mjs +151 -0
- package/dist/cli-replacement/npm-install-timing.test.mjs.map +1 -0
- package/dist/telemetry/logging.d.ts +7 -0
- package/dist/telemetry/logging.d.ts.map +1 -1
- package/dist/telemetry/logging.js +13 -0
- package/dist/telemetry/logging.js.map +1 -1
- package/dist/telemetry/logging.test.js +40 -0
- package/dist/telemetry/logging.test.js.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/dependency-install-classifier.mts +38 -6
- package/src/cli-replacement/dev-s3-restore.test.mts +98 -14
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +4 -0
- package/src/cli-replacement/dev.mts +155 -11
- package/src/cli-replacement/install-packages.npm-registry.test.mts +235 -0
- package/src/cli-replacement/npm-install-summary.mts +82 -0
- package/src/cli-replacement/npm-install-summary.test.mts +94 -0
- package/src/cli-replacement/npm-install-timing.mts +121 -0
- package/src/cli-replacement/npm-install-timing.test.mts +199 -0
- package/src/telemetry/logging.test.ts +55 -0
- package/src/telemetry/logging.ts +21 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { parseNpmInstallSummary } from "./npm-install-summary.mjs";
|
|
4
|
+
|
|
5
|
+
describe("parseNpmInstallSummary", () => {
|
|
6
|
+
it("reads npm's numeric count summary", () => {
|
|
7
|
+
expect(
|
|
8
|
+
parseNpmInstallSummary(
|
|
9
|
+
JSON.stringify({
|
|
10
|
+
added: 8,
|
|
11
|
+
removed: 0,
|
|
12
|
+
changed: 2,
|
|
13
|
+
audited: 432,
|
|
14
|
+
funding: 12,
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
).toEqual({ added: 8, removed: 0, changed: 2, audited: 432 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Some npm versions emit the affected packages as arrays instead of counts.
|
|
21
|
+
it("reads the array form by taking lengths", () => {
|
|
22
|
+
expect(
|
|
23
|
+
parseNpmInstallSummary(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
added: [{ name: "@dnd-kit/core" }, { name: "@dnd-kit/utilities" }],
|
|
26
|
+
removed: [],
|
|
27
|
+
changed: [{ name: "react" }],
|
|
28
|
+
audited: 432,
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
).toEqual({ added: 2, removed: 0, changed: 1, audited: 432 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// `{}` carries none of npm's summary keys, so it is indistinguishable from
|
|
35
|
+
// another tool's JSON output. Treat it as unrecognized: the caller's
|
|
36
|
+
// raw_output fallback keeps the text recoverable instead of logging a
|
|
37
|
+
// confident all-zero install that never happened.
|
|
38
|
+
it("returns null for a JSON object without any npm summary keys", () => {
|
|
39
|
+
expect(parseNpmInstallSummary("{}")).toBeNull();
|
|
40
|
+
// e.g. a different package manager honoring --json with its own shape
|
|
41
|
+
expect(
|
|
42
|
+
parseNpmInstallSummary(
|
|
43
|
+
JSON.stringify({ success: true, warnings: [], elapsed: 3200 }),
|
|
44
|
+
),
|
|
45
|
+
).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("tolerates surrounding whitespace and npm's trailing newline", () => {
|
|
49
|
+
expect(parseNpmInstallSummary('\n {"added": 1}\n')).toEqual({
|
|
50
|
+
added: 1,
|
|
51
|
+
removed: 0,
|
|
52
|
+
changed: 0,
|
|
53
|
+
audited: 0,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null for empty output", () => {
|
|
58
|
+
expect(parseNpmInstallSummary("")).toBeNull();
|
|
59
|
+
expect(parseNpmInstallSummary(" \n ")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns null for non-JSON output rather than throwing", () => {
|
|
63
|
+
expect(parseNpmInstallSummary("added 8 packages in 32s")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns null for JSON that is not an object", () => {
|
|
67
|
+
expect(parseNpmInstallSummary("[]")).toBeNull();
|
|
68
|
+
expect(parseNpmInstallSummary('"done"')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("clamps a negative count so aggregations cannot be skewed", () => {
|
|
72
|
+
expect(parseNpmInstallSummary(JSON.stringify({ added: -3 }))).toEqual({
|
|
73
|
+
added: 0,
|
|
74
|
+
removed: 0,
|
|
75
|
+
changed: 0,
|
|
76
|
+
audited: 0,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("truncates a fractional count to an integer", () => {
|
|
81
|
+
expect(parseNpmInstallSummary(JSON.stringify({ audited: 12.9 }))).toEqual({
|
|
82
|
+
added: 0,
|
|
83
|
+
removed: 0,
|
|
84
|
+
changed: 0,
|
|
85
|
+
audited: 12,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("ignores unexpected value types instead of emitting NaN", () => {
|
|
90
|
+
expect(
|
|
91
|
+
parseNpmInstallSummary(JSON.stringify({ added: "eight", removed: null })),
|
|
92
|
+
).toEqual({ added: 0, removed: 0, changed: 0, audited: 0 });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm's `--json` install summary says how many packages changed, but not where
|
|
3
|
+
* the time went. A real install reported `changed: 5, added: 0` alongside a
|
|
4
|
+
* 27 second duration - the counts cannot explain that. Measuring the same pod
|
|
5
|
+
* by hand showed the time was `reify:unpack` writing about 65,000 files (23.2s
|
|
6
|
+
* of 24.5s), while resolving metadata (`idealTree`) took 230ms. Without that
|
|
7
|
+
* split, a slow install is indistinguishable from a slow registry.
|
|
8
|
+
*
|
|
9
|
+
* npm records the split itself when run with `--timing`. It does NOT go to
|
|
10
|
+
* stdout: npm writes `<logs-dir>/<log id>-timing.json`, shaped
|
|
11
|
+
* `{ metadata, timers, unfinishedTimers }`, where `timers` is a flat object of
|
|
12
|
+
* timer name to milliseconds.
|
|
13
|
+
*/
|
|
14
|
+
import nodeFs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The only timers we are willing to emit, paired with their attribute name.
|
|
19
|
+
*
|
|
20
|
+
* This has to be a fixed list rather than a pass-through of `timers`, because
|
|
21
|
+
* npm also records one `reifyNode:node_modules/<package>` timer per package it
|
|
22
|
+
* touched. Emitting those would mean unbounded attribute cardinality and would
|
|
23
|
+
* put dependency names into telemetry.
|
|
24
|
+
*
|
|
25
|
+
* Seconds, to match the `superblocks.npm.install.duration_seconds` attribute
|
|
26
|
+
* these sit next to.
|
|
27
|
+
*/
|
|
28
|
+
const TIMER_ATTRIBUTES: ReadonlyArray<readonly [string, string]> = [
|
|
29
|
+
["idealTree", "superblocks.npm.install.ideal_tree_seconds"],
|
|
30
|
+
["reify", "superblocks.npm.install.reify_seconds"],
|
|
31
|
+
["reify:build", "superblocks.npm.install.build_seconds"],
|
|
32
|
+
["reify:loadTrees", "superblocks.npm.install.load_trees_seconds"],
|
|
33
|
+
["reify:unpack", "superblocks.npm.install.unpack_seconds"],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const TIMING_FILE_SUFFIX = "-timing.json";
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* npm's log ids are fixed-width ISO timestamps (`2026-08-07T12_00_00_000Z`),
|
|
44
|
+
* so sorting the names alphabetically orders them by time. Cheaper and more
|
|
45
|
+
* predictable than stat-ing every file for an mtime.
|
|
46
|
+
*/
|
|
47
|
+
async function listTimingFiles(logsDir: string): Promise<string[]> {
|
|
48
|
+
const entries = await nodeFs.readdir(logsDir);
|
|
49
|
+
return entries.filter((name) => name.endsWith(TIMING_FILE_SUFFIX)).sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* npm's own `logs-max` pruning covers its debug logs but leaves timing files
|
|
54
|
+
* alone, so they would pile up on the app volume for the life of the pod. We
|
|
55
|
+
* only ever need the one we just read, so drop them all once it is read.
|
|
56
|
+
*/
|
|
57
|
+
async function removeTimingFiles(
|
|
58
|
+
logsDir: string,
|
|
59
|
+
names: string[],
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
await Promise.all(
|
|
62
|
+
names.map((name) =>
|
|
63
|
+
nodeFs.unlink(path.join(logsDir, name)).catch(() => undefined),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toSeconds(value: unknown): number | undefined {
|
|
69
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
// A negative duration is meaningless and would skew any aggregation built on
|
|
73
|
+
// these attributes.
|
|
74
|
+
return Math.max(0, value) / 1000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read the phase durations npm recorded for the most recent install, then
|
|
79
|
+
* delete the timing files so the logs dir stays bounded.
|
|
80
|
+
*
|
|
81
|
+
* Returns null when there is nothing usable - no timing file, an unreadable or
|
|
82
|
+
* non-JSON file, or a file carrying none of the whitelisted timers. A timer
|
|
83
|
+
* npm did not record is omitted rather than reported as 0, because "npm
|
|
84
|
+
* unpacked nothing" and "npm never got as far as unpacking" are different
|
|
85
|
+
* facts; a recorded 0 IS emitted, since an install that unpacked nothing is
|
|
86
|
+
* exactly the case worth telling apart from a slow one.
|
|
87
|
+
*
|
|
88
|
+
* Never throws: this runs on the success path of a completed install, and no
|
|
89
|
+
* telemetry read may turn that into a failure.
|
|
90
|
+
*/
|
|
91
|
+
export async function consumeNpmInstallTimingAttributes(
|
|
92
|
+
logsDir: string,
|
|
93
|
+
): Promise<Record<string, number> | null> {
|
|
94
|
+
try {
|
|
95
|
+
const names = await listTimingFiles(logsDir);
|
|
96
|
+
const newest = names.at(-1);
|
|
97
|
+
if (newest === undefined) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const raw = await nodeFs.readFile(path.join(logsDir, newest), "utf8");
|
|
102
|
+
await removeTimingFiles(logsDir, names);
|
|
103
|
+
|
|
104
|
+
const parsed: unknown = JSON.parse(raw);
|
|
105
|
+
if (!isRecord(parsed) || !isRecord(parsed.timers)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const timers = parsed.timers;
|
|
109
|
+
|
|
110
|
+
const attributes: Record<string, number> = {};
|
|
111
|
+
for (const [timer, attribute] of TIMER_ATTRIBUTES) {
|
|
112
|
+
const seconds = toSeconds(timers[timer]);
|
|
113
|
+
if (seconds !== undefined) {
|
|
114
|
+
attributes[attribute] = seconds;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Object.keys(attributes).length > 0 ? attributes : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import nodeFs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { consumeNpmInstallTimingAttributes } from "./npm-install-timing.mjs";
|
|
8
|
+
|
|
9
|
+
let logsDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
logsDir = await nodeFs.mkdtemp(path.join(os.tmpdir(), "npm-timing-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await nodeFs.rm(logsDir, { force: true, recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Write a timing file using npm's own naming (`<log id>-timing.json`). */
|
|
20
|
+
async function writeTimingFile(
|
|
21
|
+
logId: string,
|
|
22
|
+
contents: unknown,
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const file = path.join(logsDir, `${logId}-timing.json`);
|
|
25
|
+
await nodeFs.writeFile(
|
|
26
|
+
file,
|
|
27
|
+
typeof contents === "string" ? contents : JSON.stringify(contents),
|
|
28
|
+
);
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function listTimingFiles(): Promise<string[]> {
|
|
33
|
+
const entries = await nodeFs.readdir(logsDir);
|
|
34
|
+
return entries.filter((name) => name.endsWith("-timing.json"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("consumeNpmInstallTimingAttributes", () => {
|
|
38
|
+
it("converts the whitelisted timers from milliseconds to seconds", async () => {
|
|
39
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
40
|
+
metadata: { version: "11.16.0" },
|
|
41
|
+
timers: {
|
|
42
|
+
idealTree: 230,
|
|
43
|
+
reify: 24500,
|
|
44
|
+
"reify:build": 90,
|
|
45
|
+
"reify:loadTrees": 400,
|
|
46
|
+
"reify:unpack": 23200,
|
|
47
|
+
},
|
|
48
|
+
unfinishedTimers: {},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
|
|
52
|
+
"superblocks.npm.install.build_seconds": 0.09,
|
|
53
|
+
"superblocks.npm.install.ideal_tree_seconds": 0.23,
|
|
54
|
+
"superblocks.npm.install.load_trees_seconds": 0.4,
|
|
55
|
+
"superblocks.npm.install.reify_seconds": 24.5,
|
|
56
|
+
"superblocks.npm.install.unpack_seconds": 23.2,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// npm records one `reifyNode:node_modules/<package>` timer per package it
|
|
61
|
+
// touched. Emitting those would be unbounded attribute cardinality and would
|
|
62
|
+
// put dependency names into telemetry, so only the fixed whitelist is ever
|
|
63
|
+
// emitted.
|
|
64
|
+
it("never emits a per-package reifyNode timer", async () => {
|
|
65
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
66
|
+
timers: {
|
|
67
|
+
idealTree: 100,
|
|
68
|
+
"reifyNode:node_modules/@dnd-kit/core": 12,
|
|
69
|
+
"reifyNode:node_modules/lodash": 34,
|
|
70
|
+
"reifyNode:node_modules/react": 56,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const attributes = await consumeNpmInstallTimingAttributes(logsDir);
|
|
75
|
+
|
|
76
|
+
expect(attributes).toEqual({
|
|
77
|
+
"superblocks.npm.install.ideal_tree_seconds": 0.1,
|
|
78
|
+
});
|
|
79
|
+
expect(
|
|
80
|
+
Object.keys(attributes ?? {}).some((key) => key.includes("reifyNode")),
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Which phases npm records depends on what the install did, so a missing
|
|
85
|
+
// timer is normal rather than an error.
|
|
86
|
+
it("omits timers npm did not record", async () => {
|
|
87
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
88
|
+
timers: { reify: 500 },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
|
|
92
|
+
"superblocks.npm.install.reify_seconds": 0.5,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// "npm unpacked nothing" is the signal that separates a metadata-only
|
|
97
|
+
// install from one that wrote tens of thousands of files, so a recorded zero
|
|
98
|
+
// is real data and must not be dropped as if it were missing.
|
|
99
|
+
it("emits a recorded zero rather than dropping it", async () => {
|
|
100
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
101
|
+
timers: { "reify:unpack": 0 },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
|
|
105
|
+
"superblocks.npm.install.unpack_seconds": 0,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// npm's log ids are fixed-width ISO timestamps, so the newest file is the
|
|
110
|
+
// last one alphabetically.
|
|
111
|
+
it("reads the newest file when older installs left files behind", async () => {
|
|
112
|
+
await writeTimingFile("2026-08-07T11_00_00_000Z", {
|
|
113
|
+
timers: { reify: 1000 },
|
|
114
|
+
});
|
|
115
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
116
|
+
timers: { reify: 2000 },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
|
|
120
|
+
"superblocks.npm.install.reify_seconds": 2,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// npm's `logs-max` pruning only covers its debug logs, so timing files would
|
|
125
|
+
// pile up on the app volume forever if nothing removed them.
|
|
126
|
+
it("removes the timing files it read so they cannot accumulate", async () => {
|
|
127
|
+
await writeTimingFile("2026-08-07T11_00_00_000Z", {
|
|
128
|
+
timers: { reify: 1000 },
|
|
129
|
+
});
|
|
130
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
131
|
+
timers: { reify: 2000 },
|
|
132
|
+
});
|
|
133
|
+
const debugLog = path.join(logsDir, "2026-08-07T12_00_00_000Z-debug-0.log");
|
|
134
|
+
await nodeFs.writeFile(debugLog, "npm debug output");
|
|
135
|
+
|
|
136
|
+
await consumeNpmInstallTimingAttributes(logsDir);
|
|
137
|
+
|
|
138
|
+
expect(await listTimingFiles()).toEqual([]);
|
|
139
|
+
// npm's debug log is a separate diagnostic that must survive.
|
|
140
|
+
await expect(nodeFs.readFile(debugLog, "utf8")).resolves.toBe(
|
|
141
|
+
"npm debug output",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null when the logs dir has no timing file", async () => {
|
|
146
|
+
await nodeFs.writeFile(
|
|
147
|
+
path.join(logsDir, "2026-08-07T12_00_00_000Z-debug-0.log"),
|
|
148
|
+
"npm debug output",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns null when the logs dir does not exist", async () => {
|
|
155
|
+
expect(
|
|
156
|
+
await consumeNpmInstallTimingAttributes(path.join(logsDir, "missing")),
|
|
157
|
+
).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns null for a file that is not JSON rather than throwing", async () => {
|
|
161
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", "not json at all");
|
|
162
|
+
|
|
163
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns null when the file carries no timers object", async () => {
|
|
167
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", { metadata: {} });
|
|
168
|
+
|
|
169
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns null when none of the whitelisted timers are present", async () => {
|
|
173
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
174
|
+
timers: { "npm:load": 12, "reifyNode:node_modules/lodash": 34 },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("ignores timer values that are not finite numbers", async () => {
|
|
181
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
182
|
+
timers: { idealTree: "230", reify: null, "reify:unpack": 23200 },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
|
|
186
|
+
"superblocks.npm.install.unpack_seconds": 23.2,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("clamps a negative duration so dashboards cannot be skewed", async () => {
|
|
191
|
+
await writeTimingFile("2026-08-07T12_00_00_000Z", {
|
|
192
|
+
timers: { reify: -500 },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
|
|
196
|
+
"superblocks.npm.install.reify_seconds": 0,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -156,6 +156,61 @@ describe("telemetry logger error sanitization", () => {
|
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
describe("telemetry logger infoStructured", () => {
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
vi.clearAllMocks();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("emits an INFO record carrying structured attributes (not flattened into the body)", () => {
|
|
165
|
+
const logger = getLogger();
|
|
166
|
+
|
|
167
|
+
logger.infoStructured?.("Package installation completed successfully", {
|
|
168
|
+
"superblocks.npm.install.added": 8,
|
|
169
|
+
"superblocks.npm.install.duration_seconds": 32.5,
|
|
170
|
+
"superblocks.npm.install.outcome": "success",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(emitMock).toHaveBeenCalledWith(
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
severityText: "INFO",
|
|
176
|
+
body: "sanitized-message:Package installation completed successfully",
|
|
177
|
+
attributes: {
|
|
178
|
+
"superblocks.npm.install.added": 8,
|
|
179
|
+
"superblocks.npm.install.duration_seconds": 32.5,
|
|
180
|
+
"superblocks.npm.install.outcome": "sanitized-object:success",
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
// Winston gets the body only (structured attributes go to OTel, not also
|
|
185
|
+
// to stdout log shippers as a duplicate).
|
|
186
|
+
expect(winstonLoggerMock.info).toHaveBeenCalledWith(
|
|
187
|
+
"sanitized-message:Package installation completed successfully",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("strips secret-named attribute keys before emitting", () => {
|
|
192
|
+
const logger = getLogger();
|
|
193
|
+
|
|
194
|
+
logger.infoStructured?.("install done", {
|
|
195
|
+
registry: "https://npm.example.com",
|
|
196
|
+
token: "[REDACTED:token]",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(emitMock).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
attributes: expect.objectContaining({
|
|
202
|
+
registry: "sanitized-object:https://npm.example.com",
|
|
203
|
+
}),
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
expect(emitMock).not.toHaveBeenCalledWith(
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
attributes: expect.objectContaining({ token: expect.anything() }),
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
159
214
|
describe("telemetry logger warnStructured", () => {
|
|
160
215
|
beforeEach(() => {
|
|
161
216
|
vi.clearAllMocks();
|
package/src/telemetry/logging.ts
CHANGED
|
@@ -79,6 +79,13 @@ export interface Logger {
|
|
|
79
79
|
debug: (...messages: unknown[]) => void;
|
|
80
80
|
info: (...messages: unknown[]) => void;
|
|
81
81
|
warn: (...messages: unknown[]) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Emit an INFO-level log whose structured attributes are exported as OTel
|
|
84
|
+
* log attributes (Datadog facets) instead of being flattened into the
|
|
85
|
+
* message body. Plain `info()` joins every argument into one body string, so
|
|
86
|
+
* attributes passed to it are never queryable.
|
|
87
|
+
*/
|
|
88
|
+
infoStructured?: (message: string, attributes: LogAttributes) => void;
|
|
82
89
|
/**
|
|
83
90
|
* Emit a WARN-level log whose structured attributes are exported as OTel log
|
|
84
91
|
* attributes (Datadog facets) instead of being flattened into the message
|
|
@@ -120,6 +127,18 @@ const logger: Logger = Object.freeze({
|
|
|
120
127
|
});
|
|
121
128
|
winstonLogger.warn(body);
|
|
122
129
|
},
|
|
130
|
+
infoStructured: (message: string, attributes: LogAttributes) => {
|
|
131
|
+
const body = sanitizeLogMessage(message);
|
|
132
|
+
// Same sanitize-then-emit contract as warnStructured below.
|
|
133
|
+
const safeAttributes = sanitizeLogObject(attributes);
|
|
134
|
+
getTracedLogger().emit({
|
|
135
|
+
severityNumber: SeverityNumber.INFO,
|
|
136
|
+
severityText: "INFO",
|
|
137
|
+
body,
|
|
138
|
+
attributes: safeAttributes,
|
|
139
|
+
});
|
|
140
|
+
winstonLogger.info(body);
|
|
141
|
+
},
|
|
123
142
|
warnStructured: (message: string, attributes: LogAttributes) => {
|
|
124
143
|
const body = sanitizeLogMessage(message);
|
|
125
144
|
// sanitizeLogObject strips secret-named keys and redacts secret values,
|
|
@@ -174,6 +193,8 @@ export function getLogger(
|
|
|
174
193
|
// above, it forwards raw args to the supplied function and does NOT
|
|
175
194
|
// sanitize or emit OTel attributes. Production callers use the default
|
|
176
195
|
// logger (and the vite-plugin wrapLogger) which sanitize before emitting.
|
|
196
|
+
infoStructured: (message: string, attributes: LogAttributes) =>
|
|
197
|
+
loggerOverride(message, attributes),
|
|
177
198
|
warnStructured: (message: string, attributes: LogAttributes) =>
|
|
178
199
|
loggerOverride(message, attributes),
|
|
179
200
|
error: loggerOverride as (message: string, meta?: ErrorMeta) => void,
|