@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
|
@@ -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 {
|
|
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.
|
|
521
|
-
//
|
|
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"
|
|
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
|
-
|
|
597
|
-
|
|
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 (
|
|
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
|
-
|
|
745
|
+
emitSuccessTelemetry(logger, () => {
|
|
746
|
+
devServerMetrics.recordDiskRecovery({
|
|
747
|
+
recovered: recovery.recovered,
|
|
748
|
+
});
|
|
749
|
+
});
|
|
636
750
|
if (recovery.recovered) {
|
|
637
|
-
|
|
638
|
-
|
|
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
|
+
}
|