@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.
Files changed (49) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dependency-install-classifier.d.mts +3 -1
  3. package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -1
  4. package/dist/cli-replacement/dependency-install-classifier.mjs +16 -5
  5. package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -1
  6. package/dist/cli-replacement/dev-s3-restore.test.mjs +73 -14
  7. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  8. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +4 -0
  9. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  10. package/dist/cli-replacement/dev.d.mts.map +1 -1
  11. package/dist/cli-replacement/dev.mjs +130 -12
  12. package/dist/cli-replacement/dev.mjs.map +1 -1
  13. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +153 -0
  14. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -1
  15. package/dist/cli-replacement/npm-install-summary.d.mts +30 -0
  16. package/dist/cli-replacement/npm-install-summary.d.mts.map +1 -0
  17. package/dist/cli-replacement/npm-install-summary.mjs +67 -0
  18. package/dist/cli-replacement/npm-install-summary.mjs.map +1 -0
  19. package/dist/cli-replacement/npm-install-summary.test.d.mts +2 -0
  20. package/dist/cli-replacement/npm-install-summary.test.d.mts.map +1 -0
  21. package/dist/cli-replacement/npm-install-summary.test.mjs +70 -0
  22. package/dist/cli-replacement/npm-install-summary.test.mjs.map +1 -0
  23. package/dist/cli-replacement/npm-install-timing.d.mts +16 -0
  24. package/dist/cli-replacement/npm-install-timing.d.mts.map +1 -0
  25. package/dist/cli-replacement/npm-install-timing.mjs +104 -0
  26. package/dist/cli-replacement/npm-install-timing.mjs.map +1 -0
  27. package/dist/cli-replacement/npm-install-timing.test.d.mts +2 -0
  28. package/dist/cli-replacement/npm-install-timing.test.d.mts.map +1 -0
  29. package/dist/cli-replacement/npm-install-timing.test.mjs +151 -0
  30. package/dist/cli-replacement/npm-install-timing.test.mjs.map +1 -0
  31. package/dist/telemetry/logging.d.ts +7 -0
  32. package/dist/telemetry/logging.d.ts.map +1 -1
  33. package/dist/telemetry/logging.js +13 -0
  34. package/dist/telemetry/logging.js.map +1 -1
  35. package/dist/telemetry/logging.test.js +40 -0
  36. package/dist/telemetry/logging.test.js.map +1 -1
  37. package/package.json +6 -6
  38. package/src/cli-replacement/dependency-install-classifier.mts +38 -6
  39. package/src/cli-replacement/dev-s3-restore.test.mts +98 -14
  40. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +4 -0
  41. package/src/cli-replacement/dev.mts +155 -11
  42. package/src/cli-replacement/install-packages.npm-registry.test.mts +235 -0
  43. package/src/cli-replacement/npm-install-summary.mts +82 -0
  44. package/src/cli-replacement/npm-install-summary.test.mts +94 -0
  45. package/src/cli-replacement/npm-install-timing.mts +121 -0
  46. package/src/cli-replacement/npm-install-timing.test.mts +199 -0
  47. package/src/telemetry/logging.test.ts +55 -0
  48. package/src/telemetry/logging.ts +21 -0
  49. package/tsconfig.tsbuildinfo +1 -1
@@ -26,6 +26,7 @@ import {
26
26
  AiService,
27
27
  AiServiceFeatureFlags,
28
28
  SnapshotManager,
29
+ hasEditLifecycleCapability,
29
30
  isSdkApiTemplate,
30
31
  stripResolvedFromLockfile,
31
32
  } from "@superblocksteam/vite-plugin-file-sync/ai-service";
@@ -40,6 +41,7 @@ import {
40
41
  type NpmRegistryClient,
41
42
  type NpmRegistryFetchResult,
42
43
  type ParseContext,
44
+ NpmInstallBlocked,
43
45
  shouldIgnoreInstallScripts,
44
46
  } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
