@ceon-oy/monitor-sdk 1.5.2 → 1.5.5

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/dist/index.d.mts CHANGED
@@ -261,6 +261,21 @@ declare class MonitorClient {
261
261
  flush(): Promise<void>;
262
262
  private getErrorKey;
263
263
  close(): Promise<void>;
264
+ /**
265
+ * Node's fetch() (undici) wraps network-level failures (ECONNREFUSED, DNS lookup
266
+ * failures, TLS errors, ...) in a generic `TypeError: fetch failed`, with the real
267
+ * reason attached via the standard `cause` chain. Without unwrapping it, every
268
+ * network failure across the SDK is reported as the same opaque "fetch failed"
269
+ * message, with no way to diagnose it from the dashboard.
270
+ */
271
+ private describeErrorCause;
272
+ private truncateDescription;
273
+ /**
274
+ * Run `fn`, swallowing any throw and returning undefined instead. Used to read
275
+ * possibly-hostile Error fields (a throwing message/stack/cause getter) without
276
+ * losing whatever else was already read — each call degrades independently.
277
+ */
278
+ private safe;
264
279
  /**
265
280
  * Queue an SDK error to be reported to the server's system errors page.
266
281
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -325,6 +340,55 @@ declare class MonitorClient {
325
340
  private startFlushTimer;
326
341
  private stopFlushTimer;
327
342
  syncDependencies(): Promise<void>;
343
+ /**
344
+ * How many sequential registry batches a version-fetch over this many packages
345
+ * will take, given the shared concurrency limit. Shared by fetchLatestVersions
346
+ * (which does the batching) and calculateSyncTimeoutMs (which budgets for it),
347
+ * so the two can't drift out of sync with each other.
348
+ */
349
+ private registryBatchCount;
350
+ /**
351
+ * Estimate a timeout budget that scales with how many roughly-equal-cost units of
352
+ * sequential work will run (registry batches, audit paths, ...), clamped to a
353
+ * floor/ceiling. Shared by calculateSyncTimeoutMs and calculateMultiPathAuditTimeoutMs
354
+ * so the two scaling formulas can't drift apart. At count=0 this always collapses to
355
+ * floorMs, since overheadMs alone never exceeds a floor sized for real work.
356
+ */
357
+ private estimateScaledTimeoutMs;
358
+ /**
359
+ * Scale the dependency sync timeout with how many registry lookups AND how many
360
+ * sequential POSTs (one per source, via sendTechnologiesWithEnvironment) it will
361
+ * make, so large dependency lists, many dependencySources, or a large requestTimeoutMs
362
+ * don't spuriously time out against a fixed budget.
363
+ *
364
+ * The POST-phase term (sources.length * requestTimeoutMs) is included even when
365
+ * version checking is off: performDependencySync still does one sequential POST per
366
+ * source regardless, so a flat overhead that ignores source count would under-budget
367
+ * that phase exactly the way a flat registry-batch estimate would under-budget large
368
+ * dependency lists.
369
+ *
370
+ * Registry batches are summed PER SOURCE (not over the combined total) because
371
+ * performDependencySync/fetchLatestVersions batch each source independently and
372
+ * sequentially — ceil(a/n) + ceil(b/n) can be larger than ceil((a+b)/n), so a
373
+ * single batch count over the combined total would under-estimate the real
374
+ * number of sequential registry round-trips when many small sources are configured.
375
+ */
376
+ private calculateSyncTimeoutMs;
377
+ /**
378
+ * Reads happen before syncDependencies' timeout/AbortSignal exists (see comment
379
+ * there), so this phase bounds itself with a wall-clock deadline on a budget that
380
+ * scales with how many sources are configured (mirroring calculateSyncTimeoutMs's
381
+ * scaling principle for the network phase). A plain deadline timestamp — not an
382
+ * AbortController — is enough here: readPackageJsonFromPath does purely synchronous,
383
+ * non-cancelable fs calls, so there is nothing for a signal to actually interrupt;
384
+ * it can only ever be checked between iterations, exactly like a deadline check.
385
+ * Once the budget is spent, stop reading further sources rather than always paying
386
+ * the full read cost for every configured dependencySource regardless of how long
387
+ * it's already taken — and report it via reportError, not just a console.warn, so a
388
+ * truncated sync isn't silently indistinguishable from a fully successful one on
389
+ * the dashboard.
390
+ */
391
+ private loadDependencySources;
328
392
  private performDependencySync;
329
393
  /**
330
394
  * Enrich technologies with latest version information from npm registry.
@@ -424,6 +488,13 @@ declare class MonitorClient {
424
488
  * Only works in Node.js environment (not browser/bundled).
425
489
  */
426
490
  private runCommandWithTimeout;
491
+ /**
492
+ * `npm audit --json` can exit without emitting valid JSON on stdout — e.g. the
493
+ * registry is unreachable, or a fatal npm error is written to stderr instead.
494
+ * Treat that as a soft failure (like a timeout) rather than throwing, so it
495
+ * doesn't surface as a confusing "Unexpected end of JSON input" SDK_ERROR.
496
+ */
497
+ private parseNpmAuditJson;
427
498
  /**
428
499
  * Run npm audit and send results to the monitoring server.
429
500
  * This scans the project for known vulnerabilities in dependencies.
@@ -446,6 +517,13 @@ declare class MonitorClient {
446
517
  * @returns Combined summary of all audit results
447
518
  */
448
519
  auditMultiplePaths(): Promise<MultiAuditSummary | null>;
520
+ /**
521
+ * Scale the multi-path audit timeout with how many paths it will scan, so
522
+ * configuring many auditPaths (or a large per-path auditTimeoutMs) doesn't
523
+ * spuriously time out the whole batch against a fixed budget — the same
524
+ * failure mode calculateSyncTimeoutMs fixes for dependency sync.
525
+ */
526
+ private calculateMultiPathAuditTimeoutMs;
449
527
  private performMultiPathAudit;
450
528
  /**
451
529
  * Parse npm audit JSON output into vulnerability items
package/dist/index.d.ts CHANGED
@@ -261,6 +261,21 @@ declare class MonitorClient {
261
261
  flush(): Promise<void>;
262
262
  private getErrorKey;
263
263
  close(): Promise<void>;
264
+ /**
265
+ * Node's fetch() (undici) wraps network-level failures (ECONNREFUSED, DNS lookup
266
+ * failures, TLS errors, ...) in a generic `TypeError: fetch failed`, with the real
267
+ * reason attached via the standard `cause` chain. Without unwrapping it, every
268
+ * network failure across the SDK is reported as the same opaque "fetch failed"
269
+ * message, with no way to diagnose it from the dashboard.
270
+ */
271
+ private describeErrorCause;
272
+ private truncateDescription;
273
+ /**
274
+ * Run `fn`, swallowing any throw and returning undefined instead. Used to read
275
+ * possibly-hostile Error fields (a throwing message/stack/cause getter) without
276
+ * losing whatever else was already read — each call degrades independently.
277
+ */
278
+ private safe;
264
279
  /**
265
280
  * Queue an SDK error to be reported to the server's system errors page.
266
281
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -325,6 +340,55 @@ declare class MonitorClient {
325
340
  private startFlushTimer;
326
341
  private stopFlushTimer;
327
342
  syncDependencies(): Promise<void>;
343
+ /**
344
+ * How many sequential registry batches a version-fetch over this many packages
345
+ * will take, given the shared concurrency limit. Shared by fetchLatestVersions
346
+ * (which does the batching) and calculateSyncTimeoutMs (which budgets for it),
347
+ * so the two can't drift out of sync with each other.
348
+ */
349
+ private registryBatchCount;
350
+ /**
351
+ * Estimate a timeout budget that scales with how many roughly-equal-cost units of
352
+ * sequential work will run (registry batches, audit paths, ...), clamped to a
353
+ * floor/ceiling. Shared by calculateSyncTimeoutMs and calculateMultiPathAuditTimeoutMs
354
+ * so the two scaling formulas can't drift apart. At count=0 this always collapses to
355
+ * floorMs, since overheadMs alone never exceeds a floor sized for real work.
356
+ */
357
+ private estimateScaledTimeoutMs;
358
+ /**
359
+ * Scale the dependency sync timeout with how many registry lookups AND how many
360
+ * sequential POSTs (one per source, via sendTechnologiesWithEnvironment) it will
361
+ * make, so large dependency lists, many dependencySources, or a large requestTimeoutMs
362
+ * don't spuriously time out against a fixed budget.
363
+ *
364
+ * The POST-phase term (sources.length * requestTimeoutMs) is included even when
365
+ * version checking is off: performDependencySync still does one sequential POST per
366
+ * source regardless, so a flat overhead that ignores source count would under-budget
367
+ * that phase exactly the way a flat registry-batch estimate would under-budget large
368
+ * dependency lists.
369
+ *
370
+ * Registry batches are summed PER SOURCE (not over the combined total) because
371
+ * performDependencySync/fetchLatestVersions batch each source independently and
372
+ * sequentially — ceil(a/n) + ceil(b/n) can be larger than ceil((a+b)/n), so a
373
+ * single batch count over the combined total would under-estimate the real
374
+ * number of sequential registry round-trips when many small sources are configured.
375
+ */
376
+ private calculateSyncTimeoutMs;
377
+ /**
378
+ * Reads happen before syncDependencies' timeout/AbortSignal exists (see comment
379
+ * there), so this phase bounds itself with a wall-clock deadline on a budget that
380
+ * scales with how many sources are configured (mirroring calculateSyncTimeoutMs's
381
+ * scaling principle for the network phase). A plain deadline timestamp — not an
382
+ * AbortController — is enough here: readPackageJsonFromPath does purely synchronous,
383
+ * non-cancelable fs calls, so there is nothing for a signal to actually interrupt;
384
+ * it can only ever be checked between iterations, exactly like a deadline check.
385
+ * Once the budget is spent, stop reading further sources rather than always paying
386
+ * the full read cost for every configured dependencySource regardless of how long
387
+ * it's already taken — and report it via reportError, not just a console.warn, so a
388
+ * truncated sync isn't silently indistinguishable from a fully successful one on
389
+ * the dashboard.
390
+ */
391
+ private loadDependencySources;
328
392
  private performDependencySync;
329
393
  /**
330
394
  * Enrich technologies with latest version information from npm registry.
@@ -424,6 +488,13 @@ declare class MonitorClient {
424
488
  * Only works in Node.js environment (not browser/bundled).
425
489
  */
426
490
  private runCommandWithTimeout;
491
+ /**
492
+ * `npm audit --json` can exit without emitting valid JSON on stdout — e.g. the
493
+ * registry is unreachable, or a fatal npm error is written to stderr instead.
494
+ * Treat that as a soft failure (like a timeout) rather than throwing, so it
495
+ * doesn't surface as a confusing "Unexpected end of JSON input" SDK_ERROR.
496
+ */
497
+ private parseNpmAuditJson;
427
498
  /**
428
499
  * Run npm audit and send results to the monitoring server.
429
500
  * This scans the project for known vulnerabilities in dependencies.
@@ -446,6 +517,13 @@ declare class MonitorClient {
446
517
  * @returns Combined summary of all audit results
447
518
  */
448
519
  auditMultiplePaths(): Promise<MultiAuditSummary | null>;
520
+ /**
521
+ * Scale the multi-path audit timeout with how many paths it will scan, so
522
+ * configuring many auditPaths (or a large per-path auditTimeoutMs) doesn't
523
+ * spuriously time out the whole batch against a fixed budget — the same
524
+ * failure mode calculateSyncTimeoutMs fixes for dependency sync.
525
+ */
526
+ private calculateMultiPathAuditTimeoutMs;
449
527
  private performMultiPathAudit;
450
528
  /**
451
529
  * Parse npm audit JSON output into vulnerability items
package/dist/index.js CHANGED
@@ -53,8 +53,9 @@ var CONFIG_LIMITS = {
53
53
  // 10MB
54
54
  AUDIT_TIMEOUT_MS: 6e4,
55
55
  // 60 seconds (default)
56
- MAX_AUDIT_TIMEOUT_MS: 3e5,
57
- // 5 minutes (max configurable)
56
+ MAX_AUDIT_TIMEOUT_MS: 18e5,
57
+ // 30 minutes (max configurable) - was 5 minutes, which every
58
+ // real npm audit run on Cloud Run (slow registry egress) exceeded, silently returning null
58
59
  SETTINGS_POLL_INTERVAL_MS: 5 * 60 * 1e3,
59
60
  // 5 minutes
60
61
  REGISTRY_TIMEOUT_MS: 5e3,
@@ -64,9 +65,17 @@ var CONFIG_LIMITS = {
64
65
  TECH_VERSION_FETCH_TIMEOUT_MS: 3e4,
65
66
  // 30 seconds max for all version fetches
66
67
  SYNC_DEPENDENCIES_TIMEOUT_MS: 6e4,
67
- // 60 seconds max for all dependency syncs
68
+ // floor: minimum timeout for all dependency syncs
69
+ MAX_SYNC_DEPENDENCIES_TIMEOUT_MS: 3e5,
70
+ // ceiling: 5 minutes, even for very large dependency lists
71
+ SYNC_FIXED_OVERHEAD_MS: 1e4,
72
+ // buffer per sync for local reads + POSTs to the server
68
73
  AUDIT_MULTI_PATH_TIMEOUT_MS: 18e4,
69
- // 3 minutes max for all audit paths
74
+ // floor: minimum timeout for all audit paths
75
+ MAX_AUDIT_MULTI_PATH_TIMEOUT_MS: 18e5,
76
+ // ceiling: 30 minutes, even for many audit paths
77
+ AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS: 1e4,
78
+ // buffer per multi-path audit for local reads + POSTs to the server
70
79
  REGISTRY_CONCURRENCY_LIMIT: 5,
71
80
  // Limit parallel requests to avoid rate limiting
72
81
  HEALTH_CHECK_FETCH_INTERVAL_MS: 6e4,
@@ -341,6 +350,46 @@ var MonitorClient = class {
341
350
  await this.flushHealthResults();
342
351
  await this.flushSdkErrors();
343
352
  }
353
+ /**
354
+ * Node's fetch() (undici) wraps network-level failures (ECONNREFUSED, DNS lookup
355
+ * failures, TLS errors, ...) in a generic `TypeError: fetch failed`, with the real
356
+ * reason attached via the standard `cause` chain. Without unwrapping it, every
357
+ * network failure across the SDK is reported as the same opaque "fetch failed"
358
+ * message, with no way to diagnose it from the dashboard.
359
+ */
360
+ describeErrorCause(err, depth = 0) {
361
+ if (depth > 3) return void 0;
362
+ const cause = err.cause;
363
+ if (cause === void 0 || cause === null) return void 0;
364
+ const aggregateErrors = cause.errors;
365
+ if (Array.isArray(aggregateErrors)) {
366
+ const childSummary = aggregateErrors.map((e) => e instanceof Error ? e.message : String(e)).join("; ");
367
+ const summary = cause instanceof Error && cause.message ? `${cause.message} (${childSummary})` : childSummary;
368
+ return this.truncateDescription(summary);
369
+ }
370
+ if (cause instanceof Error) {
371
+ const nested = this.describeErrorCause(cause, depth + 1);
372
+ const description = nested ? `${cause.message} (${nested})` : cause.message;
373
+ return this.truncateDescription(description);
374
+ }
375
+ return this.truncateDescription(String(cause));
376
+ }
377
+ truncateDescription(description) {
378
+ const limit = 500;
379
+ return description.length > limit ? `${description.slice(0, limit)}...` : description;
380
+ }
381
+ /**
382
+ * Run `fn`, swallowing any throw and returning undefined instead. Used to read
383
+ * possibly-hostile Error fields (a throwing message/stack/cause getter) without
384
+ * losing whatever else was already read — each call degrades independently.
385
+ */
386
+ safe(fn) {
387
+ try {
388
+ return fn();
389
+ } catch {
390
+ return void 0;
391
+ }
392
+ }
344
393
  /**
345
394
  * Queue an SDK error to be reported to the server's system errors page.
346
395
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -349,8 +398,16 @@ var MonitorClient = class {
349
398
  reportError(category, message, err) {
350
399
  if (this.sdkErrorsInCurrentWindow >= 20) return;
351
400
  this.sdkErrorsInCurrentWindow++;
352
- const errorMessage = err instanceof Error ? `${message}: ${err.message}` : message;
353
- const stack = err instanceof Error ? err.stack : void 0;
401
+ let errorMessage = message;
402
+ let stack;
403
+ if (err instanceof Error) {
404
+ errorMessage = this.safe(() => `${message}: ${err.message}`) ?? message;
405
+ const causeDescription = this.safe(() => this.describeErrorCause(err));
406
+ if (causeDescription) {
407
+ errorMessage += ` (cause: ${causeDescription})`;
408
+ }
409
+ stack = this.safe(() => err.stack);
410
+ }
354
411
  this.sdkErrorQueue.push({ category, message: errorMessage, stack });
355
412
  if (this.sdkErrorQueue.length >= 20) {
356
413
  this.flushSdkErrors().catch(() => {
@@ -653,9 +710,11 @@ var MonitorClient = class {
653
710
  async syncDependencies() {
654
711
  console.log("[MonitorClient] Starting technology sync...");
655
712
  try {
713
+ const sources = await this.loadDependencySources();
714
+ const timeoutMs = this.calculateSyncTimeoutMs(sources);
656
715
  await this.withTimeout(
657
- (signal) => this.performDependencySync(signal),
658
- CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
716
+ (signal) => this.performDependencySync(sources, signal),
717
+ timeoutMs,
659
718
  "Technology sync timed out"
660
719
  );
661
720
  console.log("[MonitorClient] Technology sync completed successfully");
@@ -663,25 +722,100 @@ var MonitorClient = class {
663
722
  this.reportError("DEPENDENCY_SYNC", "Technology sync failed", err);
664
723
  }
665
724
  }
666
- async performDependencySync(signal) {
725
+ /**
726
+ * How many sequential registry batches a version-fetch over this many packages
727
+ * will take, given the shared concurrency limit. Shared by fetchLatestVersions
728
+ * (which does the batching) and calculateSyncTimeoutMs (which budgets for it),
729
+ * so the two can't drift out of sync with each other.
730
+ */
731
+ registryBatchCount(itemCount) {
732
+ return Math.ceil(itemCount / CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT);
733
+ }
734
+ /**
735
+ * Estimate a timeout budget that scales with how many roughly-equal-cost units of
736
+ * sequential work will run (registry batches, audit paths, ...), clamped to a
737
+ * floor/ceiling. Shared by calculateSyncTimeoutMs and calculateMultiPathAuditTimeoutMs
738
+ * so the two scaling formulas can't drift apart. At count=0 this always collapses to
739
+ * floorMs, since overheadMs alone never exceeds a floor sized for real work.
740
+ */
741
+ estimateScaledTimeoutMs(count, perItemMs, overheadMs, floorMs, ceilingMs) {
742
+ const estimatedMs = count * perItemMs + overheadMs;
743
+ return Math.min(ceilingMs, Math.max(floorMs, estimatedMs));
744
+ }
745
+ /**
746
+ * Scale the dependency sync timeout with how many registry lookups AND how many
747
+ * sequential POSTs (one per source, via sendTechnologiesWithEnvironment) it will
748
+ * make, so large dependency lists, many dependencySources, or a large requestTimeoutMs
749
+ * don't spuriously time out against a fixed budget.
750
+ *
751
+ * The POST-phase term (sources.length * requestTimeoutMs) is included even when
752
+ * version checking is off: performDependencySync still does one sequential POST per
753
+ * source regardless, so a flat overhead that ignores source count would under-budget
754
+ * that phase exactly the way a flat registry-batch estimate would under-budget large
755
+ * dependency lists.
756
+ *
757
+ * Registry batches are summed PER SOURCE (not over the combined total) because
758
+ * performDependencySync/fetchLatestVersions batch each source independently and
759
+ * sequentially — ceil(a/n) + ceil(b/n) can be larger than ceil((a+b)/n), so a
760
+ * single batch count over the combined total would under-estimate the real
761
+ * number of sequential registry round-trips when many small sources are configured.
762
+ */
763
+ calculateSyncTimeoutMs(sources) {
764
+ const postPhaseMs = sources.length * this.requestTimeoutMs;
765
+ const batches = this.versionCheckEnabled ? sources.reduce((sum, source) => sum + this.registryBatchCount(source.technologies.length), 0) : 0;
766
+ return this.estimateScaledTimeoutMs(
767
+ batches,
768
+ this.registryTimeoutMs,
769
+ CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS + postPhaseMs,
770
+ CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
771
+ CONFIG_LIMITS.MAX_SYNC_DEPENDENCIES_TIMEOUT_MS
772
+ );
773
+ }
774
+ /**
775
+ * Reads happen before syncDependencies' timeout/AbortSignal exists (see comment
776
+ * there), so this phase bounds itself with a wall-clock deadline on a budget that
777
+ * scales with how many sources are configured (mirroring calculateSyncTimeoutMs's
778
+ * scaling principle for the network phase). A plain deadline timestamp — not an
779
+ * AbortController — is enough here: readPackageJsonFromPath does purely synchronous,
780
+ * non-cancelable fs calls, so there is nothing for a signal to actually interrupt;
781
+ * it can only ever be checked between iterations, exactly like a deadline check.
782
+ * Once the budget is spent, stop reading further sources rather than always paying
783
+ * the full read cost for every configured dependencySource regardless of how long
784
+ * it's already taken — and report it via reportError, not just a console.warn, so a
785
+ * truncated sync isn't silently indistinguishable from a fully successful one on
786
+ * the dashboard.
787
+ */
788
+ async loadDependencySources() {
667
789
  if (this.dependencySources && this.dependencySources.length > 0) {
790
+ const sources = [];
791
+ const readBudgetMs = CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS * this.dependencySources.length;
792
+ const deadline = Date.now() + readBudgetMs;
668
793
  for (const source of this.dependencySources) {
669
- if (signal?.aborted) {
670
- console.log("[MonitorClient] Technology sync cancelled");
671
- return;
794
+ if (Date.now() >= deadline) {
795
+ const skippedCount = this.dependencySources.length - sources.length;
796
+ const warning = `Dependency source reads exceeded ${readBudgetMs}ms, skipping remaining ${skippedCount} source(s)`;
797
+ console.warn(`[MonitorClient] ${warning}`);
798
+ this.reportError("DEPENDENCY_SYNC", warning);
799
+ break;
672
800
  }
673
- const technologies = await this.readPackageJsonFromPath(source.path);
674
- if (technologies.length === 0) continue;
675
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
676
- await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
677
- console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
801
+ const technologies2 = await this.readPackageJsonFromPath(source.path);
802
+ sources.push({ environment: source.environment, technologies: technologies2 });
678
803
  }
679
- } else {
680
- const technologies = await this.readPackageJson();
681
- if (technologies.length === 0) return;
682
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
683
- await this.sendTechnologies(enrichedTechnologies);
684
- console.log(`[MonitorClient] Technology sync completed for ${this.environment}`);
804
+ return sources;
805
+ }
806
+ const technologies = await this.readPackageJson();
807
+ return [{ environment: this.environment, technologies }];
808
+ }
809
+ async performDependencySync(sources, signal) {
810
+ for (const source of sources) {
811
+ if (signal?.aborted) {
812
+ console.log("[MonitorClient] Technology sync cancelled");
813
+ return;
814
+ }
815
+ if (source.technologies.length === 0) continue;
816
+ const enrichedTechnologies = await this.enrichWithLatestVersions(source.technologies, signal);
817
+ await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
818
+ console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
685
819
  }
686
820
  }
687
821
  /**
@@ -795,13 +929,22 @@ var MonitorClient = class {
795
929
  try {
796
930
  if (signal?.aborted) return null;
797
931
  const encodedName = encodeURIComponent(packageName).replace("%40", "@");
798
- const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
799
- headers: { "Accept": "application/json" },
800
- signal: signal ?? AbortSignal.timeout(this.registryTimeoutMs)
801
- });
802
- if (!response.ok) return null;
803
- const data = await response.json();
804
- return data["dist-tags"]?.latest || null;
932
+ const requestController = new AbortController();
933
+ const timeoutId = setTimeout(() => requestController.abort(), this.registryTimeoutMs);
934
+ const onOuterAbort = () => requestController.abort();
935
+ signal?.addEventListener("abort", onOuterAbort);
936
+ try {
937
+ const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
938
+ headers: { "Accept": "application/json" },
939
+ signal: requestController.signal
940
+ });
941
+ if (!response.ok) return null;
942
+ const data = await response.json();
943
+ return data["dist-tags"]?.latest || null;
944
+ } finally {
945
+ clearTimeout(timeoutId);
946
+ signal?.removeEventListener("abort", onOuterAbort);
947
+ }
805
948
  } catch {
806
949
  return null;
807
950
  }
@@ -814,7 +957,7 @@ var MonitorClient = class {
814
957
  async fetchLatestVersions(packageNames, signal) {
815
958
  const results = /* @__PURE__ */ new Map();
816
959
  const concurrencyLimit = CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT;
817
- const totalBatches = Math.ceil(packageNames.length / concurrencyLimit);
960
+ const totalBatches = this.registryBatchCount(packageNames.length);
818
961
  for (let i = 0; i < packageNames.length; i += concurrencyLimit) {
819
962
  if (signal?.aborted) {
820
963
  console.log("[MonitorClient] Version fetch cancelled");
@@ -1015,7 +1158,7 @@ var MonitorClient = class {
1015
1158
  });
1016
1159
  proc.on("close", () => {
1017
1160
  clearTimeout(timeoutId);
1018
- resolve({ stdout, timedOut });
1161
+ resolve({ stdout, stderr, timedOut });
1019
1162
  });
1020
1163
  proc.on("error", (err) => {
1021
1164
  clearTimeout(timeoutId);
@@ -1023,6 +1166,30 @@ var MonitorClient = class {
1023
1166
  });
1024
1167
  });
1025
1168
  }
1169
+ /**
1170
+ * `npm audit --json` can exit without emitting valid JSON on stdout — e.g. the
1171
+ * registry is unreachable, or a fatal npm error is written to stderr instead.
1172
+ * Treat that as a soft failure (like a timeout) rather than throwing, so it
1173
+ * doesn't surface as a confusing "Unexpected end of JSON input" SDK_ERROR.
1174
+ */
1175
+ parseNpmAuditJson(auditOutput, stderr) {
1176
+ const stderrSuffix = () => {
1177
+ const trimmedStderr = stderr.trim();
1178
+ return trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : "";
1179
+ };
1180
+ if (!auditOutput || auditOutput.trim().length === 0) {
1181
+ console.error(`[MonitorClient] npm audit produced no output${stderrSuffix()}`);
1182
+ return null;
1183
+ }
1184
+ try {
1185
+ return JSON.parse(auditOutput);
1186
+ } catch (err) {
1187
+ console.error(
1188
+ `[MonitorClient] npm audit produced invalid JSON: ${err instanceof Error ? err.message : String(err)}${stderrSuffix()}`
1189
+ );
1190
+ return null;
1191
+ }
1192
+ }
1026
1193
  /**
1027
1194
  * Run npm audit and send results to the monitoring server.
1028
1195
  * This scans the project for known vulnerabilities in dependencies.
@@ -1095,10 +1262,19 @@ var MonitorClient = class {
1095
1262
  maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1096
1263
  });
1097
1264
  if (result2.timedOut) {
1098
- console.error(`[MonitorClient] yarn audit timed out after ${this.auditTimeoutMs}ms`);
1265
+ const message = `yarn audit timed out after ${this.auditTimeoutMs}ms`;
1266
+ console.error(`[MonitorClient] ${message}`);
1267
+ this.reportError("VULNERABILITY_SCAN", message);
1099
1268
  return null;
1100
1269
  }
1101
1270
  auditOutput = result2.stdout;
1271
+ if (!auditOutput || auditOutput.trim().length === 0) {
1272
+ const trimmedStderr = result2.stderr.trim();
1273
+ console.error(
1274
+ `[MonitorClient] yarn audit produced no output${trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : ""}`
1275
+ );
1276
+ return null;
1277
+ }
1102
1278
  vulnerabilities = this.parseYarnAuditOutput(auditOutput);
1103
1279
  const lines = auditOutput.trim().split("\n");
1104
1280
  for (const line of lines) {
@@ -1111,33 +1287,24 @@ var MonitorClient = class {
1111
1287
  } catch {
1112
1288
  }
1113
1289
  }
1114
- } else if (packageManager === "pnpm") {
1115
- console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1116
- const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1117
- cwd: projectPath,
1118
- timeout: this.auditTimeoutMs,
1119
- maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1120
- });
1121
- if (result2.timedOut) {
1122
- console.error(`[MonitorClient] npm audit timed out after ${this.auditTimeoutMs}ms`);
1123
- return null;
1124
- }
1125
- auditOutput = result2.stdout;
1126
- const auditData = JSON.parse(auditOutput);
1127
- vulnerabilities = this.parseNpmAuditOutput(auditData);
1128
- totalDeps = auditData.metadata?.dependencies?.total || 0;
1129
1290
  } else {
1291
+ if (packageManager === "pnpm") {
1292
+ console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1293
+ }
1130
1294
  const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1131
1295
  cwd: projectPath,
1132
1296
  timeout: this.auditTimeoutMs,
1133
1297
  maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1134
1298
  });
1135
1299
  if (result2.timedOut) {
1136
- console.error(`[MonitorClient] npm audit timed out after ${this.auditTimeoutMs}ms`);
1300
+ const message = `npm audit timed out after ${this.auditTimeoutMs}ms`;
1301
+ console.error(`[MonitorClient] ${message}`);
1302
+ this.reportError("VULNERABILITY_SCAN", message);
1137
1303
  return null;
1138
1304
  }
1139
1305
  auditOutput = result2.stdout;
1140
- const auditData = JSON.parse(auditOutput);
1306
+ const auditData = this.parseNpmAuditJson(auditOutput, result2.stderr);
1307
+ if (!auditData) return null;
1141
1308
  vulnerabilities = this.parseNpmAuditOutput(auditData);
1142
1309
  totalDeps = auditData.metadata?.dependencies?.total || 0;
1143
1310
  }
@@ -1182,9 +1349,10 @@ var MonitorClient = class {
1182
1349
  }
1183
1350
  console.log(`[MonitorClient] Starting multi-path audit (${this.auditPaths.length} paths)...`);
1184
1351
  try {
1352
+ const timeoutMs = this.calculateMultiPathAuditTimeoutMs(this.auditPaths.length);
1185
1353
  const result = await this.withTimeout(
1186
1354
  () => this.performMultiPathAudit(),
1187
- CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1355
+ timeoutMs,
1188
1356
  "Multi-path audit timed out"
1189
1357
  );
1190
1358
  if (result) {
@@ -1196,6 +1364,21 @@ var MonitorClient = class {
1196
1364
  return null;
1197
1365
  }
1198
1366
  }
1367
+ /**
1368
+ * Scale the multi-path audit timeout with how many paths it will scan, so
1369
+ * configuring many auditPaths (or a large per-path auditTimeoutMs) doesn't
1370
+ * spuriously time out the whole batch against a fixed budget — the same
1371
+ * failure mode calculateSyncTimeoutMs fixes for dependency sync.
1372
+ */
1373
+ calculateMultiPathAuditTimeoutMs(pathCount) {
1374
+ return this.estimateScaledTimeoutMs(
1375
+ pathCount,
1376
+ this.auditTimeoutMs,
1377
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS,
1378
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1379
+ CONFIG_LIMITS.MAX_AUDIT_MULTI_PATH_TIMEOUT_MS
1380
+ );
1381
+ }
1199
1382
  async performMultiPathAudit() {
1200
1383
  if (!this.auditPaths) return null;
1201
1384
  const results = [];
package/dist/index.mjs CHANGED
@@ -17,8 +17,9 @@ var CONFIG_LIMITS = {
17
17
  // 10MB
18
18
  AUDIT_TIMEOUT_MS: 6e4,
19
19
  // 60 seconds (default)
20
- MAX_AUDIT_TIMEOUT_MS: 3e5,
21
- // 5 minutes (max configurable)
20
+ MAX_AUDIT_TIMEOUT_MS: 18e5,
21
+ // 30 minutes (max configurable) - was 5 minutes, which every
22
+ // real npm audit run on Cloud Run (slow registry egress) exceeded, silently returning null
22
23
  SETTINGS_POLL_INTERVAL_MS: 5 * 60 * 1e3,
23
24
  // 5 minutes
24
25
  REGISTRY_TIMEOUT_MS: 5e3,
@@ -28,9 +29,17 @@ var CONFIG_LIMITS = {
28
29
  TECH_VERSION_FETCH_TIMEOUT_MS: 3e4,
29
30
  // 30 seconds max for all version fetches
30
31
  SYNC_DEPENDENCIES_TIMEOUT_MS: 6e4,
31
- // 60 seconds max for all dependency syncs
32
+ // floor: minimum timeout for all dependency syncs
33
+ MAX_SYNC_DEPENDENCIES_TIMEOUT_MS: 3e5,
34
+ // ceiling: 5 minutes, even for very large dependency lists
35
+ SYNC_FIXED_OVERHEAD_MS: 1e4,
36
+ // buffer per sync for local reads + POSTs to the server
32
37
  AUDIT_MULTI_PATH_TIMEOUT_MS: 18e4,
33
- // 3 minutes max for all audit paths
38
+ // floor: minimum timeout for all audit paths
39
+ MAX_AUDIT_MULTI_PATH_TIMEOUT_MS: 18e5,
40
+ // ceiling: 30 minutes, even for many audit paths
41
+ AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS: 1e4,
42
+ // buffer per multi-path audit for local reads + POSTs to the server
34
43
  REGISTRY_CONCURRENCY_LIMIT: 5,
35
44
  // Limit parallel requests to avoid rate limiting
36
45
  HEALTH_CHECK_FETCH_INTERVAL_MS: 6e4,
@@ -305,6 +314,46 @@ var MonitorClient = class {
305
314
  await this.flushHealthResults();
306
315
  await this.flushSdkErrors();
307
316
  }
317
+ /**
318
+ * Node's fetch() (undici) wraps network-level failures (ECONNREFUSED, DNS lookup
319
+ * failures, TLS errors, ...) in a generic `TypeError: fetch failed`, with the real
320
+ * reason attached via the standard `cause` chain. Without unwrapping it, every
321
+ * network failure across the SDK is reported as the same opaque "fetch failed"
322
+ * message, with no way to diagnose it from the dashboard.
323
+ */
324
+ describeErrorCause(err, depth = 0) {
325
+ if (depth > 3) return void 0;
326
+ const cause = err.cause;
327
+ if (cause === void 0 || cause === null) return void 0;
328
+ const aggregateErrors = cause.errors;
329
+ if (Array.isArray(aggregateErrors)) {
330
+ const childSummary = aggregateErrors.map((e) => e instanceof Error ? e.message : String(e)).join("; ");
331
+ const summary = cause instanceof Error && cause.message ? `${cause.message} (${childSummary})` : childSummary;
332
+ return this.truncateDescription(summary);
333
+ }
334
+ if (cause instanceof Error) {
335
+ const nested = this.describeErrorCause(cause, depth + 1);
336
+ const description = nested ? `${cause.message} (${nested})` : cause.message;
337
+ return this.truncateDescription(description);
338
+ }
339
+ return this.truncateDescription(String(cause));
340
+ }
341
+ truncateDescription(description) {
342
+ const limit = 500;
343
+ return description.length > limit ? `${description.slice(0, limit)}...` : description;
344
+ }
345
+ /**
346
+ * Run `fn`, swallowing any throw and returning undefined instead. Used to read
347
+ * possibly-hostile Error fields (a throwing message/stack/cause getter) without
348
+ * losing whatever else was already read — each call degrades independently.
349
+ */
350
+ safe(fn) {
351
+ try {
352
+ return fn();
353
+ } catch {
354
+ return void 0;
355
+ }
356
+ }
308
357
  /**
309
358
  * Queue an SDK error to be reported to the server's system errors page.
310
359
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -313,8 +362,16 @@ var MonitorClient = class {
313
362
  reportError(category, message, err) {
314
363
  if (this.sdkErrorsInCurrentWindow >= 20) return;
315
364
  this.sdkErrorsInCurrentWindow++;
316
- const errorMessage = err instanceof Error ? `${message}: ${err.message}` : message;
317
- const stack = err instanceof Error ? err.stack : void 0;
365
+ let errorMessage = message;
366
+ let stack;
367
+ if (err instanceof Error) {
368
+ errorMessage = this.safe(() => `${message}: ${err.message}`) ?? message;
369
+ const causeDescription = this.safe(() => this.describeErrorCause(err));
370
+ if (causeDescription) {
371
+ errorMessage += ` (cause: ${causeDescription})`;
372
+ }
373
+ stack = this.safe(() => err.stack);
374
+ }
318
375
  this.sdkErrorQueue.push({ category, message: errorMessage, stack });
319
376
  if (this.sdkErrorQueue.length >= 20) {
320
377
  this.flushSdkErrors().catch(() => {
@@ -617,9 +674,11 @@ var MonitorClient = class {
617
674
  async syncDependencies() {
618
675
  console.log("[MonitorClient] Starting technology sync...");
619
676
  try {
677
+ const sources = await this.loadDependencySources();
678
+ const timeoutMs = this.calculateSyncTimeoutMs(sources);
620
679
  await this.withTimeout(
621
- (signal) => this.performDependencySync(signal),
622
- CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
680
+ (signal) => this.performDependencySync(sources, signal),
681
+ timeoutMs,
623
682
  "Technology sync timed out"
624
683
  );
625
684
  console.log("[MonitorClient] Technology sync completed successfully");
@@ -627,25 +686,100 @@ var MonitorClient = class {
627
686
  this.reportError("DEPENDENCY_SYNC", "Technology sync failed", err);
628
687
  }
629
688
  }
630
- async performDependencySync(signal) {
689
+ /**
690
+ * How many sequential registry batches a version-fetch over this many packages
691
+ * will take, given the shared concurrency limit. Shared by fetchLatestVersions
692
+ * (which does the batching) and calculateSyncTimeoutMs (which budgets for it),
693
+ * so the two can't drift out of sync with each other.
694
+ */
695
+ registryBatchCount(itemCount) {
696
+ return Math.ceil(itemCount / CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT);
697
+ }
698
+ /**
699
+ * Estimate a timeout budget that scales with how many roughly-equal-cost units of
700
+ * sequential work will run (registry batches, audit paths, ...), clamped to a
701
+ * floor/ceiling. Shared by calculateSyncTimeoutMs and calculateMultiPathAuditTimeoutMs
702
+ * so the two scaling formulas can't drift apart. At count=0 this always collapses to
703
+ * floorMs, since overheadMs alone never exceeds a floor sized for real work.
704
+ */
705
+ estimateScaledTimeoutMs(count, perItemMs, overheadMs, floorMs, ceilingMs) {
706
+ const estimatedMs = count * perItemMs + overheadMs;
707
+ return Math.min(ceilingMs, Math.max(floorMs, estimatedMs));
708
+ }
709
+ /**
710
+ * Scale the dependency sync timeout with how many registry lookups AND how many
711
+ * sequential POSTs (one per source, via sendTechnologiesWithEnvironment) it will
712
+ * make, so large dependency lists, many dependencySources, or a large requestTimeoutMs
713
+ * don't spuriously time out against a fixed budget.
714
+ *
715
+ * The POST-phase term (sources.length * requestTimeoutMs) is included even when
716
+ * version checking is off: performDependencySync still does one sequential POST per
717
+ * source regardless, so a flat overhead that ignores source count would under-budget
718
+ * that phase exactly the way a flat registry-batch estimate would under-budget large
719
+ * dependency lists.
720
+ *
721
+ * Registry batches are summed PER SOURCE (not over the combined total) because
722
+ * performDependencySync/fetchLatestVersions batch each source independently and
723
+ * sequentially — ceil(a/n) + ceil(b/n) can be larger than ceil((a+b)/n), so a
724
+ * single batch count over the combined total would under-estimate the real
725
+ * number of sequential registry round-trips when many small sources are configured.
726
+ */
727
+ calculateSyncTimeoutMs(sources) {
728
+ const postPhaseMs = sources.length * this.requestTimeoutMs;
729
+ const batches = this.versionCheckEnabled ? sources.reduce((sum, source) => sum + this.registryBatchCount(source.technologies.length), 0) : 0;
730
+ return this.estimateScaledTimeoutMs(
731
+ batches,
732
+ this.registryTimeoutMs,
733
+ CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS + postPhaseMs,
734
+ CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
735
+ CONFIG_LIMITS.MAX_SYNC_DEPENDENCIES_TIMEOUT_MS
736
+ );
737
+ }
738
+ /**
739
+ * Reads happen before syncDependencies' timeout/AbortSignal exists (see comment
740
+ * there), so this phase bounds itself with a wall-clock deadline on a budget that
741
+ * scales with how many sources are configured (mirroring calculateSyncTimeoutMs's
742
+ * scaling principle for the network phase). A plain deadline timestamp — not an
743
+ * AbortController — is enough here: readPackageJsonFromPath does purely synchronous,
744
+ * non-cancelable fs calls, so there is nothing for a signal to actually interrupt;
745
+ * it can only ever be checked between iterations, exactly like a deadline check.
746
+ * Once the budget is spent, stop reading further sources rather than always paying
747
+ * the full read cost for every configured dependencySource regardless of how long
748
+ * it's already taken — and report it via reportError, not just a console.warn, so a
749
+ * truncated sync isn't silently indistinguishable from a fully successful one on
750
+ * the dashboard.
751
+ */
752
+ async loadDependencySources() {
631
753
  if (this.dependencySources && this.dependencySources.length > 0) {
754
+ const sources = [];
755
+ const readBudgetMs = CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS * this.dependencySources.length;
756
+ const deadline = Date.now() + readBudgetMs;
632
757
  for (const source of this.dependencySources) {
633
- if (signal?.aborted) {
634
- console.log("[MonitorClient] Technology sync cancelled");
635
- return;
758
+ if (Date.now() >= deadline) {
759
+ const skippedCount = this.dependencySources.length - sources.length;
760
+ const warning = `Dependency source reads exceeded ${readBudgetMs}ms, skipping remaining ${skippedCount} source(s)`;
761
+ console.warn(`[MonitorClient] ${warning}`);
762
+ this.reportError("DEPENDENCY_SYNC", warning);
763
+ break;
636
764
  }
637
- const technologies = await this.readPackageJsonFromPath(source.path);
638
- if (technologies.length === 0) continue;
639
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
640
- await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
641
- console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
765
+ const technologies2 = await this.readPackageJsonFromPath(source.path);
766
+ sources.push({ environment: source.environment, technologies: technologies2 });
642
767
  }
643
- } else {
644
- const technologies = await this.readPackageJson();
645
- if (technologies.length === 0) return;
646
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
647
- await this.sendTechnologies(enrichedTechnologies);
648
- console.log(`[MonitorClient] Technology sync completed for ${this.environment}`);
768
+ return sources;
769
+ }
770
+ const technologies = await this.readPackageJson();
771
+ return [{ environment: this.environment, technologies }];
772
+ }
773
+ async performDependencySync(sources, signal) {
774
+ for (const source of sources) {
775
+ if (signal?.aborted) {
776
+ console.log("[MonitorClient] Technology sync cancelled");
777
+ return;
778
+ }
779
+ if (source.technologies.length === 0) continue;
780
+ const enrichedTechnologies = await this.enrichWithLatestVersions(source.technologies, signal);
781
+ await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
782
+ console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
649
783
  }
650
784
  }
651
785
  /**
@@ -759,13 +893,22 @@ var MonitorClient = class {
759
893
  try {
760
894
  if (signal?.aborted) return null;
761
895
  const encodedName = encodeURIComponent(packageName).replace("%40", "@");
762
- const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
763
- headers: { "Accept": "application/json" },
764
- signal: signal ?? AbortSignal.timeout(this.registryTimeoutMs)
765
- });
766
- if (!response.ok) return null;
767
- const data = await response.json();
768
- return data["dist-tags"]?.latest || null;
896
+ const requestController = new AbortController();
897
+ const timeoutId = setTimeout(() => requestController.abort(), this.registryTimeoutMs);
898
+ const onOuterAbort = () => requestController.abort();
899
+ signal?.addEventListener("abort", onOuterAbort);
900
+ try {
901
+ const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
902
+ headers: { "Accept": "application/json" },
903
+ signal: requestController.signal
904
+ });
905
+ if (!response.ok) return null;
906
+ const data = await response.json();
907
+ return data["dist-tags"]?.latest || null;
908
+ } finally {
909
+ clearTimeout(timeoutId);
910
+ signal?.removeEventListener("abort", onOuterAbort);
911
+ }
769
912
  } catch {
770
913
  return null;
771
914
  }
@@ -778,7 +921,7 @@ var MonitorClient = class {
778
921
  async fetchLatestVersions(packageNames, signal) {
779
922
  const results = /* @__PURE__ */ new Map();
780
923
  const concurrencyLimit = CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT;
781
- const totalBatches = Math.ceil(packageNames.length / concurrencyLimit);
924
+ const totalBatches = this.registryBatchCount(packageNames.length);
782
925
  for (let i = 0; i < packageNames.length; i += concurrencyLimit) {
783
926
  if (signal?.aborted) {
784
927
  console.log("[MonitorClient] Version fetch cancelled");
@@ -979,7 +1122,7 @@ var MonitorClient = class {
979
1122
  });
980
1123
  proc.on("close", () => {
981
1124
  clearTimeout(timeoutId);
982
- resolve({ stdout, timedOut });
1125
+ resolve({ stdout, stderr, timedOut });
983
1126
  });
984
1127
  proc.on("error", (err) => {
985
1128
  clearTimeout(timeoutId);
@@ -987,6 +1130,30 @@ var MonitorClient = class {
987
1130
  });
988
1131
  });
989
1132
  }
1133
+ /**
1134
+ * `npm audit --json` can exit without emitting valid JSON on stdout — e.g. the
1135
+ * registry is unreachable, or a fatal npm error is written to stderr instead.
1136
+ * Treat that as a soft failure (like a timeout) rather than throwing, so it
1137
+ * doesn't surface as a confusing "Unexpected end of JSON input" SDK_ERROR.
1138
+ */
1139
+ parseNpmAuditJson(auditOutput, stderr) {
1140
+ const stderrSuffix = () => {
1141
+ const trimmedStderr = stderr.trim();
1142
+ return trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : "";
1143
+ };
1144
+ if (!auditOutput || auditOutput.trim().length === 0) {
1145
+ console.error(`[MonitorClient] npm audit produced no output${stderrSuffix()}`);
1146
+ return null;
1147
+ }
1148
+ try {
1149
+ return JSON.parse(auditOutput);
1150
+ } catch (err) {
1151
+ console.error(
1152
+ `[MonitorClient] npm audit produced invalid JSON: ${err instanceof Error ? err.message : String(err)}${stderrSuffix()}`
1153
+ );
1154
+ return null;
1155
+ }
1156
+ }
990
1157
  /**
991
1158
  * Run npm audit and send results to the monitoring server.
992
1159
  * This scans the project for known vulnerabilities in dependencies.
@@ -1059,10 +1226,19 @@ var MonitorClient = class {
1059
1226
  maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1060
1227
  });
1061
1228
  if (result2.timedOut) {
1062
- console.error(`[MonitorClient] yarn audit timed out after ${this.auditTimeoutMs}ms`);
1229
+ const message = `yarn audit timed out after ${this.auditTimeoutMs}ms`;
1230
+ console.error(`[MonitorClient] ${message}`);
1231
+ this.reportError("VULNERABILITY_SCAN", message);
1063
1232
  return null;
1064
1233
  }
1065
1234
  auditOutput = result2.stdout;
1235
+ if (!auditOutput || auditOutput.trim().length === 0) {
1236
+ const trimmedStderr = result2.stderr.trim();
1237
+ console.error(
1238
+ `[MonitorClient] yarn audit produced no output${trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : ""}`
1239
+ );
1240
+ return null;
1241
+ }
1066
1242
  vulnerabilities = this.parseYarnAuditOutput(auditOutput);
1067
1243
  const lines = auditOutput.trim().split("\n");
1068
1244
  for (const line of lines) {
@@ -1075,33 +1251,24 @@ var MonitorClient = class {
1075
1251
  } catch {
1076
1252
  }
1077
1253
  }
1078
- } else if (packageManager === "pnpm") {
1079
- console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1080
- const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1081
- cwd: projectPath,
1082
- timeout: this.auditTimeoutMs,
1083
- maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1084
- });
1085
- if (result2.timedOut) {
1086
- console.error(`[MonitorClient] npm audit timed out after ${this.auditTimeoutMs}ms`);
1087
- return null;
1088
- }
1089
- auditOutput = result2.stdout;
1090
- const auditData = JSON.parse(auditOutput);
1091
- vulnerabilities = this.parseNpmAuditOutput(auditData);
1092
- totalDeps = auditData.metadata?.dependencies?.total || 0;
1093
1254
  } else {
1255
+ if (packageManager === "pnpm") {
1256
+ console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1257
+ }
1094
1258
  const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1095
1259
  cwd: projectPath,
1096
1260
  timeout: this.auditTimeoutMs,
1097
1261
  maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1098
1262
  });
1099
1263
  if (result2.timedOut) {
1100
- console.error(`[MonitorClient] npm audit timed out after ${this.auditTimeoutMs}ms`);
1264
+ const message = `npm audit timed out after ${this.auditTimeoutMs}ms`;
1265
+ console.error(`[MonitorClient] ${message}`);
1266
+ this.reportError("VULNERABILITY_SCAN", message);
1101
1267
  return null;
1102
1268
  }
1103
1269
  auditOutput = result2.stdout;
1104
- const auditData = JSON.parse(auditOutput);
1270
+ const auditData = this.parseNpmAuditJson(auditOutput, result2.stderr);
1271
+ if (!auditData) return null;
1105
1272
  vulnerabilities = this.parseNpmAuditOutput(auditData);
1106
1273
  totalDeps = auditData.metadata?.dependencies?.total || 0;
1107
1274
  }
@@ -1146,9 +1313,10 @@ var MonitorClient = class {
1146
1313
  }
1147
1314
  console.log(`[MonitorClient] Starting multi-path audit (${this.auditPaths.length} paths)...`);
1148
1315
  try {
1316
+ const timeoutMs = this.calculateMultiPathAuditTimeoutMs(this.auditPaths.length);
1149
1317
  const result = await this.withTimeout(
1150
1318
  () => this.performMultiPathAudit(),
1151
- CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1319
+ timeoutMs,
1152
1320
  "Multi-path audit timed out"
1153
1321
  );
1154
1322
  if (result) {
@@ -1160,6 +1328,21 @@ var MonitorClient = class {
1160
1328
  return null;
1161
1329
  }
1162
1330
  }
1331
+ /**
1332
+ * Scale the multi-path audit timeout with how many paths it will scan, so
1333
+ * configuring many auditPaths (or a large per-path auditTimeoutMs) doesn't
1334
+ * spuriously time out the whole batch against a fixed budget — the same
1335
+ * failure mode calculateSyncTimeoutMs fixes for dependency sync.
1336
+ */
1337
+ calculateMultiPathAuditTimeoutMs(pathCount) {
1338
+ return this.estimateScaledTimeoutMs(
1339
+ pathCount,
1340
+ this.auditTimeoutMs,
1341
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS,
1342
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1343
+ CONFIG_LIMITS.MAX_AUDIT_MULTI_PATH_TIMEOUT_MS
1344
+ );
1345
+ }
1163
1346
  async performMultiPathAudit() {
1164
1347
  if (!this.auditPaths) return null;
1165
1348
  const results = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceon-oy/monitor-sdk",
3
- "version": "1.5.2",
3
+ "version": "1.5.5",
4
4
  "description": "Client SDK for Ceon Monitor - Error tracking, health monitoring, security events, and vulnerability scanning",
5
5
  "author": "Ceon",
6
6
  "license": "MIT",