@superblocksteam/sdk 2.0.150-next.0 → 2.0.150-next.2

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 (38) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dev.d.mts.map +1 -1
  3. package/dist/cli-replacement/dev.mjs +111 -8
  4. package/dist/cli-replacement/dev.mjs.map +1 -1
  5. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +153 -0
  6. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -1
  7. package/dist/cli-replacement/npm-install-summary.d.mts +30 -0
  8. package/dist/cli-replacement/npm-install-summary.d.mts.map +1 -0
  9. package/dist/cli-replacement/npm-install-summary.mjs +67 -0
  10. package/dist/cli-replacement/npm-install-summary.mjs.map +1 -0
  11. package/dist/cli-replacement/npm-install-summary.test.d.mts +2 -0
  12. package/dist/cli-replacement/npm-install-summary.test.d.mts.map +1 -0
  13. package/dist/cli-replacement/npm-install-summary.test.mjs +70 -0
  14. package/dist/cli-replacement/npm-install-summary.test.mjs.map +1 -0
  15. package/dist/cli-replacement/npm-install-timing.d.mts +16 -0
  16. package/dist/cli-replacement/npm-install-timing.d.mts.map +1 -0
  17. package/dist/cli-replacement/npm-install-timing.mjs +104 -0
  18. package/dist/cli-replacement/npm-install-timing.mjs.map +1 -0
  19. package/dist/cli-replacement/npm-install-timing.test.d.mts +2 -0
  20. package/dist/cli-replacement/npm-install-timing.test.d.mts.map +1 -0
  21. package/dist/cli-replacement/npm-install-timing.test.mjs +151 -0
  22. package/dist/cli-replacement/npm-install-timing.test.mjs.map +1 -0
  23. package/dist/telemetry/logging.d.ts +7 -0
  24. package/dist/telemetry/logging.d.ts.map +1 -1
  25. package/dist/telemetry/logging.js +13 -0
  26. package/dist/telemetry/logging.js.map +1 -1
  27. package/dist/telemetry/logging.test.js +40 -0
  28. package/dist/telemetry/logging.test.js.map +1 -1
  29. package/package.json +6 -6
  30. package/src/cli-replacement/dev.mts +128 -10
  31. package/src/cli-replacement/install-packages.npm-registry.test.mts +235 -0
  32. package/src/cli-replacement/npm-install-summary.mts +82 -0
  33. package/src/cli-replacement/npm-install-summary.test.mts +94 -0
  34. package/src/cli-replacement/npm-install-timing.mts +121 -0
  35. package/src/cli-replacement/npm-install-timing.test.mts +199 -0
  36. package/src/telemetry/logging.test.ts +55 -0
  37. package/src/telemetry/logging.ts +21 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -26,6 +26,7 @@ import {
26
26
  } from "vitest";
27
27
 
28
28
  import type { installPackages as InstallPackagesType } from "./dev.mjs";
29
+ import type * as DiskSpace from "./disk-space.mjs";
29
30
 
30
31
  const execMock = vi.fn();
31
32
  const execFileMock = vi.fn();
@@ -45,6 +46,17 @@ vi.mock("package-manager-detector", () => ({
45
46
  resolveCommand: (...args: unknown[]) => resolveCommandMock(...args),
46
47
  }));
47
48
 
