@superblocksteam/sdk 2.0.129-next.0 → 2.0.130-next.0

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 (75) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +235 -42
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +406 -3
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev.d.mts.map +1 -1
  8. package/dist/cli-replacement/dev.mjs +8 -9
  9. package/dist/cli-replacement/dev.mjs.map +1 -1
  10. package/dist/cli-replacement/home-npmrc.d.mts +6 -0
  11. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -1
  12. package/dist/cli-replacement/home-npmrc.mjs +22 -0
  13. package/dist/cli-replacement/home-npmrc.mjs.map +1 -1
  14. package/dist/client.billing-usage.test.d.ts +2 -0
  15. package/dist/client.billing-usage.test.d.ts.map +1 -0
  16. package/dist/client.billing-usage.test.js +66 -0
  17. package/dist/client.billing-usage.test.js.map +1 -0
  18. package/dist/client.d.ts +41 -0
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +15 -1
  21. package/dist/client.js.map +1 -1
  22. package/dist/dev-utils/dev-server-metrics.boot.test.d.mts +2 -0
  23. package/dist/dev-utils/dev-server-metrics.boot.test.d.mts.map +1 -0
  24. package/dist/dev-utils/dev-server-metrics.boot.test.mjs +55 -0
  25. package/dist/dev-utils/dev-server-metrics.boot.test.mjs.map +1 -0
  26. package/dist/dev-utils/dev-server-metrics.d.mts +32 -0
  27. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  28. package/dist/dev-utils/dev-server-metrics.mjs +60 -0
  29. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  30. package/dist/dev-utils/dev-server-metrics.test.mjs +18 -1
  31. package/dist/dev-utils/dev-server-metrics.test.mjs.map +1 -1
  32. package/dist/dev-utils/dev-server.d.mts +28 -2
  33. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  34. package/dist/dev-utils/dev-server.mjs +68 -7
  35. package/dist/dev-utils/dev-server.mjs.map +1 -1
  36. package/dist/dev-utils/dev-server.status.test.mjs +28 -1
  37. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
  38. package/dist/flag.d.ts +14 -0
  39. package/dist/flag.d.ts.map +1 -1
  40. package/dist/flag.js +17 -0
  41. package/dist/flag.js.map +1 -1
  42. package/dist/flag.test.d.ts +2 -0
  43. package/dist/flag.test.d.ts.map +1 -0
  44. package/dist/flag.test.js +54 -0
  45. package/dist/flag.test.js.map +1 -0
  46. package/dist/index.d.ts +2 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/sdk.d.ts +3 -1
  51. package/dist/sdk.d.ts.map +1 -1
  52. package/dist/sdk.js +11 -1
  53. package/dist/sdk.js.map +1 -1
  54. package/dist/types/common.d.ts +1 -0
  55. package/dist/types/common.d.ts.map +1 -1
  56. package/dist/types/common.js.map +1 -1
  57. package/package.json +8 -8
  58. package/src/cli-replacement/automatic-upgrades.test.ts +556 -3
  59. package/src/cli-replacement/automatic-upgrades.ts +251 -54
  60. package/src/cli-replacement/dev.mts +9 -10
  61. package/src/cli-replacement/home-npmrc.mts +45 -0
  62. package/src/client.billing-usage.test.ts +73 -0
  63. package/src/client.ts +66 -1
  64. package/src/dev-utils/dev-server-metrics.boot.test.mts +83 -0
  65. package/src/dev-utils/dev-server-metrics.mts +65 -0
  66. package/src/dev-utils/dev-server-metrics.test.mts +24 -1
  67. package/src/dev-utils/dev-server.mts +96 -7
  68. package/src/dev-utils/dev-server.status.test.mts +35 -1
  69. package/src/flag.test.ts +63 -0
  70. package/src/flag.ts +24 -0
  71. package/src/index.ts +9 -0
  72. package/src/sdk.ts +20 -0
  73. package/src/types/common.ts +1 -0
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/turbo.json +2 -1
@@ -15,6 +15,7 @@ import {
15
15
  } from "@superblocksteam/shared";
16
16
  import {
17
17
  bucketNpmRegistryHost,
18
+ recordNpmInstall,
18
19
  redactNpmRegistryHostsFromText,
19
20
  } from "@superblocksteam/telemetry";
