@ceon-oy/monitor-sdk 1.5.1 → 1.5.3

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
@@ -217,6 +217,7 @@ declare class MonitorClient {
217
217
  private lastScanTime;
218
218
  private lastKnownScanRequestedAt;
219
219
  private lastKnownTechScanRequestedAt;
220
+ private currentAuditIntervalHours;
220
221
  private versionCheckEnabled;
221
222
  private auditTimeoutMs;
222
223
  private registryTimeoutMs;
@@ -260,6 +261,21 @@ declare class MonitorClient {
260
261
  flush(): Promise<void>;
261
262
  private getErrorKey;
262
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;
263
279
  /**
264
280
  * Queue an SDK error to be reported to the server's system errors page.
265
281
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -269,6 +285,7 @@ declare class MonitorClient {
269
285
  private startSdkErrorFlushTimer;
270
286
  private stopSdkErrorFlushTimer;
271
287
  private flushSdkErrors;
288
+ private clearAuditIntervalTimer;
272
289
  private stopAuditIntervalTimer;
273
290
  /**
274
291
  * Fetch project settings from the monitoring server.
@@ -292,6 +309,12 @@ declare class MonitorClient {
292
309
  * Also sets up polling for on-demand scan requests from the server.
293
310
  */
294
311
  private setupAutoAudit;
312
+ /**
313
+ * (Re)configure the recurring audit timer for the given interval, replacing
314
+ * whichever timer is currently running (if any). intervalHours <= 0 leaves
315
+ * scanning disabled.
316
+ */
317
+ private setAuditInterval;
295
318
  /**
296
319
  * Run a vulnerability scan and track the time it was run.
297
320
  * Uses auditMultiplePaths() if auditPaths is configured, otherwise runs single audit.
@@ -317,6 +340,55 @@ declare class MonitorClient {
317
340
  private startFlushTimer;
318
341
  private stopFlushTimer;
319
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;
320
392
  private performDependencySync;
321
393
  /**
322
394
  * Enrich technologies with latest version information from npm registry.
@@ -416,6 +488,13 @@ declare class MonitorClient {
416
488
  * Only works in Node.js environment (not browser/bundled).
417
489
  */
418
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;
419
498
  /**
420
499
  * Run npm audit and send results to the monitoring server.
421
500
  * This scans the project for known vulnerabilities in dependencies.
@@ -438,6 +517,13 @@ declare class MonitorClient {
438
517
  * @returns Combined summary of all audit results
439
518
  */
440
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;
441
527
  private performMultiPathAudit;
442
528
  /**
443
529
  * Parse npm audit JSON output into vulnerability items
@@ -512,7 +598,8 @@ declare class MonitorClient {
512
598
  /**
513
599
  * Measure CPU utilization by sampling cpus() twice with a 3s gap.
514
600
  * The cumulative counters make this the average utilization over the
515
- * window, across all cores of the host. Returns a percentage (0–100).
601
+ * window, across all cores of the host. Returns a percentage (0–100)
602
+ * plus the CPU model, read off the same samples at zero extra cost.
516
603
  */
517
604
  private measureCpuPercent;
518
605
  /**
@@ -531,6 +618,7 @@ declare class MonitorClient {
531
618
  submitSystemMetric(metric: {
532
619
  hostname: string;
533
620
  cpuPercent: number;
621
+ cpuModel?: string | null;
534
622
  memoryTotal: number;
535
623
  memoryUsed: number;
536
624
  memoryPercent: number;
package/dist/index.d.ts CHANGED
@@ -217,6 +217,7 @@ declare class MonitorClient {
217
217
  private lastScanTime;
218
218
  private lastKnownScanRequestedAt;
219
219
  private lastKnownTechScanRequestedAt;
220
+ private currentAuditIntervalHours;
220
221
  private versionCheckEnabled;
221
222
  private auditTimeoutMs;
222
223
  private registryTimeoutMs;
@@ -260,6 +261,21 @@ declare class MonitorClient {
260
261
  flush(): Promise<void>;
261
262
  private getErrorKey;
262
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;
263
279
  /**
264
280
  * Queue an SDK error to be reported to the server's system errors page.
265
281
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -269,6 +285,7 @@ declare class MonitorClient {
269
285
  private startSdkErrorFlushTimer;
270
286
  private stopSdkErrorFlushTimer;
271
287
  private flushSdkErrors;
288
+ private clearAuditIntervalTimer;
272
289
  private stopAuditIntervalTimer;
273
290
  /**
274
291
  * Fetch project settings from the monitoring server.
@@ -292,6 +309,12 @@ declare class MonitorClient {
292
309
  * Also sets up polling for on-demand scan requests from the server.
293
310
  */
294
311
  private setupAutoAudit;
312
+ /**
313
+ * (Re)configure the recurring audit timer for the given interval, replacing
314
+ * whichever timer is currently running (if any). intervalHours <= 0 leaves
315
+ * scanning disabled.
316
+ */
317
+ private setAuditInterval;
295
318
  /**
296
319
  * Run a vulnerability scan and track the time it was run.
297
320
  * Uses auditMultiplePaths() if auditPaths is configured, otherwise runs single audit.
@@ -317,6 +340,55 @@ declare class MonitorClient {
317
340
  private startFlushTimer;
318
341
  private stopFlushTimer;
319
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;
320
392
  private performDependencySync;
321
393
  /**
322
394
  * Enrich technologies with latest version information from npm registry.
@@ -416,6 +488,13 @@ declare class MonitorClient {
416
488
  * Only works in Node.js environment (not browser/bundled).
417
489
  */
418
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;
419
498
  /**
420
499
  * Run npm audit and send results to the monitoring server.
421
500
  * This scans the project for known vulnerabilities in dependencies.
@@ -438,6 +517,13 @@ declare class MonitorClient {
438
517
  * @returns Combined summary of all audit results
439
518
  */
440
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;
441
527
  private performMultiPathAudit;
442
528
  /**
443
529
  * Parse npm audit JSON output into vulnerability items
@@ -512,7 +598,8 @@ declare class MonitorClient {
512
598
  /**
513
599
  * Measure CPU utilization by sampling cpus() twice with a 3s gap.
514
600
  * The cumulative counters make this the average utilization over the
515
- * window, across all cores of the host. Returns a percentage (0–100).
601
+ * window, across all cores of the host. Returns a percentage (0–100)
602
+ * plus the CPU model, read off the same samples at zero extra cost.
516
603
  */
517
604
  private measureCpuPercent;
518
605
  /**
@@ -531,6 +618,7 @@ declare class MonitorClient {
531
618
  submitSystemMetric(metric: {
532
619
  hostname: string;
533
620
  cpuPercent: number;
621
+ cpuModel?: string | null;
534
622
  memoryTotal: number;
535
623
  memoryUsed: number;
536
624
  memoryPercent: number;
package/dist/index.js CHANGED
@@ -64,9 +64,17 @@ var CONFIG_LIMITS = {
64
64
  TECH_VERSION_FETCH_TIMEOUT_MS: 3e4,
65
65
  // 30 seconds max for all version fetches
66
66
  SYNC_DEPENDENCIES_TIMEOUT_MS: 6e4,
67
- // 60 seconds max for all dependency syncs
67
+ // floor: minimum timeout for all dependency syncs
68
+ MAX_SYNC_DEPENDENCIES_TIMEOUT_MS: 3e5,
69
+ // ceiling: 5 minutes, even for very large dependency lists
70
+ SYNC_FIXED_OVERHEAD_MS: 1e4,
71
+ // buffer per sync for local reads + POSTs to the server
68
72
  AUDIT_MULTI_PATH_TIMEOUT_MS: 18e4,
69
- // 3 minutes max for all audit paths
73
+ // floor: minimum timeout for all audit paths
74
+ MAX_AUDIT_MULTI_PATH_TIMEOUT_MS: 18e5,
75
+ // ceiling: 30 minutes, even for many audit paths
76
+ AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS: 1e4,
77
+ // buffer per multi-path audit for local reads + POSTs to the server
70
78
  REGISTRY_CONCURRENCY_LIMIT: 5,
71
79
  // Limit parallel requests to avoid rate limiting
72
80
  HEALTH_CHECK_FETCH_INTERVAL_MS: 6e4,
@@ -90,6 +98,9 @@ var MonitorClient = class {
90
98
  this.lastScanTime = null;
91
99
  this.lastKnownScanRequestedAt = null;
92
100
  this.lastKnownTechScanRequestedAt = null;
101
+ // Interval (in hours) the currently-running auditIntervalTimer was configured with,
102
+ // so checkForScanRequest() can detect server-side changes and reconfigure it.
103
+ this.currentAuditIntervalHours = null;
93
104
  this.healthCheckFetchTimer = null;
94
105
  this.healthCheckTimers = /* @__PURE__ */ new Map();
95
106
  this.healthCheckResultsQueue = [];
@@ -338,6 +349,46 @@ var MonitorClient = class {
338
349
  await this.flushHealthResults();
339
350
  await this.flushSdkErrors();
340
351
  }
352
+ /**
353
+ * Node's fetch() (undici) wraps network-level failures (ECONNREFUSED, DNS lookup
354
+ * failures, TLS errors, ...) in a generic `TypeError: fetch failed`, with the real
355
+ * reason attached via the standard `cause` chain. Without unwrapping it, every
356
+ * network failure across the SDK is reported as the same opaque "fetch failed"
357
+ * message, with no way to diagnose it from the dashboard.
358
+ */
359
+ describeErrorCause(err, depth = 0) {
360
+ if (depth > 3) return void 0;
361
+ const cause = err.cause;
362
+ if (cause === void 0 || cause === null) return void 0;
363
+ const aggregateErrors = cause.errors;
364
+ if (Array.isArray(aggregateErrors)) {
365
+ const childSummary = aggregateErrors.map((e) => e instanceof Error ? e.message : String(e)).join("; ");
366
+ const summary = cause instanceof Error && cause.message ? `${cause.message} (${childSummary})` : childSummary;
367
+ return this.truncateDescription(summary);
368
+ }
369
+ if (cause instanceof Error) {
370
+ const nested = this.describeErrorCause(cause, depth + 1);
371
+ const description = nested ? `${cause.message} (${nested})` : cause.message;
372
+ return this.truncateDescription(description);
373
+ }
374
+ return this.truncateDescription(String(cause));
375
+ }
376
+ truncateDescription(description) {
377
+ const limit = 500;
378
+ return description.length > limit ? `${description.slice(0, limit)}...` : description;
379
+ }
380
+ /**
381
+ * Run `fn`, swallowing any throw and returning undefined instead. Used to read
382
+ * possibly-hostile Error fields (a throwing message/stack/cause getter) without
383
+ * losing whatever else was already read — each call degrades independently.
384
+ */
385
+ safe(fn) {
386
+ try {
387
+ return fn();
388
+ } catch {
389
+ return void 0;
390
+ }
391
+ }
341
392
  /**
342
393
  * Queue an SDK error to be reported to the server's system errors page.
343
394
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -346,8 +397,16 @@ var MonitorClient = class {
346
397
  reportError(category, message, err) {
347
398
  if (this.sdkErrorsInCurrentWindow >= 20) return;
348
399
  this.sdkErrorsInCurrentWindow++;
349
- const errorMessage = err instanceof Error ? `${message}: ${err.message}` : message;
350
- const stack = err instanceof Error ? err.stack : void 0;
400
+ let errorMessage = message;
401
+ let stack;
402
+ if (err instanceof Error) {
403
+ errorMessage = this.safe(() => `${message}: ${err.message}`) ?? message;
404
+ const causeDescription = this.safe(() => this.describeErrorCause(err));
405
+ if (causeDescription) {
406
+ errorMessage += ` (cause: ${causeDescription})`;
407
+ }
408
+ stack = this.safe(() => err.stack);
409
+ }
351
410
  this.sdkErrorQueue.push({ category, message: errorMessage, stack });
352
411
  if (this.sdkErrorQueue.length >= 20) {
353
412
  this.flushSdkErrors().catch(() => {
@@ -393,11 +452,14 @@ var MonitorClient = class {
393
452
  console.warn("[MonitorClient] Failed to flush SDK errors to server:", err instanceof Error ? err.message : String(err));
394
453
  }
395
454
  }
396
- stopAuditIntervalTimer() {
455
+ clearAuditIntervalTimer() {
397
456
  if (this.auditIntervalTimer) {
398
457
  clearInterval(this.auditIntervalTimer);
399
458
  this.auditIntervalTimer = null;
400
459
  }
460
+ }
461
+ stopAuditIntervalTimer() {
462
+ this.clearAuditIntervalTimer();
401
463
  if (this.settingsPollingTimer) {
402
464
  clearInterval(this.settingsPollingTimer);
403
465
  this.settingsPollingTimer = null;
@@ -447,15 +509,11 @@ var MonitorClient = class {
447
509
  const intervalHours = settings.vulnerabilityScanIntervalHours;
448
510
  if (intervalHours <= 0) {
449
511
  console.log("[MonitorClient] Scheduled vulnerability scanning disabled by server configuration");
512
+ this.currentAuditIntervalHours = intervalHours;
450
513
  } else {
451
514
  console.log(`[MonitorClient] Auto vulnerability scanning enabled (every ${intervalHours} hours)`);
452
515
  await this.runScanAndTrackTime();
453
- const intervalMs = intervalHours * 60 * 60 * 1e3;
454
- this.auditIntervalTimer = setInterval(() => {
455
- this.runScanAndTrackTime().catch((err) => {
456
- this.reportError("AUDIT_SCAN", "Auto audit scan failed", err);
457
- });
458
- }, intervalMs);
516
+ this.setAuditInterval(intervalHours);
459
517
  }
460
518
  console.log("[MonitorClient] Polling for scan requests enabled (every 5 minutes)");
461
519
  this.settingsPollingTimer = setInterval(() => {
@@ -464,6 +522,24 @@ var MonitorClient = class {
464
522
  });
465
523
  }, CONFIG_LIMITS.SETTINGS_POLL_INTERVAL_MS);
466
524
  }
525
+ /**
526
+ * (Re)configure the recurring audit timer for the given interval, replacing
527
+ * whichever timer is currently running (if any). intervalHours <= 0 leaves
528
+ * scanning disabled.
529
+ */
530
+ setAuditInterval(intervalHours) {
531
+ this.clearAuditIntervalTimer();
532
+ this.currentAuditIntervalHours = intervalHours;
533
+ if (intervalHours <= 0) {
534
+ return;
535
+ }
536
+ const intervalMs = intervalHours * 60 * 60 * 1e3;
537
+ this.auditIntervalTimer = setInterval(() => {
538
+ this.runScanAndTrackTime().catch((err) => {
539
+ this.reportError("AUDIT_SCAN", "Auto audit scan failed", err);
540
+ });
541
+ }, intervalMs);
542
+ }
467
543
  /**
468
544
  * Run a vulnerability scan and track the time it was run.
469
545
  * Uses auditMultiplePaths() if auditPaths is configured, otherwise runs single audit.
@@ -507,6 +583,12 @@ var MonitorClient = class {
507
583
  await this.syncDependencies();
508
584
  }
509
585
  }
586
+ if (settings.vulnerabilityScanIntervalHours !== this.currentAuditIntervalHours) {
587
+ console.log(
588
+ `[MonitorClient] Auto scan interval changed (${this.currentAuditIntervalHours ?? "unset"} \u2192 ${settings.vulnerabilityScanIntervalHours} hours), reconfiguring`
589
+ );
590
+ this.setAuditInterval(settings.vulnerabilityScanIntervalHours);
591
+ }
510
592
  } catch (err) {
511
593
  this.reportError("SETTINGS_POLL", "Failed to check for scan request", err);
512
594
  }
@@ -627,9 +709,11 @@ var MonitorClient = class {
627
709
  async syncDependencies() {
628
710
  console.log("[MonitorClient] Starting technology sync...");
629
711
  try {
712
+ const sources = await this.loadDependencySources();
713
+ const timeoutMs = this.calculateSyncTimeoutMs(sources);
630
714
  await this.withTimeout(
631
- (signal) => this.performDependencySync(signal),
632
- CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
715
+ (signal) => this.performDependencySync(sources, signal),
716
+ timeoutMs,
633
717
  "Technology sync timed out"
634
718
  );
635
719
  console.log("[MonitorClient] Technology sync completed successfully");
@@ -637,25 +721,100 @@ var MonitorClient = class {
637
721
  this.reportError("DEPENDENCY_SYNC", "Technology sync failed", err);
638
722
  }
639
723
  }
640
- async performDependencySync(signal) {
724
+ /**
725
+ * How many sequential registry batches a version-fetch over this many packages
726
+ * will take, given the shared concurrency limit. Shared by fetchLatestVersions
727
+ * (which does the batching) and calculateSyncTimeoutMs (which budgets for it),
728
+ * so the two can't drift out of sync with each other.
729
+ */
730
+ registryBatchCount(itemCount) {
731
+ return Math.ceil(itemCount / CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT);
732
+ }
733
+ /**
734
+ * Estimate a timeout budget that scales with how many roughly-equal-cost units of
735
+ * sequential work will run (registry batches, audit paths, ...), clamped to a
736
+ * floor/ceiling. Shared by calculateSyncTimeoutMs and calculateMultiPathAuditTimeoutMs
737
+ * so the two scaling formulas can't drift apart. At count=0 this always collapses to
738
+ * floorMs, since overheadMs alone never exceeds a floor sized for real work.
739
+ */
740
+ estimateScaledTimeoutMs(count, perItemMs, overheadMs, floorMs, ceilingMs) {
741
+ const estimatedMs = count * perItemMs + overheadMs;
742
+ return Math.min(ceilingMs, Math.max(floorMs, estimatedMs));
743
+ }
744
+ /**
745
+ * Scale the dependency sync timeout with how many registry lookups AND how many
746
+ * sequential POSTs (one per source, via sendTechnologiesWithEnvironment) it will
747
+ * make, so large dependency lists, many dependencySources, or a large requestTimeoutMs
748
+ * don't spuriously time out against a fixed budget.
749
+ *
750
+ * The POST-phase term (sources.length * requestTimeoutMs) is included even when
751
+ * version checking is off: performDependencySync still does one sequential POST per
752
+ * source regardless, so a flat overhead that ignores source count would under-budget
753
+ * that phase exactly the way a flat registry-batch estimate would under-budget large
754
+ * dependency lists.
755
+ *
756
+ * Registry batches are summed PER SOURCE (not over the combined total) because
757
+ * performDependencySync/fetchLatestVersions batch each source independently and
758
+ * sequentially — ceil(a/n) + ceil(b/n) can be larger than ceil((a+b)/n), so a
759
+ * single batch count over the combined total would under-estimate the real
760
+ * number of sequential registry round-trips when many small sources are configured.
761
+ */
762
+ calculateSyncTimeoutMs(sources) {
763
+ const postPhaseMs = sources.length * this.requestTimeoutMs;
764
+ const batches = this.versionCheckEnabled ? sources.reduce((sum, source) => sum + this.registryBatchCount(source.technologies.length), 0) : 0;
765
+ return this.estimateScaledTimeoutMs(
766
+ batches,
767
+ this.registryTimeoutMs,
768
+ CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS + postPhaseMs,
769
+ CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
770
+ CONFIG_LIMITS.MAX_SYNC_DEPENDENCIES_TIMEOUT_MS
771
+ );
772
+ }
773
+ /**
774
+ * Reads happen before syncDependencies' timeout/AbortSignal exists (see comment
775
+ * there), so this phase bounds itself with a wall-clock deadline on a budget that
776
+ * scales with how many sources are configured (mirroring calculateSyncTimeoutMs's
777
+ * scaling principle for the network phase). A plain deadline timestamp — not an
778
+ * AbortController — is enough here: readPackageJsonFromPath does purely synchronous,
779
+ * non-cancelable fs calls, so there is nothing for a signal to actually interrupt;
780
+ * it can only ever be checked between iterations, exactly like a deadline check.
781
+ * Once the budget is spent, stop reading further sources rather than always paying
782
+ * the full read cost for every configured dependencySource regardless of how long
783
+ * it's already taken — and report it via reportError, not just a console.warn, so a
784
+ * truncated sync isn't silently indistinguishable from a fully successful one on
785
+ * the dashboard.
786
+ */
787
+ async loadDependencySources() {
641
788
  if (this.dependencySources && this.dependencySources.length > 0) {
789
+ const sources = [];
790
+ const readBudgetMs = CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS * this.dependencySources.length;
791
+ const deadline = Date.now() + readBudgetMs;
642
792
  for (const source of this.dependencySources) {
643
- if (signal?.aborted) {
644
- console.log("[MonitorClient] Technology sync cancelled");
645
- return;
793
+ if (Date.now() >= deadline) {
794
+ const skippedCount = this.dependencySources.length - sources.length;
795
+ const warning = `Dependency source reads exceeded ${readBudgetMs}ms, skipping remaining ${skippedCount} source(s)`;
796
+ console.warn(`[MonitorClient] ${warning}`);
797
+ this.reportError("DEPENDENCY_SYNC", warning);
798
+ break;
646
799
  }
647
- const technologies = await this.readPackageJsonFromPath(source.path);
648
- if (technologies.length === 0) continue;
649
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
650
- await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
651
- console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
800
+ const technologies2 = await this.readPackageJsonFromPath(source.path);
801
+ sources.push({ environment: source.environment, technologies: technologies2 });
652
802
  }
653
- } else {
654
- const technologies = await this.readPackageJson();
655
- if (technologies.length === 0) return;
656
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
657
- await this.sendTechnologies(enrichedTechnologies);
658
- console.log(`[MonitorClient] Technology sync completed for ${this.environment}`);
803
+ return sources;
804
+ }
805
+ const technologies = await this.readPackageJson();
806
+ return [{ environment: this.environment, technologies }];
807
+ }
808
+ async performDependencySync(sources, signal) {
809
+ for (const source of sources) {
810
+ if (signal?.aborted) {
811
+ console.log("[MonitorClient] Technology sync cancelled");
812
+ return;
813
+ }
814
+ if (source.technologies.length === 0) continue;
815
+ const enrichedTechnologies = await this.enrichWithLatestVersions(source.technologies, signal);
816
+ await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
817
+ console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
659
818
  }
660
819
  }
661
820
  /**
@@ -769,13 +928,22 @@ var MonitorClient = class {
769
928
  try {
770
929
  if (signal?.aborted) return null;
771
930
  const encodedName = encodeURIComponent(packageName).replace("%40", "@");
772
- const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
773
- headers: { "Accept": "application/json" },
774
- signal: signal ?? AbortSignal.timeout(this.registryTimeoutMs)
775
- });
776
- if (!response.ok) return null;
777
- const data = await response.json();
778
- return data["dist-tags"]?.latest || null;
931
+ const requestController = new AbortController();
932
+ const timeoutId = setTimeout(() => requestController.abort(), this.registryTimeoutMs);
933
+ const onOuterAbort = () => requestController.abort();
934
+ signal?.addEventListener("abort", onOuterAbort);
935
+ try {
936
+ const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
937
+ headers: { "Accept": "application/json" },
938
+ signal: requestController.signal
939
+ });
940
+ if (!response.ok) return null;
941
+ const data = await response.json();
942
+ return data["dist-tags"]?.latest || null;
943
+ } finally {
944
+ clearTimeout(timeoutId);
945
+ signal?.removeEventListener("abort", onOuterAbort);
946
+ }
779
947
  } catch {
780
948
  return null;
781
949
  }
@@ -788,7 +956,7 @@ var MonitorClient = class {
788
956
  async fetchLatestVersions(packageNames, signal) {
789
957
  const results = /* @__PURE__ */ new Map();
790
958
  const concurrencyLimit = CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT;
791
- const totalBatches = Math.ceil(packageNames.length / concurrencyLimit);
959
+ const totalBatches = this.registryBatchCount(packageNames.length);
792
960
  for (let i = 0; i < packageNames.length; i += concurrencyLimit) {
793
961
  if (signal?.aborted) {
794
962
  console.log("[MonitorClient] Version fetch cancelled");
@@ -959,7 +1127,7 @@ var MonitorClient = class {
959
1127
  "child_process"
960
1128
  );
961
1129
  const { spawn } = childProcess;
962
- return new Promise((resolve) => {
1130
+ return new Promise((resolve, reject) => {
963
1131
  const proc = spawn(command, args, {
964
1132
  cwd: options.cwd,
965
1133
  stdio: ["pipe", "pipe", "pipe"],
@@ -968,10 +1136,8 @@ var MonitorClient = class {
968
1136
  let stdout = "";
969
1137
  let stderr = "";
970
1138
  let timedOut = false;
971
- let killed = false;
972
1139
  const timeoutId = setTimeout(() => {
973
1140
  timedOut = true;
974
- killed = true;
975
1141
  proc.kill("SIGTERM");
976
1142
  setTimeout(() => {
977
1143
  if (!proc.killed) {
@@ -991,14 +1157,38 @@ var MonitorClient = class {
991
1157
  });
992
1158
  proc.on("close", () => {
993
1159
  clearTimeout(timeoutId);
994
- resolve({ stdout, timedOut });
1160
+ resolve({ stdout, stderr, timedOut });
995
1161
  });
996
- proc.on("error", () => {
1162
+ proc.on("error", (err) => {
997
1163
  clearTimeout(timeoutId);
998
- resolve({ stdout, timedOut: killed });
1164
+ reject(err);
999
1165
  });
1000
1166
  });
1001
1167
  }
1168
+ /**
1169
+ * `npm audit --json` can exit without emitting valid JSON on stdout — e.g. the
1170
+ * registry is unreachable, or a fatal npm error is written to stderr instead.
1171
+ * Treat that as a soft failure (like a timeout) rather than throwing, so it
1172
+ * doesn't surface as a confusing "Unexpected end of JSON input" SDK_ERROR.
1173
+ */
1174
+ parseNpmAuditJson(auditOutput, stderr) {
1175
+ const stderrSuffix = () => {
1176
+ const trimmedStderr = stderr.trim();
1177
+ return trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : "";
1178
+ };
1179
+ if (!auditOutput || auditOutput.trim().length === 0) {
1180
+ console.error(`[MonitorClient] npm audit produced no output${stderrSuffix()}`);
1181
+ return null;
1182
+ }
1183
+ try {
1184
+ return JSON.parse(auditOutput);
1185
+ } catch (err) {
1186
+ console.error(
1187
+ `[MonitorClient] npm audit produced invalid JSON: ${err instanceof Error ? err.message : String(err)}${stderrSuffix()}`
1188
+ );
1189
+ return null;
1190
+ }
1191
+ }
1002
1192
  /**
1003
1193
  * Run npm audit and send results to the monitoring server.
1004
1194
  * This scans the project for known vulnerabilities in dependencies.
@@ -1075,6 +1265,13 @@ var MonitorClient = class {
1075
1265
  return null;
1076
1266
  }
1077
1267
  auditOutput = result2.stdout;
1268
+ if (!auditOutput || auditOutput.trim().length === 0) {
1269
+ const trimmedStderr = result2.stderr.trim();
1270
+ console.error(
1271
+ `[MonitorClient] yarn audit produced no output${trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : ""}`
1272
+ );
1273
+ return null;
1274
+ }
1078
1275
  vulnerabilities = this.parseYarnAuditOutput(auditOutput);
1079
1276
  const lines = auditOutput.trim().split("\n");
1080
1277
  for (const line of lines) {
@@ -1087,22 +1284,10 @@ var MonitorClient = class {
1087
1284
  } catch {
1088
1285
  }
1089
1286
  }
1090
- } else if (packageManager === "pnpm") {
1091
- console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1092
- const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1093
- cwd: projectPath,
1094
- timeout: this.auditTimeoutMs,
1095
- maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1096
- });
1097
- if (result2.timedOut) {
1098
- console.error(`[MonitorClient] npm audit timed out after ${this.auditTimeoutMs}ms`);
1099
- return null;
1100
- }
1101
- auditOutput = result2.stdout;
1102
- const auditData = JSON.parse(auditOutput);
1103
- vulnerabilities = this.parseNpmAuditOutput(auditData);
1104
- totalDeps = auditData.metadata?.dependencies?.total || 0;
1105
1287
  } else {
1288
+ if (packageManager === "pnpm") {
1289
+ console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1290
+ }
1106
1291
  const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1107
1292
  cwd: projectPath,
1108
1293
  timeout: this.auditTimeoutMs,
@@ -1113,7 +1298,8 @@ var MonitorClient = class {
1113
1298
  return null;
1114
1299
  }
1115
1300
  auditOutput = result2.stdout;
1116
- const auditData = JSON.parse(auditOutput);
1301
+ const auditData = this.parseNpmAuditJson(auditOutput, result2.stderr);
1302
+ if (!auditData) return null;
1117
1303
  vulnerabilities = this.parseNpmAuditOutput(auditData);
1118
1304
  totalDeps = auditData.metadata?.dependencies?.total || 0;
1119
1305
  }
@@ -1158,9 +1344,10 @@ var MonitorClient = class {
1158
1344
  }
1159
1345
  console.log(`[MonitorClient] Starting multi-path audit (${this.auditPaths.length} paths)...`);
1160
1346
  try {
1347
+ const timeoutMs = this.calculateMultiPathAuditTimeoutMs(this.auditPaths.length);
1161
1348
  const result = await this.withTimeout(
1162
1349
  () => this.performMultiPathAudit(),
1163
- CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1350
+ timeoutMs,
1164
1351
  "Multi-path audit timed out"
1165
1352
  );
1166
1353
  if (result) {
@@ -1172,6 +1359,21 @@ var MonitorClient = class {
1172
1359
  return null;
1173
1360
  }
1174
1361
  }
1362
+ /**
1363
+ * Scale the multi-path audit timeout with how many paths it will scan, so
1364
+ * configuring many auditPaths (or a large per-path auditTimeoutMs) doesn't
1365
+ * spuriously time out the whole batch against a fixed budget — the same
1366
+ * failure mode calculateSyncTimeoutMs fixes for dependency sync.
1367
+ */
1368
+ calculateMultiPathAuditTimeoutMs(pathCount) {
1369
+ return this.estimateScaledTimeoutMs(
1370
+ pathCount,
1371
+ this.auditTimeoutMs,
1372
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS,
1373
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1374
+ CONFIG_LIMITS.MAX_AUDIT_MULTI_PATH_TIMEOUT_MS
1375
+ );
1376
+ }
1175
1377
  async performMultiPathAudit() {
1176
1378
  if (!this.auditPaths) return null;
1177
1379
  const results = [];
@@ -1594,7 +1796,7 @@ var MonitorClient = class {
1594
1796
  }
1595
1797
  try {
1596
1798
  const hostname = os.hostname();
1597
- const cpuPercent = await this.measureCpuPercent(os);
1799
+ const { percent: cpuPercent, model: cpuModel } = await this.measureCpuPercent(os);
1598
1800
  const memoryTotal = os.totalmem();
1599
1801
  const memoryAvailable = await this.getAvailableMemoryBytes(os, fs);
1600
1802
  const memoryUsed = memoryTotal - memoryAvailable;
@@ -1603,6 +1805,7 @@ var MonitorClient = class {
1603
1805
  await this.submitSystemMetric({
1604
1806
  hostname,
1605
1807
  cpuPercent,
1808
+ cpuModel,
1606
1809
  memoryTotal,
1607
1810
  memoryUsed,
1608
1811
  memoryPercent,
@@ -1615,12 +1818,14 @@ var MonitorClient = class {
1615
1818
  /**
1616
1819
  * Measure CPU utilization by sampling cpus() twice with a 3s gap.
1617
1820
  * The cumulative counters make this the average utilization over the
1618
- * window, across all cores of the host. Returns a percentage (0–100).
1821
+ * window, across all cores of the host. Returns a percentage (0–100)
1822
+ * plus the CPU model, read off the same samples at zero extra cost.
1619
1823
  */
1620
1824
  async measureCpuPercent(os) {
1621
1825
  const sample1 = os.cpus();
1622
1826
  await new Promise((resolve) => setTimeout(resolve, 3e3));
1623
1827
  const sample2 = os.cpus();
1828
+ const model = sample1[0]?.model ?? null;
1624
1829
  let totalIdle = 0;
1625
1830
  let totalTick = 0;
1626
1831
  for (let i = 0; i < sample2.length; i++) {
@@ -1632,10 +1837,10 @@ var MonitorClient = class {
1632
1837
  totalIdle += curr.times.idle - prev.times.idle;
1633
1838
  }
1634
1839
  if (totalTick <= 0) {
1635
- return 0;
1840
+ return { percent: 0, model };
1636
1841
  }
1637
1842
  const idlePercent = totalIdle / totalTick * 100;
1638
- return Math.max(0, Math.min(100, 100 - idlePercent));
1843
+ return { percent: Math.max(0, Math.min(100, 100 - idlePercent)), model };
1639
1844
  }
1640
1845
  /**
1641
1846
  * Get reclaimable memory in bytes. On Linux, reads MemAvailable from
package/dist/index.mjs CHANGED
@@ -28,9 +28,17 @@ var CONFIG_LIMITS = {
28
28
  TECH_VERSION_FETCH_TIMEOUT_MS: 3e4,
29
29
  // 30 seconds max for all version fetches
30
30
  SYNC_DEPENDENCIES_TIMEOUT_MS: 6e4,
31
- // 60 seconds max for all dependency syncs
31
+ // floor: minimum timeout for all dependency syncs
32
+ MAX_SYNC_DEPENDENCIES_TIMEOUT_MS: 3e5,
33
+ // ceiling: 5 minutes, even for very large dependency lists
34
+ SYNC_FIXED_OVERHEAD_MS: 1e4,
35
+ // buffer per sync for local reads + POSTs to the server
32
36
  AUDIT_MULTI_PATH_TIMEOUT_MS: 18e4,
33
- // 3 minutes max for all audit paths
37
+ // floor: minimum timeout for all audit paths
38
+ MAX_AUDIT_MULTI_PATH_TIMEOUT_MS: 18e5,
39
+ // ceiling: 30 minutes, even for many audit paths
40
+ AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS: 1e4,
41
+ // buffer per multi-path audit for local reads + POSTs to the server
34
42
  REGISTRY_CONCURRENCY_LIMIT: 5,
35
43
  // Limit parallel requests to avoid rate limiting
36
44
  HEALTH_CHECK_FETCH_INTERVAL_MS: 6e4,
@@ -54,6 +62,9 @@ var MonitorClient = class {
54
62
  this.lastScanTime = null;
55
63
  this.lastKnownScanRequestedAt = null;
56
64
  this.lastKnownTechScanRequestedAt = null;
65
+ // Interval (in hours) the currently-running auditIntervalTimer was configured with,
66
+ // so checkForScanRequest() can detect server-side changes and reconfigure it.
67
+ this.currentAuditIntervalHours = null;
57
68
  this.healthCheckFetchTimer = null;
58
69
  this.healthCheckTimers = /* @__PURE__ */ new Map();
59
70
  this.healthCheckResultsQueue = [];
@@ -302,6 +313,46 @@ var MonitorClient = class {
302
313
  await this.flushHealthResults();
303
314
  await this.flushSdkErrors();
304
315
  }
316
+ /**
317
+ * Node's fetch() (undici) wraps network-level failures (ECONNREFUSED, DNS lookup
318
+ * failures, TLS errors, ...) in a generic `TypeError: fetch failed`, with the real
319
+ * reason attached via the standard `cause` chain. Without unwrapping it, every
320
+ * network failure across the SDK is reported as the same opaque "fetch failed"
321
+ * message, with no way to diagnose it from the dashboard.
322
+ */
323
+ describeErrorCause(err, depth = 0) {
324
+ if (depth > 3) return void 0;
325
+ const cause = err.cause;
326
+ if (cause === void 0 || cause === null) return void 0;
327
+ const aggregateErrors = cause.errors;
328
+ if (Array.isArray(aggregateErrors)) {
329
+ const childSummary = aggregateErrors.map((e) => e instanceof Error ? e.message : String(e)).join("; ");
330
+ const summary = cause instanceof Error && cause.message ? `${cause.message} (${childSummary})` : childSummary;
331
+ return this.truncateDescription(summary);
332
+ }
333
+ if (cause instanceof Error) {
334
+ const nested = this.describeErrorCause(cause, depth + 1);
335
+ const description = nested ? `${cause.message} (${nested})` : cause.message;
336
+ return this.truncateDescription(description);
337
+ }
338
+ return this.truncateDescription(String(cause));
339
+ }
340
+ truncateDescription(description) {
341
+ const limit = 500;
342
+ return description.length > limit ? `${description.slice(0, limit)}...` : description;
343
+ }
344
+ /**
345
+ * Run `fn`, swallowing any throw and returning undefined instead. Used to read
346
+ * possibly-hostile Error fields (a throwing message/stack/cause getter) without
347
+ * losing whatever else was already read — each call degrades independently.
348
+ */
349
+ safe(fn) {
350
+ try {
351
+ return fn();
352
+ } catch {
353
+ return void 0;
354
+ }
355
+ }
305
356
  /**
306
357
  * Queue an SDK error to be reported to the server's system errors page.
307
358
  * Fire-and-forget — never throws. If reporting itself fails, logs to console only.
@@ -310,8 +361,16 @@ var MonitorClient = class {
310
361
  reportError(category, message, err) {
311
362
  if (this.sdkErrorsInCurrentWindow >= 20) return;
312
363
  this.sdkErrorsInCurrentWindow++;
313
- const errorMessage = err instanceof Error ? `${message}: ${err.message}` : message;
314
- const stack = err instanceof Error ? err.stack : void 0;
364
+ let errorMessage = message;
365
+ let stack;
366
+ if (err instanceof Error) {
367
+ errorMessage = this.safe(() => `${message}: ${err.message}`) ?? message;
368
+ const causeDescription = this.safe(() => this.describeErrorCause(err));
369
+ if (causeDescription) {
370
+ errorMessage += ` (cause: ${causeDescription})`;
371
+ }
372
+ stack = this.safe(() => err.stack);
373
+ }
315
374
  this.sdkErrorQueue.push({ category, message: errorMessage, stack });
316
375
  if (this.sdkErrorQueue.length >= 20) {
317
376
  this.flushSdkErrors().catch(() => {
@@ -357,11 +416,14 @@ var MonitorClient = class {
357
416
  console.warn("[MonitorClient] Failed to flush SDK errors to server:", err instanceof Error ? err.message : String(err));
358
417
  }
359
418
  }
360
- stopAuditIntervalTimer() {
419
+ clearAuditIntervalTimer() {
361
420
  if (this.auditIntervalTimer) {
362
421
  clearInterval(this.auditIntervalTimer);
363
422
  this.auditIntervalTimer = null;
364
423
  }
424
+ }
425
+ stopAuditIntervalTimer() {
426
+ this.clearAuditIntervalTimer();
365
427
  if (this.settingsPollingTimer) {
366
428
  clearInterval(this.settingsPollingTimer);
367
429
  this.settingsPollingTimer = null;
@@ -411,15 +473,11 @@ var MonitorClient = class {
411
473
  const intervalHours = settings.vulnerabilityScanIntervalHours;
412
474
  if (intervalHours <= 0) {
413
475
  console.log("[MonitorClient] Scheduled vulnerability scanning disabled by server configuration");
476
+ this.currentAuditIntervalHours = intervalHours;
414
477
  } else {
415
478
  console.log(`[MonitorClient] Auto vulnerability scanning enabled (every ${intervalHours} hours)`);
416
479
  await this.runScanAndTrackTime();
417
- const intervalMs = intervalHours * 60 * 60 * 1e3;
418
- this.auditIntervalTimer = setInterval(() => {
419
- this.runScanAndTrackTime().catch((err) => {
420
- this.reportError("AUDIT_SCAN", "Auto audit scan failed", err);
421
- });
422
- }, intervalMs);
480
+ this.setAuditInterval(intervalHours);
423
481
  }
424
482
  console.log("[MonitorClient] Polling for scan requests enabled (every 5 minutes)");
425
483
  this.settingsPollingTimer = setInterval(() => {
@@ -428,6 +486,24 @@ var MonitorClient = class {
428
486
  });
429
487
  }, CONFIG_LIMITS.SETTINGS_POLL_INTERVAL_MS);
430
488
  }
489
+ /**
490
+ * (Re)configure the recurring audit timer for the given interval, replacing
491
+ * whichever timer is currently running (if any). intervalHours <= 0 leaves
492
+ * scanning disabled.
493
+ */
494
+ setAuditInterval(intervalHours) {
495
+ this.clearAuditIntervalTimer();
496
+ this.currentAuditIntervalHours = intervalHours;
497
+ if (intervalHours <= 0) {
498
+ return;
499
+ }
500
+ const intervalMs = intervalHours * 60 * 60 * 1e3;
501
+ this.auditIntervalTimer = setInterval(() => {
502
+ this.runScanAndTrackTime().catch((err) => {
503
+ this.reportError("AUDIT_SCAN", "Auto audit scan failed", err);
504
+ });
505
+ }, intervalMs);
506
+ }
431
507
  /**
432
508
  * Run a vulnerability scan and track the time it was run.
433
509
  * Uses auditMultiplePaths() if auditPaths is configured, otherwise runs single audit.
@@ -471,6 +547,12 @@ var MonitorClient = class {
471
547
  await this.syncDependencies();
472
548
  }
473
549
  }
550
+ if (settings.vulnerabilityScanIntervalHours !== this.currentAuditIntervalHours) {
551
+ console.log(
552
+ `[MonitorClient] Auto scan interval changed (${this.currentAuditIntervalHours ?? "unset"} \u2192 ${settings.vulnerabilityScanIntervalHours} hours), reconfiguring`
553
+ );
554
+ this.setAuditInterval(settings.vulnerabilityScanIntervalHours);
555
+ }
474
556
  } catch (err) {
475
557
  this.reportError("SETTINGS_POLL", "Failed to check for scan request", err);
476
558
  }
@@ -591,9 +673,11 @@ var MonitorClient = class {
591
673
  async syncDependencies() {
592
674
  console.log("[MonitorClient] Starting technology sync...");
593
675
  try {
676
+ const sources = await this.loadDependencySources();
677
+ const timeoutMs = this.calculateSyncTimeoutMs(sources);
594
678
  await this.withTimeout(
595
- (signal) => this.performDependencySync(signal),
596
- CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
679
+ (signal) => this.performDependencySync(sources, signal),
680
+ timeoutMs,
597
681
  "Technology sync timed out"
598
682
  );
599
683
  console.log("[MonitorClient] Technology sync completed successfully");
@@ -601,25 +685,100 @@ var MonitorClient = class {
601
685
  this.reportError("DEPENDENCY_SYNC", "Technology sync failed", err);
602
686
  }
603
687
  }
604
- async performDependencySync(signal) {
688
+ /**
689
+ * How many sequential registry batches a version-fetch over this many packages
690
+ * will take, given the shared concurrency limit. Shared by fetchLatestVersions
691
+ * (which does the batching) and calculateSyncTimeoutMs (which budgets for it),
692
+ * so the two can't drift out of sync with each other.
693
+ */
694
+ registryBatchCount(itemCount) {
695
+ return Math.ceil(itemCount / CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT);
696
+ }
697
+ /**
698
+ * Estimate a timeout budget that scales with how many roughly-equal-cost units of
699
+ * sequential work will run (registry batches, audit paths, ...), clamped to a
700
+ * floor/ceiling. Shared by calculateSyncTimeoutMs and calculateMultiPathAuditTimeoutMs
701
+ * so the two scaling formulas can't drift apart. At count=0 this always collapses to
702
+ * floorMs, since overheadMs alone never exceeds a floor sized for real work.
703
+ */
704
+ estimateScaledTimeoutMs(count, perItemMs, overheadMs, floorMs, ceilingMs) {
705
+ const estimatedMs = count * perItemMs + overheadMs;
706
+ return Math.min(ceilingMs, Math.max(floorMs, estimatedMs));
707
+ }
708
+ /**
709
+ * Scale the dependency sync timeout with how many registry lookups AND how many
710
+ * sequential POSTs (one per source, via sendTechnologiesWithEnvironment) it will
711
+ * make, so large dependency lists, many dependencySources, or a large requestTimeoutMs
712
+ * don't spuriously time out against a fixed budget.
713
+ *
714
+ * The POST-phase term (sources.length * requestTimeoutMs) is included even when
715
+ * version checking is off: performDependencySync still does one sequential POST per
716
+ * source regardless, so a flat overhead that ignores source count would under-budget
717
+ * that phase exactly the way a flat registry-batch estimate would under-budget large
718
+ * dependency lists.
719
+ *
720
+ * Registry batches are summed PER SOURCE (not over the combined total) because
721
+ * performDependencySync/fetchLatestVersions batch each source independently and
722
+ * sequentially — ceil(a/n) + ceil(b/n) can be larger than ceil((a+b)/n), so a
723
+ * single batch count over the combined total would under-estimate the real
724
+ * number of sequential registry round-trips when many small sources are configured.
725
+ */
726
+ calculateSyncTimeoutMs(sources) {
727
+ const postPhaseMs = sources.length * this.requestTimeoutMs;
728
+ const batches = this.versionCheckEnabled ? sources.reduce((sum, source) => sum + this.registryBatchCount(source.technologies.length), 0) : 0;
729
+ return this.estimateScaledTimeoutMs(
730
+ batches,
731
+ this.registryTimeoutMs,
732
+ CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS + postPhaseMs,
733
+ CONFIG_LIMITS.SYNC_DEPENDENCIES_TIMEOUT_MS,
734
+ CONFIG_LIMITS.MAX_SYNC_DEPENDENCIES_TIMEOUT_MS
735
+ );
736
+ }
737
+ /**
738
+ * Reads happen before syncDependencies' timeout/AbortSignal exists (see comment
739
+ * there), so this phase bounds itself with a wall-clock deadline on a budget that
740
+ * scales with how many sources are configured (mirroring calculateSyncTimeoutMs's
741
+ * scaling principle for the network phase). A plain deadline timestamp — not an
742
+ * AbortController — is enough here: readPackageJsonFromPath does purely synchronous,
743
+ * non-cancelable fs calls, so there is nothing for a signal to actually interrupt;
744
+ * it can only ever be checked between iterations, exactly like a deadline check.
745
+ * Once the budget is spent, stop reading further sources rather than always paying
746
+ * the full read cost for every configured dependencySource regardless of how long
747
+ * it's already taken — and report it via reportError, not just a console.warn, so a
748
+ * truncated sync isn't silently indistinguishable from a fully successful one on
749
+ * the dashboard.
750
+ */
751
+ async loadDependencySources() {
605
752
  if (this.dependencySources && this.dependencySources.length > 0) {
753
+ const sources = [];
754
+ const readBudgetMs = CONFIG_LIMITS.SYNC_FIXED_OVERHEAD_MS * this.dependencySources.length;
755
+ const deadline = Date.now() + readBudgetMs;
606
756
  for (const source of this.dependencySources) {
607
- if (signal?.aborted) {
608
- console.log("[MonitorClient] Technology sync cancelled");
609
- return;
757
+ if (Date.now() >= deadline) {
758
+ const skippedCount = this.dependencySources.length - sources.length;
759
+ const warning = `Dependency source reads exceeded ${readBudgetMs}ms, skipping remaining ${skippedCount} source(s)`;
760
+ console.warn(`[MonitorClient] ${warning}`);
761
+ this.reportError("DEPENDENCY_SYNC", warning);
762
+ break;
610
763
  }
611
- const technologies = await this.readPackageJsonFromPath(source.path);
612
- if (technologies.length === 0) continue;
613
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
614
- await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
615
- console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
764
+ const technologies2 = await this.readPackageJsonFromPath(source.path);
765
+ sources.push({ environment: source.environment, technologies: technologies2 });
616
766
  }
617
- } else {
618
- const technologies = await this.readPackageJson();
619
- if (technologies.length === 0) return;
620
- const enrichedTechnologies = await this.enrichWithLatestVersions(technologies, signal);
621
- await this.sendTechnologies(enrichedTechnologies);
622
- console.log(`[MonitorClient] Technology sync completed for ${this.environment}`);
767
+ return sources;
768
+ }
769
+ const technologies = await this.readPackageJson();
770
+ return [{ environment: this.environment, technologies }];
771
+ }
772
+ async performDependencySync(sources, signal) {
773
+ for (const source of sources) {
774
+ if (signal?.aborted) {
775
+ console.log("[MonitorClient] Technology sync cancelled");
776
+ return;
777
+ }
778
+ if (source.technologies.length === 0) continue;
779
+ const enrichedTechnologies = await this.enrichWithLatestVersions(source.technologies, signal);
780
+ await this.sendTechnologiesWithEnvironment(enrichedTechnologies, source.environment);
781
+ console.log(`[MonitorClient] Technology sync completed for ${source.environment}`);
623
782
  }
624
783
  }
625
784
  /**
@@ -733,13 +892,22 @@ var MonitorClient = class {
733
892
  try {
734
893
  if (signal?.aborted) return null;
735
894
  const encodedName = encodeURIComponent(packageName).replace("%40", "@");
736
- const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
737
- headers: { "Accept": "application/json" },
738
- signal: signal ?? AbortSignal.timeout(this.registryTimeoutMs)
739
- });
740
- if (!response.ok) return null;
741
- const data = await response.json();
742
- return data["dist-tags"]?.latest || null;
895
+ const requestController = new AbortController();
896
+ const timeoutId = setTimeout(() => requestController.abort(), this.registryTimeoutMs);
897
+ const onOuterAbort = () => requestController.abort();
898
+ signal?.addEventListener("abort", onOuterAbort);
899
+ try {
900
+ const response = await fetch(`${this.npmRegistryUrl}/${encodedName}`, {
901
+ headers: { "Accept": "application/json" },
902
+ signal: requestController.signal
903
+ });
904
+ if (!response.ok) return null;
905
+ const data = await response.json();
906
+ return data["dist-tags"]?.latest || null;
907
+ } finally {
908
+ clearTimeout(timeoutId);
909
+ signal?.removeEventListener("abort", onOuterAbort);
910
+ }
743
911
  } catch {
744
912
  return null;
745
913
  }
@@ -752,7 +920,7 @@ var MonitorClient = class {
752
920
  async fetchLatestVersions(packageNames, signal) {
753
921
  const results = /* @__PURE__ */ new Map();
754
922
  const concurrencyLimit = CONFIG_LIMITS.REGISTRY_CONCURRENCY_LIMIT;
755
- const totalBatches = Math.ceil(packageNames.length / concurrencyLimit);
923
+ const totalBatches = this.registryBatchCount(packageNames.length);
756
924
  for (let i = 0; i < packageNames.length; i += concurrencyLimit) {
757
925
  if (signal?.aborted) {
758
926
  console.log("[MonitorClient] Version fetch cancelled");
@@ -923,7 +1091,7 @@ var MonitorClient = class {
923
1091
  "child_process"
924
1092
  );
925
1093
  const { spawn } = childProcess;
926
- return new Promise((resolve) => {
1094
+ return new Promise((resolve, reject) => {
927
1095
  const proc = spawn(command, args, {
928
1096
  cwd: options.cwd,
929
1097
  stdio: ["pipe", "pipe", "pipe"],
@@ -932,10 +1100,8 @@ var MonitorClient = class {
932
1100
  let stdout = "";
933
1101
  let stderr = "";
934
1102
  let timedOut = false;
935
- let killed = false;
936
1103
  const timeoutId = setTimeout(() => {
937
1104
  timedOut = true;
938
- killed = true;
939
1105
  proc.kill("SIGTERM");
940
1106
  setTimeout(() => {
941
1107
  if (!proc.killed) {
@@ -955,14 +1121,38 @@ var MonitorClient = class {
955
1121
  });
956
1122
  proc.on("close", () => {
957
1123
  clearTimeout(timeoutId);
958
- resolve({ stdout, timedOut });
1124
+ resolve({ stdout, stderr, timedOut });
959
1125
  });
960
- proc.on("error", () => {
1126
+ proc.on("error", (err) => {
961
1127
  clearTimeout(timeoutId);
962
- resolve({ stdout, timedOut: killed });
1128
+ reject(err);
963
1129
  });
964
1130
  });
965
1131
  }
1132
+ /**
1133
+ * `npm audit --json` can exit without emitting valid JSON on stdout — e.g. the
1134
+ * registry is unreachable, or a fatal npm error is written to stderr instead.
1135
+ * Treat that as a soft failure (like a timeout) rather than throwing, so it
1136
+ * doesn't surface as a confusing "Unexpected end of JSON input" SDK_ERROR.
1137
+ */
1138
+ parseNpmAuditJson(auditOutput, stderr) {
1139
+ const stderrSuffix = () => {
1140
+ const trimmedStderr = stderr.trim();
1141
+ return trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : "";
1142
+ };
1143
+ if (!auditOutput || auditOutput.trim().length === 0) {
1144
+ console.error(`[MonitorClient] npm audit produced no output${stderrSuffix()}`);
1145
+ return null;
1146
+ }
1147
+ try {
1148
+ return JSON.parse(auditOutput);
1149
+ } catch (err) {
1150
+ console.error(
1151
+ `[MonitorClient] npm audit produced invalid JSON: ${err instanceof Error ? err.message : String(err)}${stderrSuffix()}`
1152
+ );
1153
+ return null;
1154
+ }
1155
+ }
966
1156
  /**
967
1157
  * Run npm audit and send results to the monitoring server.
968
1158
  * This scans the project for known vulnerabilities in dependencies.
@@ -1039,6 +1229,13 @@ var MonitorClient = class {
1039
1229
  return null;
1040
1230
  }
1041
1231
  auditOutput = result2.stdout;
1232
+ if (!auditOutput || auditOutput.trim().length === 0) {
1233
+ const trimmedStderr = result2.stderr.trim();
1234
+ console.error(
1235
+ `[MonitorClient] yarn audit produced no output${trimmedStderr ? ` (stderr: ${this.truncateDescription(trimmedStderr)})` : ""}`
1236
+ );
1237
+ return null;
1238
+ }
1042
1239
  vulnerabilities = this.parseYarnAuditOutput(auditOutput);
1043
1240
  const lines = auditOutput.trim().split("\n");
1044
1241
  for (const line of lines) {
@@ -1051,22 +1248,10 @@ var MonitorClient = class {
1051
1248
  } catch {
1052
1249
  }
1053
1250
  }
1054
- } else if (packageManager === "pnpm") {
1055
- console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1056
- const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1057
- cwd: projectPath,
1058
- timeout: this.auditTimeoutMs,
1059
- maxBuffer: CONFIG_LIMITS.AUDIT_MAX_BUFFER
1060
- });
1061
- if (result2.timedOut) {
1062
- console.error(`[MonitorClient] npm audit timed out after ${this.auditTimeoutMs}ms`);
1063
- return null;
1064
- }
1065
- auditOutput = result2.stdout;
1066
- const auditData = JSON.parse(auditOutput);
1067
- vulnerabilities = this.parseNpmAuditOutput(auditData);
1068
- totalDeps = auditData.metadata?.dependencies?.total || 0;
1069
1251
  } else {
1252
+ if (packageManager === "pnpm") {
1253
+ console.log("[MonitorClient] pnpm detected, using npm audit (pnpm compatible)");
1254
+ }
1070
1255
  const result2 = await this.runCommandWithTimeout("npm", ["audit", "--json"], {
1071
1256
  cwd: projectPath,
1072
1257
  timeout: this.auditTimeoutMs,
@@ -1077,7 +1262,8 @@ var MonitorClient = class {
1077
1262
  return null;
1078
1263
  }
1079
1264
  auditOutput = result2.stdout;
1080
- const auditData = JSON.parse(auditOutput);
1265
+ const auditData = this.parseNpmAuditJson(auditOutput, result2.stderr);
1266
+ if (!auditData) return null;
1081
1267
  vulnerabilities = this.parseNpmAuditOutput(auditData);
1082
1268
  totalDeps = auditData.metadata?.dependencies?.total || 0;
1083
1269
  }
@@ -1122,9 +1308,10 @@ var MonitorClient = class {
1122
1308
  }
1123
1309
  console.log(`[MonitorClient] Starting multi-path audit (${this.auditPaths.length} paths)...`);
1124
1310
  try {
1311
+ const timeoutMs = this.calculateMultiPathAuditTimeoutMs(this.auditPaths.length);
1125
1312
  const result = await this.withTimeout(
1126
1313
  () => this.performMultiPathAudit(),
1127
- CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1314
+ timeoutMs,
1128
1315
  "Multi-path audit timed out"
1129
1316
  );
1130
1317
  if (result) {
@@ -1136,6 +1323,21 @@ var MonitorClient = class {
1136
1323
  return null;
1137
1324
  }
1138
1325
  }
1326
+ /**
1327
+ * Scale the multi-path audit timeout with how many paths it will scan, so
1328
+ * configuring many auditPaths (or a large per-path auditTimeoutMs) doesn't
1329
+ * spuriously time out the whole batch against a fixed budget — the same
1330
+ * failure mode calculateSyncTimeoutMs fixes for dependency sync.
1331
+ */
1332
+ calculateMultiPathAuditTimeoutMs(pathCount) {
1333
+ return this.estimateScaledTimeoutMs(
1334
+ pathCount,
1335
+ this.auditTimeoutMs,
1336
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_FIXED_OVERHEAD_MS,
1337
+ CONFIG_LIMITS.AUDIT_MULTI_PATH_TIMEOUT_MS,
1338
+ CONFIG_LIMITS.MAX_AUDIT_MULTI_PATH_TIMEOUT_MS
1339
+ );
1340
+ }
1139
1341
  async performMultiPathAudit() {
1140
1342
  if (!this.auditPaths) return null;
1141
1343
  const results = [];
@@ -1558,7 +1760,7 @@ var MonitorClient = class {
1558
1760
  }
1559
1761
  try {
1560
1762
  const hostname = os.hostname();
1561
- const cpuPercent = await this.measureCpuPercent(os);
1763
+ const { percent: cpuPercent, model: cpuModel } = await this.measureCpuPercent(os);
1562
1764
  const memoryTotal = os.totalmem();
1563
1765
  const memoryAvailable = await this.getAvailableMemoryBytes(os, fs);
1564
1766
  const memoryUsed = memoryTotal - memoryAvailable;
@@ -1567,6 +1769,7 @@ var MonitorClient = class {
1567
1769
  await this.submitSystemMetric({
1568
1770
  hostname,
1569
1771
  cpuPercent,
1772
+ cpuModel,
1570
1773
  memoryTotal,
1571
1774
  memoryUsed,
1572
1775
  memoryPercent,
@@ -1579,12 +1782,14 @@ var MonitorClient = class {
1579
1782
  /**
1580
1783
  * Measure CPU utilization by sampling cpus() twice with a 3s gap.
1581
1784
  * The cumulative counters make this the average utilization over the
1582
- * window, across all cores of the host. Returns a percentage (0–100).
1785
+ * window, across all cores of the host. Returns a percentage (0–100)
1786
+ * plus the CPU model, read off the same samples at zero extra cost.
1583
1787
  */
1584
1788
  async measureCpuPercent(os) {
1585
1789
  const sample1 = os.cpus();
1586
1790
  await new Promise((resolve) => setTimeout(resolve, 3e3));
1587
1791
  const sample2 = os.cpus();
1792
+ const model = sample1[0]?.model ?? null;
1588
1793
  let totalIdle = 0;
1589
1794
  let totalTick = 0;
1590
1795
  for (let i = 0; i < sample2.length; i++) {
@@ -1596,10 +1801,10 @@ var MonitorClient = class {
1596
1801
  totalIdle += curr.times.idle - prev.times.idle;
1597
1802
  }
1598
1803
  if (totalTick <= 0) {
1599
- return 0;
1804
+ return { percent: 0, model };
1600
1805
  }
1601
1806
  const idlePercent = totalIdle / totalTick * 100;
1602
- return Math.max(0, Math.min(100, 100 - idlePercent));
1807
+ return { percent: Math.max(0, Math.min(100, 100 - idlePercent)), model };
1603
1808
  }
1604
1809
  /**
1605
1810
  * Get reclaimable memory in bytes. On Linux, reads MemAvailable from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceon-oy/monitor-sdk",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
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",