45
47
  import { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/operation-queue";
@@ -55,7 +57,11 @@ import type {
55
57
  TokenManager,
56
58
  SuperblocksSdk,
57
59
  } from "../index.js";
58
- import { getTracer, shutdownTelemetry } from "../telemetry/index.js";
60
+ import {
61
+ getTracer,
62
+ isCloudPrem,
63
+ shutdownTelemetry,
64
+ } from "../telemetry/index.js";
59
65
  import { getErrorMeta, getLogger, type Logger } from "../telemetry/logging.js";
60
66
  import type {
61
67
  ApplicationConfig,
@@ -68,6 +74,7 @@ import {
68
74
  } from "./automatic-upgrades.js";
69
75
  import {
70
76
  classifyInitialInstallError,
77
+ initialInstallFailedFromBlocked,
71
78
  InitialInstallFailed,
72
79
  UpgradeFailed,
73
80
  } from "./dependency-install-classifier.mjs";
@@ -82,6 +89,8 @@ import {
82
89
  syncHomeNpmrc,
83
90
  } from "./home-npmrc.mjs";
84
91
  import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
92
+ import { parseNpmInstallSummary } from "./npm-install-summary.mjs";
93
+ import { consumeNpmInstallTimingAttributes } from "./npm-install-timing.mjs";
85
94
  import {
86
95
  didPackageJsonSnapshotChange,
87
96
  packageJsonSnapshot,
@@ -455,6 +464,39 @@ async function normalizePackageJsonForNpm(cwd: string, logger: Logger) {
455
464
  }
456
465
  }
457
466
 
467
+ function emitSuccessTelemetry(logger: Logger, emit: () => void): void {
468
+ try {
469
+ emit();
470
+ } catch (err) {
471
+ try {
472
+ logger.warn(
473
+ "Could not emit package installation success telemetry",
474
+ getErrorMeta(err),
475
+ );
476
+ } catch {
477
+ // Telemetry must not turn a successful install into a failure.
478
+ }
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Plain `info()` joins every argument into one message string, so attributes
484
+ * passed to it never reach Datadog as facets. `infoStructured` emits them as
485
+ * OTel log attributes; the fallback is only for custom Logger implementations
486
+ * that don't provide it.
487
+ */
488
+ function logInstallSuccess(
489
+ logger: Logger,
490
+ message: string,
491
+ attributes: Record<string, boolean | number>,
492
+ ): void {
493
+ if (logger.infoStructured) {
494
+ logger.infoStructured(message, attributes);
495
+ } else {
496
+ logger.info(message, attributes);
497
+ }
498
+ }
499
+
458
500
  export async function installPackages(
459
501
  cwd: string,
460
502
  logger: Logger,
@@ -466,6 +508,10 @@ export async function installPackages(
466
508
  let runInstall:
467
509
  | (() => Promise<{ stdout: string; stderr: string }>)
468
510
  | undefined;
511
+ // Hoisted alongside `runInstall` so the ENOSPC recovery path in the catch can
512
+ // report install duration too. Set as soon as the retryable command exists so
513
+ // every recovery-eligible path has a start time.
514
+ let installStart: number | undefined;
469
515
  try {
470
516
  const pm = await detect({
471
517
  strategies: [
@@ -517,10 +563,21 @@ export async function installPackages(
517
563
  // `--json` makes npm channel the structured error envelope (code +
518
564
  // summary + detail) onto stdout so a failure can be classified into a
519
565
  // DependencyInstallError. Output-only: it does not change resolution,
520
- // only how npm reports it. The success-path `logger.info(stdout)` below
521
- // then logs JSON, which is acceptable.
566
+ // only how npm reports it. On success the same envelope carries the
567
+ // install summary, which the success path below turns into log
568
+ // attributes.
569
+ // `--timing` makes npm record how long each install phase took, which is
570
+ // what tells a slow registry apart from writing tens of thousands of files.
571
+ // It does not go to stdout - npm writes it to a file in its logs dir, which
572
+ // the success path below reads back. It costs no install time (npm keeps
573
+ // these timers either way; the flag only decides whether they get written
574
+ // out at the end), but it does add one line per package to npm's debug log
575
+ // in the same dir - bounded by the `logs-max` pruning that already applies
576
+ // there.
522
577
  const baseArgs =
523
- pm.agent === "npm" ? ["--fund=false", "--audit=false", "--json"] : [];
578
+ pm.agent === "npm"
579
+ ? ["--fund=false", "--audit=false", "--json", "--timing"]
580
+ : [];
524
581
  const installArgs = ignoreScripts
525
582
  ? [...baseArgs, "--ignore-scripts"]
526
583
  : baseArgs;
@@ -576,6 +633,7 @@ export async function installPackages(
576
633
  cwd,
577
634
  env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
578
635
  });
636
+ installStart = Date.now();
579
637
 
580
638
  // Cheap `df` snapshot before the install (statfs only — no directory walk
581
639
  // on the happy path). On a 3Gi live-edit PVC this is the baseline that
@@ -593,8 +651,55 @@ export async function installPackages(
593
651
  }
594
652
 
595
653
  const { stdout } = await runInstall();
596
- logger.info("Package installation completed successfully");
597
- logger.info(stdout);
654
+ const installDurationMs = Date.now() - installStart;
655
+ // npm's `--timing` breakdown lives in a file, so it has to be read back
656
+ // once the install is done. Only npm writes one; reading under pnpm would
657
+ // report a leftover npm file as this install's timings. The `catch` keeps
658
+ // the "telemetry never fails a completed install" invariant at the call
659
+ // site, so it holds even if the reader stops swallowing its own errors.
660
+ const timingAttributes =
661
+ pm.agent === "npm"
662
+ ? await consumeNpmInstallTimingAttributes(logsDir).catch(() => null)
663
+ : null;
664
+ // `--json` is already passed for error classification; on success it also
665
+ // carries the only record of what the install DID. Emit it as attributes
666
+ // rather than as the log message: passing the raw envelope to
667
+ // `logger.info(stdout)` surfaced as an empty line in Datadog, so the counts
668
+ // that size this install were collected and then thrown away.
669
+ emitSuccessTelemetry(logger, () => {
670
+ const summary = parseNpmInstallSummary(stdout);
671
+ const logSuccess = (attributes: Record<string, boolean | number>) =>
672
+ logInstallSuccess(
673
+ logger,
674
+ "Package installation completed successfully",
675
+ attributes,
676
+ );
677
+ // Reuse the existing npm-install namespace and its duration unit. The
678
+ // package counts are log-only sizing data from this separate install path.
679
+ const commonAttributes = {
680
+ "superblocks.npm.install.disk_recovered": false,
681
+ "superblocks.npm.install.duration_seconds": installDurationMs / 1000,
682
+ ...timingAttributes,
683
+ };
684
+ if (summary) {
685
+ logSuccess({
686
+ ...commonAttributes,
687
+ "superblocks.npm.install.added": summary.added,
688
+ "superblocks.npm.install.removed": summary.removed,
689
+ "superblocks.npm.install.changed": summary.changed,
690
+ "superblocks.npm.install.audited": summary.audited,
691
+ "superblocks.npm.install.summary_parsed": true,
692
+ });
693
+ } else {
694
+ // Free-form output is not a safe OTel attribute. Keep duration queryable
695
+ // and send non-empty output through the regular local/logging path.
696
+ logSuccess({
697
+ ...commonAttributes,
698
+ "superblocks.npm.install.summary_parsed": false,
699
+ });
700
+ if (stdout.trim() !== "") logger.info(stdout);
701
+ }
702
+ });
598
703
  } catch (error) {
599
704
  // `util.promisify(child_process.exec)` preserves `.stdout`/`.stderr` on
600
705
  // the rejected error separately; with `--json`, the structured npm error
@@ -619,7 +724,12 @@ export async function installPackages(
619
724
  // only autopsy we can get of a PVC that `reclaimPolicy: Delete` destroys
620
725
  // at teardown. `runInstall` is only set once the command was resolved; if
621
726
  // the failure happened before that, there's nothing to retry.
622
- if (serverError.category === "disk_full" && runInstall) {
727
+ if (
728
+ serverError.category === "disk_full" &&
729
+ runInstall &&
730
+ installStart !== undefined
731
+ ) {
732
+ const startedAt = installStart;
623
733
  let recovery: Awaited<ReturnType<typeof recoverFromDiskFull>> | undefined;
624
734
  try {
625
735
  recovery = await recoverFromDiskFull(cwd, runInstall, logger);
@@ -632,11 +742,26 @@ export async function installPackages(
632
742
  });
633
743
  }
634
744
  if (recovery) {
635
- devServerMetrics.recordDiskRecovery({ recovered: recovery.recovered });
745
+ emitSuccessTelemetry(logger, () => {
746
+ devServerMetrics.recordDiskRecovery({
747
+ recovered: recovery.recovered,
748
+ });
749
+ });
636
750
  if (recovery.recovered) {
637
- logger.info(
638
- "Package installation completed successfully after disk recovery",
639
- );
751
+ // Recovered installs previously emitted no install telemetry at all,
752
+ // so the slowest installs on the fleet were the ones missing from the
753
+ // data. Duration spans every attempt including the reclaim.
754
+ emitSuccessTelemetry(logger, () => {
755
+ logInstallSuccess(
756
+ logger,
757
+ "Package installation completed successfully after disk recovery",
758
+ {
759
+ "superblocks.npm.install.duration_seconds":
760
+ (Date.now() - startedAt) / 1000,
761
+ "superblocks.npm.install.disk_recovered": true,
762
+ },
763
+ );
764
+ });
640
765
  return;
641
766
  }
642
767
  // Reclassify the final attempt so the surfaced error reflects the last
@@ -1181,6 +1306,10 @@ export async function dev(options: {
1181
1306
  appRootDirPath: options.cwd,
1182
1307
  applicationId: applicationConfig.id,
1183
1308
  organizationId: currentUser.organizations[0].id,
1309
+ agentType: currentUser.organizations[0].agentType,
1310
+ isCloudPrem,
1311
+ managedNativeDbLifecycleCapableForEdit:
1312
+ hasEditLifecycleCapability(currentUser.agents),
1184
1313
  fsOperationQueue,
1185
1314
  draftInterface: syncService as DraftInterface,
1186
1315
  rpcClient,
@@ -1515,6 +1644,21 @@ export async function dev(options: {
1515
1644
  );
1516
1645
  }
1517
1646
 
1647
+ try {
1648
+ await aiService?.remediateDeclaredDependencies();
1649
+ } catch (error) {
1650
+ // Soft package.json rewrite failures are already swallowed inside
1651
+ // applyPackageJsonSubstitutions. A gate/registry hard-block must
1652
+ // not fall through into the ungated CLI install below.
1653
+ if (error instanceof NpmInstallBlocked) {
1654
+ throw initialInstallFailedFromBlocked(error);
1655
+ }
1656
+ logger.warn(
1657
+ "[pkg-security] startup remediation failed; keeping declared versions",
1658
+ getErrorMeta(error),
1659
+ );
1660
+ }
1661
+
1518
1662
  const packageJsonAfter = await readPkgJson(cwd);
1519
1663
 
1520
1664
  let packageJsonSnapshotBefore: PackageJsonSnapshot | null = null;
@@ -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
+ }