20
21
  import type { LockService } from "@superblocksteam/vite-plugin-file-sync/lock-service";
@@ -31,6 +32,9 @@ import {
31
32
  clearCliVersionCache,
32
33
  } from "./version-detection.js";
33
34
 
35
+ // Child exit code that asks the `superblocks dev` parent supervisor to re-exec
36
+ // the dev server after the CLI upgrades its own on-disk binary. Full exit-code
37
+ // contract: packages/cli/packages/cli/src/commands/dev-parent.mts.
34
38
  export const AUTO_UPGRADE_EXIT_CODE = 99;
35
39
 
36
40
  /**
@@ -172,6 +176,12 @@ async function upgradeCliWithOclif(targetVersion: string): Promise<boolean> {
172
176
  }
173
177
  }
174
178
 
179
+ // Returns `true` when an install command actually ran and post-install
180
+ // validation passed (every other failure throws). Returns `false` only when no
181
+ // global install command could be resolved for the detected package manager — a
182
+ // no-op the caller marks as a failed span WITHOUT throwing, so the live-edit
183
+ // pod degrades on the current version instead of crash-looping. Proper graceful
184
+ // degrade + surfacing the failed upgrade to the user is tracked by APPS-4457.
175
185
  async function upgradeCliWithPackageManager(
176
186
  pm: DetectResult,
177
187
  targetVersion: string,
@@ -181,7 +191,7 @@ async function upgradeCliWithPackageManager(
181
191
  // `.superblocks/logs`). Optional so callers/tests that don't care keep
182
192
  // npm's default `_logs` location.
183
193
  logsDir?: string,
184
- ): Promise<void> {
194
+ ): Promise<boolean> {
185
195
  const packageName = alias
186
196
  ? `@superblocksteam/cli@npm:${alias}@${targetVersion}`
187
197
  : `@superblocksteam/cli@${targetVersion}`;
@@ -191,12 +201,57 @@ async function upgradeCliWithPackageManager(
191
201
  const installCommand = resolveCommand(pm.agent, "global", installArgs);
192
202
 
193
203
  if (!installCommand) {
204
+ // APPS-4544 (Bugbot 613adfc7): we couldn't even build a global install
205
+ // command for the detected package manager, so no upgrade happened.
206
+ // Before this PR added the `upgradeCli` span `outcome` attribute and the
207
+ // `superblocks.npm.install.*` metric, this no-op left no telemetry: the
208
+ // span kept its default `outcome=success` and no install metric was
209
+ // emitted, so an auto-upgrade that did nothing looked successful.
210
+ //
211
+ // Emit a failure sample so the histogram carries a `runner="auto_upgrade"`
212
+ // failure sample for this path, then return `false` so the caller flips
213
+ // the `upgradeCli` span to `outcome=failure`. `durationMs` is 0 because no
214
+ // install process ran; `outcome="other"` mirrors the unclassified-failure
215
+ // fallback used by the exec/validation paths below.
216
+ //
217
+ // We deliberately do NOT throw: a rejected upgrade promise routes through
218
+ // `dev.mts` to `process.exit(1)`, crash-looping the live-edit pod. The
219
+ // no-op dev-server restart that still follows (`cliUpdated` is set
220
+ // unconditionally at enqueue time in `checkVersionsAndWritePackageJson`),
221
+ // plus degrading gracefully (continue on the current version) and
222
+ // surfacing the failed upgrade to the user, are tracked by APPS-4457.
194
223
  logger.error("Could not determine how to upgrade Superblocks packages.");
195
- return;
224
+ recordNpmInstall({
225
+ runner: "auto_upgrade",
226
+ outcome: "other",
227
+ configured: hasAnyRegistryConfigured ?? false,
228
+ registryHost: undefined,
229
+ durationMs: 0,
230
+ });
231
+ return false;
196
232
  }
197
233
 
198
234
  const { command, args } = installCommand;
199
235
  logger.info(`Running ${command} ${args.join(" ")}...`);
236
+ // APPS-4544: emit `superblocks.npm.install.*` for the CLI auto-upgrade
237
+ // install. Until now this path's only failure signal was a structured log
238
+ // line + the rejected promise; metrics/dashboards distinguishing a
239
+ // perimeter-blocked upgrade from a working one had no `runner="auto_upgrade"`
240
+ // facet because no call site emitted it. `installStart` covers exec +
241
+ // post-install version validation so the histogram answers the operator's
242
+ // real question ("how long did the upgrade take end-to-end"), not just
243
+ // "how long did pnpm spend".
244
+ //
245
+ // `outcome` / `registryHost` are mutated from inside the exec callback
246
+ // because that's where the classifier runs; the `finally` then emits with
247
+ // whatever the callback (or a post-install validation throw below) left
248
+ // behind. `recordNpmInstall` itself coerces `outcome` through
249
+ // `normalizeInstallOutcome` and `registryHost` through `bucketNpmRegistryHost`,
250
+ // so raw `NpmInstallBlocked.reason` / hostnames can't leak past the emit
251
+ // boundary even if a future caller forgets to pre-sanitize.
252
+ const installStart = Date.now();
253
+ let outcome: string = "success";
254
+ let registryHost: string | undefined;
200
255
  let resolver: (value: void | PromiseLike<void>) => void;
201
256
  let rejecter: (reason?: any) => void;
202
257
  const promise = new Promise<void>((resolve, reject) => {
@@ -233,6 +288,21 @@ async function upgradeCliWithPackageManager(
233
288
  requestedPackages: [{ name: packageName }],
234
289
  hasAnyRegistryConfigured,
235
290
  });
291
+ // Surface the structured `NpmInstallBlocked.reason` (e.g.
292
+ // `registry_unreachable`, `registry_auth_failed`) and raw host to
293
+ // the install metric below; the emitter coerces both at the emit
294
+ // boundary (`normalizeInstallOutcome` / `bucketNpmRegistryHost`),
295
+ // so a reason that isn't in `NPM_INSTALL_OUTCOMES` folds to
296
+ // `other` and a private host buckets to `private`. When the
297
+ // classifier doesn't match (network glitch, lockfile conflict, …)
298
+ // we still need to flip outcome off `success`, since the install
299
+ // did fail; default to `other` so the metric reflects the failure.
300
+ outcome = blocked?.reason ?? "other";
301
+ // `blocked?.registryHost` is already `string | undefined` (optional
302
+ // chaining yields `undefined` when `blocked` is null, and
303
+ // `NpmInstallBlocked.registryHost` is `readonly registryHost?: string`),
304
+ // so no `?? undefined` fallback is needed.
305
+ registryHost = blocked?.registryHost;
236
306
  if (blocked) {
237
307
  // Structured fields are grep-able from the message body because
238
308
  // the local logger contract limits the meta arg to
@@ -296,28 +366,61 @@ async function upgradeCliWithPackageManager(
296
366
  logger.warn(data);
297
367
  });
298
368
 
299
- // Wait for the install to complete, then validate the version
300
- await promise;
369
+ try {
370
+ // Wait for the install to complete, then validate the version
371
+ await promise;
301
372
 
302
- // Clear cache to force actual version detection (not just returning cached value)
303
- clearCliVersionCache();
373
+ // Clear cache to force actual version detection (not just returning cached value)
374
+ clearCliVersionCache();
304
375
 
305
- const currentCliInfo = await getCurrentCliVersion();
306
- if (!currentCliInfo) {
307
- throw new Error("Could not get validate CLI version post-install");
308
- }
376
+ const currentCliInfo = await getCurrentCliVersion();
377
+ if (!currentCliInfo) {
378
+ throw new Error("Could not get validate CLI version post-install");
379
+ }
309
380
 
310
- if (currentCliInfo.version !== targetVersion) {
311
- throw new Error(
312
- `CLI version mismatch after upgrade: ${currentCliInfo.version} !== ${targetVersion}`,
313
- );
314
- }
381
+ if (currentCliInfo.version !== targetVersion) {
382
+ throw new Error(
383
+ `CLI version mismatch after upgrade: ${currentCliInfo.version} !== ${targetVersion}`,
384
+ );
385
+ }
315
386
 
316
- // Now that we've validated, cache the correct version (avoids re-detection on next call)
317
- clearCliVersionCache({ version: targetVersion, alias });
387
+ // Now that we've validated, cache the correct version (avoids re-detection on next call)
388
+ clearCliVersionCache({ version: targetVersion, alias });
389
+
390
+ // This log line is used to end the upgrade spinner
391
+ logger.info(`Successfully upgraded packages to ${targetVersion}`);
392
+ } catch (err) {
393
+ // If outcome is still "success", the install itself succeeded and we
394
+ // failed during post-install validation (missing binary / version
395
+ // mismatch). The user-visible effect is the same — auto-upgrade
396
+ // failed — so the metric flips to `other`. Exec-time failures already
397
+ // set outcome from the classified `NpmInstallBlocked.reason` (or
398
+ // `other` for unclassified errors) in the callback above.
399
+ if (outcome === "success") {
400
+ outcome = "other";
401
+ }
402
+ throw err;
403
+ } finally {
404
+ // Both the happy path and every throw above (exec failure → callback
405
+ // sets outcome, post-install validation throw → catch sets outcome to
406
+ // `other`) flow through here. The earlier `resolveCommand` failure
407
+ // returns before `installStart`, so it never reaches this `finally`; it
408
+ // emits its own `outcome="other"` failure sample inline (with
409
+ // `durationMs: 0`, since no install ran) so that path isn't a metric gap
410
+ // either. No double-emit results because that early return skips this block.
411
+ recordNpmInstall({
412
+ runner: "auto_upgrade",
413
+ registryHost,
414
+ outcome,
415
+ configured: hasAnyRegistryConfigured ?? false,
416
+ durationMs: Date.now() - installStart,
417
+ });
418
+ }
318
419
 
319
- // This log line is used to end the upgrade spinner
320
- logger.info(`Successfully upgraded packages to ${targetVersion}`);
420
+ // Reached only when the install ran and post-install validation passed
421
+ // (every failure above throws). Signals the caller that a real upgrade
422
+ // happened so it leaves the `upgradeCli` span `outcome=success`.
423
+ return true;
321
424
  }
322
425
 
323
426
  export async function checkVersionsAndWritePackageJson(
@@ -437,49 +540,143 @@ export async function checkVersionsAndWritePackageJson(
437
540
  const cliUpgradePromise = traceFunction({
438
541
  name: "upgradeCli",
439
542
  tracer,
440
- fn: async () => {
441
- logger.info(
442
- `Beginning CLI upgrade from ${currentCliInfo.version} to ${targetVersions.cli}`,
443
- );
444
- const oclifUpgradeSucceeded = await traceFunction({
445
- name: "upgradeCliWithOclif",
446
- tracer,
447
- fn: async () => {
448
- return await upgradeCliWithOclif(targetVersions.cli);
449
- },
450
- });
451
- if (!oclifUpgradeSucceeded) {
452
- logger.info(`Falling back to package manager upgrade for CLI`);
453
- await traceFunction({
454
- name: "upgradePackageWithPackageManager - cli",
543
+ // APPS-4544: capture the span so the `outcome` attribute can be
544
+ // set in `finally` below. Adds a single trace-side fact so
545
+ // operators can correlate a failed upgrade span with the pod
546
+ // that crash-looped, complementing the
547
+ // `superblocks.npm.install.*` metric the package-manager and
548
+ // oclif paths emit below.
549
+ fn: async (span) => {
550
+ let outcome: "success" | "failure" = "success";
551
+ try {
552
+ logger.info(
553
+ `Beginning CLI upgrade from ${currentCliInfo.version} to ${targetVersions.cli}`,
554
+ );
555
+ const oclifStart = Date.now();
556
+ const oclifUpgradeSucceeded = await traceFunction({
557
+ name: "upgradeCliWithOclif",
455
558
  tracer,
456
559
  fn: async () => {
457
- await upgradeCliWithPackageManager(
458
- packageManager,
459
- targetVersions.cli,
460
- currentCliInfo.alias,
461
- undefined,
462
- superblocksLogsPath(appDir),
463
- );
560
+ return await upgradeCliWithOclif(targetVersions.cli);
464
561
  },
465
562
  });
466
- cliUpdated = true;
563
+ if (oclifUpgradeSucceeded) {
564
+ // APPS-4544: oclif succeeded outright, so the package
565
+ // manager fallback below is skipped — record the
566
+ // upgrade in `superblocks.npm.install.*` here so the
567
+ // dashboard's "auto-upgrade succeeded" count includes
568
+ // oclif-channel installs. Failures and "not updatable"
569
+ // both flow through the fallback, which already
570
+ // records the outcome with the correct attributes
571
+ // pulled from the classifier; double-emitting from
572
+ // both branches would double-count those failures.
573
+ recordNpmInstall({
574
+ runner: "auto_upgrade",
575
+ outcome: "success",
576
+ // oclif's update channel is the public superblocks
577
+ // tarball location (not a customer-configured npm
578
+ // registry), so `configured` is false here even
579
+ // when the app shell has a private registry — the
580
+ // CLI binary upgrade does not flow through that
581
+ // registry, only library/dependency installs do.
582
+ configured: false,
583
+ // oclif resolves its tarball via its update channel,
584
+ // not a configurable npm registry, so there's no
585
+ // single host to attribute the install to. Leave
586
+ // unset and let `bucketNpmRegistryHost` map it to
587
+ // `unknown` at the emit boundary.
588
+ registryHost: undefined,
589
+ durationMs: Date.now() - oclifStart,
590
+ });
591
+ // Operator log anchor symmetric with the package-manager
592
+ // path's "Successfully upgraded packages to ..." line: the
593
+ // oclif branch records a `runner=auto_upgrade, outcome=success`
594
+ // metric but, without this, left no log entry to correlate
595
+ // the increment against on the oclif channel.
596
+ logger.info(
597
+ `Successfully upgraded CLI to ${targetVersions.cli} via oclif`,
598
+ );
599
+ } else {
600
+ logger.info(
601
+ `Falling back to package manager upgrade for CLI`,
602
+ );
603
+ const cliUpgradeRan = await traceFunction({
604
+ name: "upgradePackageWithPackageManager - cli",
605
+ tracer,
606
+ fn: async () => {
607
+ return await upgradeCliWithPackageManager(
608
+ packageManager,
609
+ targetVersions.cli,
610
+ currentCliInfo.alias,
611
+ undefined,
612
+ superblocksLogsPath(appDir),
613
+ );
614
+ },
615
+ });
616
+ if (!cliUpgradeRan) {
617
+ // No global install command could be resolved → the CLI
618
+ // upgrade was a no-op (the failure metric was already
619
+ // emitted inline). Flip the span to `failure` so the trace
620
+ // is accurate, but do NOT throw: throwing routes through
621
+ // `dev.mts` to `process.exit(1)` and crash-loops the pod.
622
+ // The no-op dev-server restart that still follows
623
+ // (`cliUpdated = true` below), graceful degrade, and user
624
+ // surfacing of the failed upgrade are tracked by APPS-4457.
625
+ outcome = "failure";
626
+ }
627
+ }
628
+ // The freshly fetched tarball may ship bundled lockfiles with
629
+ // stale public-host `resolved` URLs; strip them so the next
630
+ // install does not bypass the active registry. Non-fatal —
631
+ // a failed post-step must not crash the pod after a working
632
+ // upgrade, and (APPS-4544) must not flip the span outcome
633
+ // to `failure` either, since the actual upgrade succeeded.
634
+ // The inner traceFunction still records the strip-level
635
+ // exception on its own span, so we don't lose the signal.
636
+ try {
637
+ await traceFunction({
638
+ name: "stripUpgradedCliLockfiles",
639
+ tracer,
640
+ fn: async () => {
641
+ await stripUpgradedCliLockfiles(logger);
642
+ },
643
+ });
644
+ } catch (stripErr) {
645
+ logger.warn(
646
+ `stripUpgradedCliLockfiles failed (non-fatal): ${
647
+ isNativeError(stripErr) ? stripErr.message : stripErr
648
+ }`,
649
+ );
650
+ }
651
+ } catch (err) {
652
+ outcome = "failure";
653
+ throw err;
654
+ } finally {
655
+ // Closed enum string keeps the trace attribute
656
+ // queryable in TraceQL without cardinality concerns;
657
+ // `traceFunction` already records the exception itself
658
+ // via `recordException`, so this attribute is just a
659
+ // semantic label for grouping.
660
+ span.setAttribute("outcome", outcome);
467
661
  }
468
- // The freshly fetched tarball may ship bundled lockfiles with
469
- // stale public-host `resolved` URLs; strip them so the next
470
- // install does not bypass the active registry. Non-fatal — a
471
- // failed post-step must not crash the pod after a working
472
- // upgrade.
473
- await traceFunction({
474
- name: "stripUpgradedCliLockfiles",
475
- tracer,
476
- fn: async () => {
477
- await stripUpgradedCliLockfiles(logger);
478
- },
479
- });
480
662
  },
481
663
  });
482
664
  upgradePromises.push(cliUpgradePromise);
665
+ // APPS-4554: mark the CLI upgrade as pending SYNCHRONOUSLY here,
666
+ // not inside the async promise above. `dev.mts` reads
667
+ // `result.cliUpdated` as a value snapshot immediately after this
668
+ // function returns (to decide whether to restart the dev server),
669
+ // but `cliUpgradePromise` suspends at its first `await exec(...)`
670
+ // and resolves long after the return — so a `cliUpdated = true`
671
+ // set inside either the oclif or package-manager branch never
672
+ // reaches the returned snapshot. Setting it at schedule time means
673
+ // "a CLI upgrade was enqueued; restart once it succeeds." The
674
+ // success gate stays in `dev.mts`, which awaits this promise via
675
+ // `joinPackageUpgrade` before restarting and exits (no restart) if
676
+ // the upgrade rejects. This fixes both the oclif-success path
677
+ // (Bugbot: it set the flag in neither branch reliably) and the
678
+ // pre-existing package-manager race.
679
+ cliUpdated = true;
483
680
  }
484
681
 
485
682
  // Skip if we're in local development
@@ -1190,27 +1190,26 @@ export async function dev(options: {
1190
1190
  // auto-upgrade fires. With the file in place, the auto-upgrade's
1191
1191
  // `npm install -g @superblocksteam/cli@…` resolves through the
1192
1192
  // customer's private registry instead of `registry.npmjs.org`.
1193
- await tracer.startActiveSpan("syncHomeNpmrc", async (span) => {
1193
+ // `syncHomeNpmrc` owns its own `npm_registry.sync_home_npmrc`
1194
+ // span (APPS-4378), so no outer span is created here.
1195
+ if (!aiService) {
1196
+ logger.info(
1197
+ "[home-npmrc] skipped: AiService unavailable at startup",
1198
+ );
1199
+ } else {
1194
1200
  try {
1195
- if (!aiService) {
1196
- logger.info(
1197
- "[home-npmrc] skipped: AiService unavailable at startup",
1198
- );
1199
- return;
1200
- }
1201
1201
  await syncHomeNpmrc({
1202
1202
  npmRegistryClient: aiService.getNpmRegistryClient(),
1203
1203
  logger,
1204
+ orgId: currentUser.user.currentOrganizationId,
1204
1205
  });
1205
1206
  } catch (error) {
1206
1207
  logger.warn(
1207
1208
  "[home-npmrc] sync step failed unexpectedly; ~/.superblocks/npmrc left untouched",
1208
1209
  getErrorMeta(error),
1209
1210
  );
1210
- } finally {
1211
- span.end();
1212
1211
  }
1213
- });
1212
+ }
1214
1213
 
1215
1214
  let hasCliUpdated = false;
1216
1215
  let upgradePromises: Promise<void>[] = [];
@@ -2,6 +2,14 @@ import { mkdir } from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
+ import { trace } from "@opentelemetry/api";
6
+
7
+ import {
8
+ bucketNpmRegistryHost,
9
+ NPM_REGISTRY_SPAN,
10
+ NPM_REGISTRY_SPAN_ATTR,
11
+ withNpmRegistrySpan,
12
+ } from "@superblocksteam/telemetry";
5
13
  import {
6
14
  type NpmRegistryClient,
7
15
  type NpmRegistryFetchResult,
@@ -177,6 +185,12 @@ export interface SyncHomeNpmrcDeps {
177
185
  * leave it unset.
178
186
  */