49
+ const recoverFromDiskFullMock = vi.fn();
50
+ vi.mock("./disk-space.mjs", async (importOriginal) => {
51
+ const actual = await importOriginal<typeof DiskSpace>();
52
+ return {
53
+ ...actual,
54
+ getDiskSpace: vi.fn(async () => null),
55
+ recoverFromDiskFull: (...args: unknown[]) =>
56
+ recoverFromDiskFullMock(...args),
57
+ };
58
+ });
59
+
48
60
  vi.mock("node:fs/promises", () => ({
49
61
  readFile: vi.fn(async () => "{}"),
50
62
  writeFile: vi.fn(async () => undefined),
@@ -53,8 +65,20 @@ vi.mock("node:fs/promises", () => ({
53
65
  mkdir: vi.fn(async () => undefined),
54
66
  }));
55
67
 
68
+ // npm's `--timing` phase breakdown is read back from a file on disk. The
69
+ // parser has its own coverage in `npm-install-timing.test.mts`; here we only
70
+ // care that whatever it returns reaches the success log line.
71
+ const consumeTimingMock = vi.fn<
72
+ (logsDir: string) => Promise<Record<string, number> | null>
73
+ >(async () => null);
74
+ vi.mock("./npm-install-timing.mjs", () => ({
75
+ consumeNpmInstallTimingAttributes: (logsDir: string) =>
76
+ consumeTimingMock(logsDir),
77
+ }));
78
+
56
79
  const mockLogger = {
57
80
  info: vi.fn(),
81
+ infoStructured: vi.fn(),
58
82
  warn: vi.fn(),
59
83
  error: vi.fn(),
60
84
  debug: vi.fn(),
@@ -118,6 +142,8 @@ beforeAll(async () => {
118
142
 
119
143
  beforeEach(() => {
120
144
  vi.clearAllMocks();
145
+ mockLogger.infoStructured.mockReset();
146
+ mockLogger.warn.mockReset();
121
147
  // Default: npm package manager, install resolves to a real command so we
122
148
  // can read back the args the call site passed in.
123
149
  detectMock.mockResolvedValue({
@@ -146,6 +172,8 @@ beforeEach(() => {
146
172
  }
147
173
  },
148
174
  );
175
+ recoverFromDiskFullMock.mockResolvedValue(undefined);
176
+ consumeTimingMock.mockResolvedValue(null);
149
177
  });
150
178
 
151
179
  function getResolveCommandArgs(): string[] {
@@ -183,6 +211,7 @@ describe("installPackages honors allow_install_scripts policy", () => {
183
211
  // Existing baseline npm flags must survive.
184
212
  expect(args).toContain("--fund=false");
185
213
  expect(args).toContain("--audit=false");
214
+ expect(args).toContain("--timing");
186
215
  });
187
216
 
188
217
  it("does NOT append --ignore-scripts when allow_install_scripts === true", async () => {
@@ -252,6 +281,8 @@ describe("installPackages honors allow_install_scripts policy", () => {
252
281
  expect(args).toContain("--ignore-scripts");
253
282
  expect(args).not.toContain("--fund=false");
254
283
  expect(args).not.toContain("--audit=false");
284
+ // pnpm has no equivalent of npm's phase timing.
285
+ expect(args).not.toContain("--timing");
255
286
  });
256
287
  });
257
288
 
@@ -343,3 +374,207 @@ describe("installPackages userconfig env plumbing", () => {
343
374
  // (`userconfig-env.integration.test.mts`) would fail to resolve
344
375
  // `npm`/`pnpm` on PATH if the overlay clobbered it.
345
376
  });
377
+
378
+ describe("installPackages success logging", () => {
379
+ it("keeps unrecognized package-manager output out of structured attributes", async () => {
380
+ const { installPackages } = await import("./dev.mjs");
381
+
382
+ await installPackages("/tmp/app", mockLogger as never);
383
+
384
+ expect(mockLogger.infoStructured).toHaveBeenCalledWith(
385
+ "Package installation completed successfully",
386
+ {
387
+ "superblocks.npm.install.disk_recovered": false,
388
+ "superblocks.npm.install.duration_seconds": expect.any(Number),
389
+ "superblocks.npm.install.summary_parsed": false,
390
+ },
391
+ );
392
+ expect(mockLogger.info).toHaveBeenCalledWith("ok");
393
+ });
394
+
395
+ it("marks parsed npm summaries and emits their counts", async () => {
396
+ const stdout = JSON.stringify({
397
+ added: 8,
398
+ audited: 432,
399
+ changed: 2,
400
+ removed: 0,
401
+ });
402
+ execMock.mockImplementation(
403
+ (
404
+ _cmd: string,
405
+ _opts: unknown,
406
+ cb?: (err: null, result: { stdout: string }) => void,
407
+ ) => cb?.(null, { stdout }),
408
+ );
409
+ const { installPackages } = await import("./dev.mjs");
410
+
411
+ await installPackages("/tmp/app", mockLogger as never);
412
+
413
+ expect(mockLogger.infoStructured).toHaveBeenCalledWith(
414
+ "Package installation completed successfully",
415
+ {
416
+ "superblocks.npm.install.added": 8,
417
+ "superblocks.npm.install.audited": 432,
418
+ "superblocks.npm.install.changed": 2,
419
+ "superblocks.npm.install.disk_recovered": false,
420
+ "superblocks.npm.install.duration_seconds": expect.any(Number),
421
+ "superblocks.npm.install.removed": 0,
422
+ "superblocks.npm.install.summary_parsed": true,
423
+ },
424
+ );
425
+ expect(mockLogger.info).not.toHaveBeenCalledWith(stdout);
426
+ });
427
+
428
+ it("joins npm's phase timings to the same success log line", async () => {
429
+ consumeTimingMock.mockResolvedValue({
430
+ "superblocks.npm.install.ideal_tree_seconds": 0.23,
431
+ "superblocks.npm.install.unpack_seconds": 23.2,
432
+ });
433
+ const stdout = JSON.stringify({
434
+ added: 0,
435
+ audited: 432,
436
+ changed: 5,
437
+ removed: 0,
438
+ });
439
+ execMock.mockImplementation(
440
+ (
441
+ _cmd: string,
442
+ _opts: unknown,
443
+ cb?: (err: null, result: { stdout: string }) => void,
444
+ ) => cb?.(null, { stdout }),
445
+ );
446
+ const { installPackages } = await import("./dev.mjs");
447
+
448
+ await installPackages("/tmp/app", mockLogger as never);
449
+
450
+ // npm writes the timing file into the same dir as its debug log.
451
+ expect(consumeTimingMock).toHaveBeenCalledWith(
452
+ "/tmp/app/.superblocks/logs",
453
+ );
454
+ expect(mockLogger.infoStructured).toHaveBeenCalledWith(
455
+ "Package installation completed successfully",
456
+ {
457
+ "superblocks.npm.install.added": 0,
458
+ "superblocks.npm.install.audited": 432,
459
+ "superblocks.npm.install.changed": 5,
460
+ "superblocks.npm.install.disk_recovered": false,
461
+ "superblocks.npm.install.duration_seconds": expect.any(Number),
462
+ "superblocks.npm.install.ideal_tree_seconds": 0.23,
463
+ "superblocks.npm.install.removed": 0,
464
+ "superblocks.npm.install.summary_parsed": true,
465
+ "superblocks.npm.install.unpack_seconds": 23.2,
466
+ },
467
+ );
468
+ });
469
+
470
+ it("still logs the counts when there is no timing file to read", async () => {
471
+ consumeTimingMock.mockResolvedValue(null);
472
+ const { installPackages } = await import("./dev.mjs");
473
+
474
+ await installPackages("/tmp/app", mockLogger as never);
475
+
476
+ expect(mockLogger.infoStructured).toHaveBeenCalledWith(
477
+ "Package installation completed successfully",
478
+ {
479
+ "superblocks.npm.install.disk_recovered": false,
480
+ "superblocks.npm.install.duration_seconds": expect.any(Number),
481
+ "superblocks.npm.install.summary_parsed": false,
482
+ },
483
+ );
484
+ });
485
+
486
+ it("does not read a timing file under pnpm, which cannot write one", async () => {
487
+ detectMock.mockResolvedValue({
488
+ name: "pnpm",
489
+ agent: "pnpm",
490
+ version: "11.0.0",
491
+ });
492
+ resolveCommandMock.mockImplementation(
493
+ (_agent: string, _action: string, args: string[]) => ({
494
+ command: "pnpm",
495
+ args: ["install", ...args],
496
+ }),
497
+ );
498
+ const { installPackages } = await import("./dev.mjs");
499
+
500
+ await installPackages("/tmp/app", mockLogger as never);
501
+
502
+ // A leftover npm timing file must not be reported as pnpm's timings.
503
+ expect(consumeTimingMock).not.toHaveBeenCalled();
504
+ });
505
+
506
+ it("does not fail a completed install when the timing read rejects", async () => {
507
+ consumeTimingMock.mockRejectedValue(new Error("logs dir unreadable"));
508
+ const { installPackages } = await import("./dev.mjs");
509
+
510
+ await expect(
511
+ installPackages("/tmp/app", mockLogger as never),
512
+ ).resolves.toBeUndefined();
513
+
514
+ expect(execMock).toHaveBeenCalledTimes(1);
515
+ expect(mockLogger.infoStructured).toHaveBeenCalledWith(
516
+ "Package installation completed successfully",
517
+ expect.objectContaining({
518
+ "superblocks.npm.install.duration_seconds": expect.any(Number),
519
+ }),
520
+ );
521
+ });
522
+
523
+ it("does not fail or retry when success logging throws", async () => {
524
+ mockLogger.infoStructured.mockImplementationOnce(() => {
525
+ throw new Error("telemetry unavailable");
526
+ });
527
+ mockLogger.warn.mockImplementation((message: string) => {
528
+ if (message === "Could not emit package installation success telemetry") {
529
+ throw new Error("warning unavailable");
530
+ }
531
+ });
532
+ const { installPackages } = await import("./dev.mjs");
533
+
534
+ await expect(
535
+ installPackages("/tmp/app", mockLogger),
536
+ ).resolves.toBeUndefined();
537
+
538
+ expect(execMock).toHaveBeenCalledTimes(1);
539
+ expect(mockLogger.error).not.toHaveBeenCalledWith(
540
+ "Error during package installation",
541
+ expect.anything(),
542
+ );
543
+ });
544
+
545
+ it("does not fail after disk recovery when success logging throws", async () => {
546
+ execMock.mockImplementation(
547
+ (
548
+ _cmd: string,
549
+ _opts: unknown,
550
+ cb?: (err: NodeJS.ErrnoException) => void,
551
+ ) => {
552
+ const error: NodeJS.ErrnoException = new Error(
553
+ "no space left on device",
554
+ );
555
+ error.code = "ENOSPC";
556
+ cb?.(error);
557
+ },
558
+ );
559
+ recoverFromDiskFullMock.mockResolvedValue({ recovered: true });
560
+ mockLogger.infoStructured.mockImplementationOnce(() => {
561
+ throw new Error("telemetry unavailable");
562
+ });
563
+ mockLogger.warn.mockImplementation((message: string) => {
564
+ if (message === "Could not emit package installation success telemetry") {
565
+ throw new Error("warning unavailable");
566
+ }
567
+ });
568
+ const { installPackages } = await import("./dev.mjs");
569
+
570
+ await expect(
571
+ installPackages("/tmp/app", mockLogger),
572
+ ).resolves.toBeUndefined();
573
+
574
+ expect(recoverFromDiskFullMock).toHaveBeenCalledTimes(1);
575
+ expect(mockLogger.error).not.toHaveBeenCalledWith(
576
+ "Error during package installation",
577
+ expect.anything(),
578
+ );
579
+ });
580
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `npm install` already runs with `--json` so a failure can be classified from
3
+ * the structured error envelope. On the SUCCESS path that same JSON was passed
4
+ * straight to `logger.info(stdout)` as the log MESSAGE, which is why Datadog
5
+ * shows an empty line after "Package installation completed successfully" and
6
+ * the counts never reached anyone.
7
+ *
8
+ * Those counts are the only evidence of what an install actually did. Deciding
9
+ * whether to persist a node_modules delta, and whether the trade is worth it,
10
+ * needs to distinguish "added 8 packages" from "added nothing and still spent
11
+ * 52 seconds".
12
+ */
13
+
14
+ export type NpmInstallSummary = {
15
+ added: number;
16
+ removed: number;
17
+ changed: number;
18
+ audited: number;
19
+ };
20
+
21
+ /**
22
+ * npm reports affected packages either as counts or as arrays of package
23
+ * objects, depending on version. Normalize both to a count and treat anything
24
+ * else as zero, so a shape change upstream degrades to an under-count rather
25
+ * than `NaN` in telemetry.
26
+ */
27
+ function toCount(value: unknown): number {
28
+ if (typeof value === "number" && Number.isFinite(value)) {
29
+ // Clamp: a negative count is meaningless and would skew any aggregation
30
+ // built on these attributes.
31
+ return Math.max(0, Math.trunc(value));
32
+ }
33
+ if (Array.isArray(value)) {
34
+ return value.length;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ /**
40
+ * Parse npm's `--json` install summary.
41
+ *
42
+ * Returns null when there is nothing parseable — no output, output that is
43
+ * not a JSON object, or a JSON object that carries none of npm's summary keys
44
+ * (the install may have run under yarn/pnpm, whose JSON output would otherwise
45
+ * masquerade as a confidently-measured no-op npm install). Callers should fall
46
+ * back to logging the raw text so an unrecognized shape is still recoverable
47
+ * from logs.
48
+ */
49
+ export function parseNpmInstallSummary(
50
+ stdout: string,
51
+ ): NpmInstallSummary | null {
52
+ const trimmed = stdout.trim();
53
+ if (trimmed === "") {
54
+ return null;
55
+ }
56
+
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(trimmed);
60
+ } catch {
61
+ return null;
62
+ }
63
+
64
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
65
+ return null;
66
+ }
67
+
68
+ const record = parsed as Record<string, unknown>;
69
+ const hasNpmSummaryKey = ["added", "removed", "changed", "audited"].some(
70
+ (key) => key in record,
71
+ );
72
+ if (!hasNpmSummaryKey) {
73
+ return null;
74
+ }
75
+
76
+ return {
77
+ added: toCount(record.added),
78
+ removed: toCount(record.removed),
79
+ changed: toCount(record.changed),
80
+ audited: toCount(record.audited),
81
+ };
82
+ }
@@ -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
+ }