179
187
  homeDir?: string;
188
+ /**
189
+ * Organization id, surfaced on the `npm_registry.sync_home_npmrc` span
190
+ * (per-trace context, never a metric label). Absent in tests / when no org
191
+ * context is available at the call site.
192
+ */
193
+ orgId?: string;
180
194
  }
181
195
 
182
196
  /**
@@ -230,6 +244,28 @@ export interface SyncHomeNpmrcDeps {
230
244
  */
231
245
  export async function syncHomeNpmrc(
232
246
  deps: SyncHomeNpmrcDeps,
247
+ ): Promise<SyncHomeNpmrcResult> {
248
+ // `npm_registry.sync_home_npmrc` span (APPS-4378). org_id rides on the span;
249
+ // the precise 5-value outcome and resolved `source` are attached once the
250
+ // sync resolves (registry_host is set inside the impl, after the fetch).
251
+ return withNpmRegistrySpan(
252
+ NPM_REGISTRY_SPAN.SYNC_HOME_NPMRC,
253
+ deps.orgId ? { [NPM_REGISTRY_SPAN_ATTR.ORG_ID]: deps.orgId } : {},
254
+ async (span) => {
255
+ const result = await syncHomeNpmrcImpl(deps);
256
+ span.setAttributes({
257
+ [NPM_REGISTRY_SPAN_ATTR.OUTCOME]: result.outcome,
258
+ ...(result.source
259
+ ? { [NPM_REGISTRY_SPAN_ATTR.SOURCE]: result.source }
260
+ : {}),
261
+ });
262
+ return result;
263
+ },
264
+ );
265
+ }
266
+
267
+ async function syncHomeNpmrcImpl(
268
+ deps: SyncHomeNpmrcDeps,
233
269
  ): Promise<SyncHomeNpmrcResult> {
234
270
  const targetPath = superblocksNpmrcPath(deps.homeDir);
235
271
  const backupPath = superblocksNpmrcBackupPath(deps.homeDir);
@@ -250,6 +286,15 @@ export async function syncHomeNpmrc(
250
286
  return { outcome: "error", path: targetPath };
251
287
  }
252
288
 
289
+ // Attach the bucketed registry_host to the active sync span now that the
290
+ // config has resolved (the default registry URL is only known here).
291
+ trace
292
+ .getActiveSpan()
293
+ ?.setAttribute(
294
+ NPM_REGISTRY_SPAN_ATTR.REGISTRY_HOST,
295
+ bucketNpmRegistryHost(result.config.default?.url),
296
+ );
297
+
253
298
  if (result.source === "unreachable") {
254
299
  deps.logger.warn(
255
300
  `${LOG_PREFIX} registry server unreachable with no cached config; ~/.superblocks/npmrc left unchanged`,
@@ -0,0 +1,73 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { fetchBillingUsageRecordsResponse } from "./client.js";
4
+
5
+ const { mockAxios } = vi.hoisted(() => ({
6
+ mockAxios: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("axios", async () => {
10
+ const actual = await vi.importActual("axios");
11
+ return {
12
+ ...actual,
13
+ default: mockAxios,
14
+ };
15
+ });
16
+
17
+ describe("fetchBillingUsageRecordsResponse", () => {
18
+ beforeEach(() => {
19
+ mockAxios.mockReset();
20
+ });
21
+
22
+ it("returns the full billing usage records payload", async () => {
23
+ const payload = {
24
+ agentUsage: [
25
+ { agentId: "agent-1", name: "Support agent", creditsUsed: 50 },
26
+ ],
27
+ applicationUsageRows: [
28
+ {
29
+ applicationId: "00000000-0000-4000-8000-000000000001",
30
+ applicationName: "Operations",
31
+ creditsUsed: 75,
32
+ usageType: "ai_credits",
33
+ },
34
+ ],
35
+ dollarLedgerRows: [
36
+ {
37
+ amountCents: 1234,
38
+ applicationId: "app-1",
39
+ applicationName: "Operations",
40
+ chargeKind: "committed",
41
+ creditsUsed: 62,
42
+ date: "2026-03-24",
43
+ email: "alice@example.com",
44
+ entryType: "ai_credit_debit",
45
+ name: "Alice",
46
+ usageType: "ai_credits",
47
+ },
48
+ ],
49
+ rows: [
50
+ {
51
+ actorId: "user-1",
52
+ actorType: "user",
53
+ creditsUsed: 100,
54
+ date: "2026-03-24",
55
+ email: "alice@example.com",
56
+ name: "Alice",
57
+ source: "seat",
58
+ },
59
+ ],
60
+ };
61
+ mockAxios.mockResolvedValue({ data: { data: payload } });
62
+
63
+ await expect(
64
+ fetchBillingUsageRecordsResponse({
65
+ cliVersion: "1.2.3",
66
+ endDate: "2026-03-25",
67
+ startDate: "2026-03-24",
68
+ superblocksBaseUrl: "https://staging.superblocks.com",
69
+ token: "test-token",
70
+ }),
71
+ ).resolves.toEqual(payload);
72
+ });
73
+ });
package/src/client.ts CHANGED
@@ -2789,6 +2789,8 @@ export interface DailyUsageRow {
2789
2789
  }
2790
2790
 
2791
2791
  export interface UsageRecordRow {
2792
+ actorId?: string;
2793
+ actorType?: "ai_agent" | "user";
2792
2794
  date: string;
2793
2795
  email: string;
2794
2796
  name: string;
@@ -2796,6 +2798,42 @@ export interface UsageRecordRow {
2796
2798
  source: string;
2797
2799
  }
2798
2800
 
2801
+ export interface AgentUsageRow {
2802
+ agentId: string;
2803
+ name: string;
2804
+ creditsUsed: number;
2805
+ }
2806
+
2807
+ export interface ApplicationUsageRow {
2808
+ applicationId: string;
2809
+ applicationName: string;
2810
+ creditsUsed: number;
2811
+ usageType: "ai_credits";
2812
+ }
2813
+
2814
+ export interface DollarLedgerRow {
2815
+ amountCents: number;
2816
+ applicationId: string | null;
2817
+ applicationName: string | null;
2818
+ chargeKind: "committed" | "overage";
2819
+ creditsUsed: number | null;
2820
+ date: string;
2821
+ email: string | null;
2822
+ entryType: string;
2823
+ name: string | null;
2824
+ usageType: "ai_credits" | "deployed_app";
2825
+ }
2826
+
2827
+ export interface BillingUsageRecordsResponse {
2828
+ agentUsage?: AgentUsageRow[];
2829
+ applicationUsageRows?: ApplicationUsageRow[];
2830
+ dollarCommitUsedCents?: number;
2831
+ dollarLedgerRows?: DollarLedgerRow[];
2832
+ enterpriseBillingWindows?: unknown[];
2833
+ rows: UsageRecordRow[];
2834
+ seatAssignments?: unknown[];
2835
+ }
2836
+
2799
2837
  export interface UserCreditLimit {
2800
2838
  amountLimitCents: number | null;
2801
2839
  creditLimit: number | null;
@@ -2922,6 +2960,29 @@ export async function fetchBillingUsageRecords({
2922
2960
  startDate: string;
2923
2961
  endDate: string;
2924
2962
  }): Promise<UsageRecordRow[]> {
2963
+ const response = await fetchBillingUsageRecordsResponse({
2964
+ cliVersion,
2965
+ token,
2966
+ superblocksBaseUrl,
2967
+ startDate,
2968
+ endDate,
2969
+ });
2970
+ return response.rows;
2971
+ }
2972
+
2973
+ export async function fetchBillingUsageRecordsResponse({
2974
+ cliVersion,
2975
+ token,
2976
+ superblocksBaseUrl,
2977
+ startDate,
2978
+ endDate,
2979
+ }: {
2980
+ cliVersion: string;
2981
+ token: string;
2982
+ superblocksBaseUrl: string;
2983
+ startDate: string;
2984
+ endDate: string;
2985
+ }): Promise<BillingUsageRecordsResponse> {
2925
2986
  try {
2926
2987
  const url = new URL(
2927
2988
  `${BASE_SERVER_API_URL_V1}/billing/usage-records`,
@@ -2939,7 +3000,11 @@ export async function fetchBillingUsageRecords({
2939
3000
  },
2940
3001
  };
2941
3002
  const response = await axios(config);
2942
- return response.data.data.rows as UsageRecordRow[];
3003
+ const data = response.data.data as Partial<BillingUsageRecordsResponse>;
3004
+ if (!Array.isArray(data.rows)) {
3005
+ throw new Error("Billing usage records response did not include rows");
3006
+ }
3007
+ return data as BillingUsageRecordsResponse;
2943
3008
  } catch (e: any) {
2944
3009
  let message: string;
2945
3010
  if (e instanceof AxiosError) {