@newrelic/preflight 1.18.1 → 1.18.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.
Files changed (58) hide show
  1. package/dist/config.d.ts +12 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +43 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/dashboard/dashboard-server.d.ts +8 -0
  6. package/dist/dashboard/dashboard-server.d.ts.map +1 -1
  7. package/dist/dashboard/dashboard-server.js +33 -0
  8. package/dist/dashboard/dashboard-server.js.map +1 -1
  9. package/dist/dashboard/routes/api-handler.d.ts +4 -0
  10. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  11. package/dist/dashboard/routes/api-handler.js +5 -0
  12. package/dist/dashboard/routes/api-handler.js.map +1 -1
  13. package/dist/homelab/homelab-accumulator.d.ts +21 -0
  14. package/dist/homelab/homelab-accumulator.d.ts.map +1 -0
  15. package/dist/homelab/homelab-accumulator.js +139 -0
  16. package/dist/homelab/homelab-accumulator.js.map +1 -0
  17. package/dist/homelab/homelab-forwarder.d.ts +24 -0
  18. package/dist/homelab/homelab-forwarder.d.ts.map +1 -0
  19. package/dist/homelab/homelab-forwarder.js +96 -0
  20. package/dist/homelab/homelab-forwarder.js.map +1 -0
  21. package/dist/homelab/index.d.ts +5 -0
  22. package/dist/homelab/index.d.ts.map +1 -0
  23. package/dist/homelab/index.js +3 -0
  24. package/dist/homelab/index.js.map +1 -0
  25. package/dist/hooks/collector-script.d.ts.map +1 -1
  26. package/dist/hooks/collector-script.js +29 -0
  27. package/dist/hooks/collector-script.js.map +1 -1
  28. package/dist/hooks/event-processor.d.ts +18 -0
  29. package/dist/hooks/event-processor.d.ts.map +1 -1
  30. package/dist/hooks/event-processor.js +28 -0
  31. package/dist/hooks/event-processor.js.map +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +143 -8
  34. package/dist/index.js.map +1 -1
  35. package/dist/install/install-helper.d.ts +5 -0
  36. package/dist/install/install-helper.d.ts.map +1 -1
  37. package/dist/install/install-helper.js +16 -3
  38. package/dist/install/install-helper.js.map +1 -1
  39. package/dist/metrics/api-failure-tracker.d.ts +29 -0
  40. package/dist/metrics/api-failure-tracker.d.ts.map +1 -1
  41. package/dist/metrics/api-failure-tracker.js +56 -3
  42. package/dist/metrics/api-failure-tracker.js.map +1 -1
  43. package/dist/security/ssrf.d.ts +15 -2
  44. package/dist/security/ssrf.d.ts.map +1 -1
  45. package/dist/security/ssrf.js +25 -7
  46. package/dist/security/ssrf.js.map +1 -1
  47. package/dist/storage/types.d.ts +17 -1
  48. package/dist/storage/types.d.ts.map +1 -1
  49. package/dist/tools/extended-analytics-tools.js +1 -1
  50. package/dist/tools/extended-analytics-tools.js.map +1 -1
  51. package/dist/transport/nr-ingest.d.ts +4 -0
  52. package/dist/transport/nr-ingest.d.ts.map +1 -1
  53. package/dist/transport/nr-ingest.js +7 -1
  54. package/dist/transport/nr-ingest.js.map +1 -1
  55. package/dist/web/assets/index-BBZyx02c.js +64 -0
  56. package/dist/web/index.html +1 -1
  57. package/package.json +1 -1
  58. package/dist/web/assets/index-CC_l0esP.js +0 -64
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import 'dotenv/config';
3
3
  import { Command, Option } from 'commander';
4
4
  import { readFileSync, realpathSync } from 'node:fs';
5
+ import { timingSafeEqual } from 'node:crypto';
5
6
  import { resolve } from 'node:path';
6
7
  import { AlertLog } from './alerts/alert-log.js';
7
8
  import { AlertSnapshotCollector } from './alerts/alert-snapshot-collector.js';
@@ -24,7 +25,7 @@ import { migrateStoragePath } from './install/migrate.js';
24
25
  import { checkNodeVersion } from './install/node-version-check.js';
25
26
  import { localDateKey, todayPortionOfSessionCost } from './lib/date.js';
26
27
  import { AntiPatternDetector } from './metrics/anti-patterns.js';
27
- import { ApiFailureTracker } from './metrics/api-failure-tracker.js';
28
+ import { ApiFailureTracker, mapClaudeCodeErrorType } from './metrics/api-failure-tracker.js';
28
29
  import { BudgetTracker } from './metrics/budget-tracker.js';
29
30
  import { ClaudeMdTracker } from './metrics/claudemd-tracker.js';
30
31
  import { CollaborationProfiler } from './metrics/collaboration-profile.js';
@@ -75,6 +76,7 @@ import { SessionSpan } from './tracing/session-span.js';
75
76
  import { TaskSpanTracker } from './tracing/task-span-tracker.js';
76
77
  import { emitToolCallSpan } from './tracing/tool-call-span.js';
77
78
  import { NrIngestManager } from './transport/nr-ingest.js';
79
+ import { HomelabAccumulator, HomelabForwarder } from './homelab/index.js';
78
80
  import { VERSION } from './version.js';
79
81
  export { loadMcpConfig, redactSensitive } from './config.js';
80
82
  export { AmazonQAdapter, ClaudeCodeAdapter, ContinueAdapter, CopilotAdapter, createDefaultRegistry, CursorAdapter, GenericMcpAdapter, parseCopilotUsageResponse, PlatformRegistry, REPORT_SESSION_END_TOOL, REPORT_SESSION_START_TOOL, REPORT_TOOL_CALL_TOOL, validateReportToolCallInput, WindsurfAdapter, ZedAdapter, } from './platforms/index.js';
@@ -324,6 +326,7 @@ export function startDashboardRepoll(opts) {
324
326
  const SUBCOMMAND_NAMES = [
325
327
  'deploy-dashboards',
326
328
  'deploy-alerts',
329
+ 'server',
327
330
  'install',
328
331
  'uninstall',
329
332
  'setup',
@@ -335,8 +338,8 @@ const SUBCOMMAND_NAMES = [
335
338
  ];
336
339
  // Install-CLI subcommands — a subset of SUBCOMMAND_NAMES routed to runInstallCli.
337
340
  // Derived from SUBCOMMAND_NAMES to ensure a single source of truth: deploy-*
338
- // commands are excluded and handled by the commander path in dispatchSubcommand.
339
- const INSTALL_CLI_SUBCOMMANDS = SUBCOMMAND_NAMES.filter((s) => s !== 'deploy-dashboards' && s !== 'deploy-alerts');
341
+ // and server commands are excluded and handled by the commander path in dispatchSubcommand.
342
+ const INSTALL_CLI_SUBCOMMANDS = SUBCOMMAND_NAMES.filter((s) => s !== 'deploy-dashboards' && s !== 'deploy-alerts' && s !== 'server');
340
343
  function isSubcommand(value) {
341
344
  return typeof value === 'string' && SUBCOMMAND_NAMES.includes(value);
342
345
  }
@@ -392,6 +395,76 @@ export async function dispatchSubcommand(argv) {
392
395
  process.exitCode = code;
393
396
  });
394
397
  }
398
+ else if (sub === 'server') {
399
+ program
400
+ .command('server')
401
+ .description('Start Preflight in homelab server mode — receives events forwarded from remote clients and accumulates them to disk (no dashboard yet — see docs/homelab.md)')
402
+ .option('--port <port>', 'Port to bind to (default: 7777 or NEW_RELIC_AI_HOMELAB_SERVER_PORT)')
403
+ .option('--token <token>', 'Bearer token for /ingest auth (default: NEW_RELIC_AI_HOMELAB_TOKEN)')
404
+ .option('--bind <address>', 'Bind address (default: 0.0.0.0 or NEW_RELIC_AI_HOMELAB_BIND_ADDRESS)')
405
+ .option('--config <path>', 'Path to config file')
406
+ .action(async (opts) => {
407
+ if (opts.port)
408
+ process.env.NEW_RELIC_AI_HOMELAB_SERVER_PORT = opts.port;
409
+ if (opts.token)
410
+ process.env.NEW_RELIC_AI_HOMELAB_TOKEN = opts.token;
411
+ if (opts.bind)
412
+ process.env.NEW_RELIC_AI_HOMELAB_BIND_ADDRESS = opts.bind;
413
+ // server mode never needs cloud credentials — force local so loadMcpConfig
414
+ // doesn't require licenseKey/accountId.
415
+ process.env.NR_AI_MODE = 'local';
416
+ const config = loadConfigOrDie({ config: opts.config });
417
+ const token = config.homelabToken;
418
+ if (!token) {
419
+ process.stderr.write('[preflight] error: homelab server requires a token. Set --token or NEW_RELIC_AI_HOMELAB_TOKEN.\n');
420
+ process.exit(1);
421
+ }
422
+ const accumulator = new HomelabAccumulator({ storagePath: config.storagePath });
423
+ accumulator.start();
424
+ const bus = new LiveEventBus();
425
+ const dashboardServer = new DashboardServer({
426
+ port: config.homelabServer.port,
427
+ host: config.homelabServer.bindAddress,
428
+ bus,
429
+ serverMode: true,
430
+ ingestHandler: (authHeader, body) => {
431
+ const expected = `Bearer ${token}`;
432
+ const isAuthed = authHeader !== undefined &&
433
+ authHeader.length === expected.length &&
434
+ timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected));
435
+ if (!isAuthed)
436
+ return 401;
437
+ const result = accumulator.handleIngest(body);
438
+ return result === 'ok' ? 204 : 400;
439
+ },
440
+ });
441
+ await dashboardServer.start();
442
+ logger.info('Homelab server ready', {
443
+ port: config.homelabServer.port,
444
+ bind: config.homelabServer.bindAddress,
445
+ });
446
+ // Keep the action (and therefore program.parseAsync) alive until a
447
+ // signal fires — without this, the action returns immediately and
448
+ // dispatchSubcommand exits the process.
449
+ await new Promise((resolve) => {
450
+ let shutting = false;
451
+ const shutdown = async () => {
452
+ if (shutting)
453
+ return;
454
+ shutting = true;
455
+ accumulator.stop();
456
+ await dashboardServer.stop();
457
+ resolve();
458
+ };
459
+ process.on('SIGTERM', () => {
460
+ void shutdown();
461
+ });
462
+ process.on('SIGINT', () => {
463
+ void shutdown();
464
+ });
465
+ });
466
+ });
467
+ }
395
468
  else {
396
469
  program
397
470
  .command('deploy-alerts')
@@ -582,6 +655,7 @@ async function main() {
582
655
  }, getPendingConfirmationCapMs());
583
656
  pendingConfirmationCapTimer.unref?.();
584
657
  };
658
+ let homelabForwarder = null;
585
659
  let shuttingDown = false;
586
660
  const shutdown = async () => {
587
661
  if (shuttingDown)
@@ -652,6 +726,7 @@ async function main() {
652
726
  nrIngest ? nrIngest.stop() : Promise.resolve(),
653
727
  mcpServer ? mcpServer.close() : Promise.resolve(),
654
728
  proxyManager ? proxyManager.stop() : Promise.resolve(),
729
+ homelabForwarder ? homelabForwarder.stop() : Promise.resolve(),
655
730
  ]);
656
731
  for (const r of stopResults) {
657
732
  if (r.status === 'rejected') {
@@ -818,11 +893,12 @@ async function main() {
818
893
  });
819
894
  const toolSelectionScorer = new ToolSelectionScorer();
820
895
  const qualityProxyTracker = new QualityProxyTracker();
821
- // ApiFailureTracker is instantiated but never fed: recordRequest()/recordFailure()
822
- // require visibility into model-API-level traffic (LLM provider rate limits,
823
- // timeouts, auth errors), which is not observable in either stdio mode (hooks
824
- // only see Claude Code's own tool calls) or proxy mode (which forwards to MCP
825
- // servers, not the model API). Kept dormant for a future LLM-facing proxy.
896
+ // Fed via Claude Code's StopFailure hook (see the onApiFailure callback on
897
+ // eventProcessor below) through recordFailure(). recordRequest() still has
898
+ // no caller request-level latency/throughput stays unobservable and
899
+ // recordFailure()'s tokensInFlight/recoveryMs/retryCount/totalRequests
900
+ // inputs stay at their "unknown" defaults for the same reason. See
901
+ // api-failure-tracker.ts's API_FAILURE_PARTIAL_DATA_NOTE for the full story.
826
902
  const apiFailureTracker = new ApiFailureTracker();
827
903
  liveSessionRegistry = new LiveSessionRegistry();
828
904
  liveSessionRegistry.startSampling();
@@ -1350,6 +1426,7 @@ async function main() {
1350
1426
  },
1351
1427
  antiPatternDetector,
1352
1428
  retryDetector,
1429
+ apiFailureTracker,
1353
1430
  instructionDriftTracker,
1354
1431
  decisionTracker,
1355
1432
  turnCostAttributor,
@@ -1541,11 +1618,34 @@ async function main() {
1541
1618
  costTracker,
1542
1619
  efficiencyScorer,
1543
1620
  feedbackCollector,
1621
+ apiFailureTracker,
1544
1622
  turnCostAttributor,
1545
1623
  sessionTraceId,
1546
1624
  });
1547
1625
  capturedNrIngest = nrIngest;
1548
1626
  }
1627
+ // Start homelab forwarder if configured. Construction validates
1628
+ // homelabServerUrl (scheme + cloud-metadata-endpoint check) and can
1629
+ // throw — caught here so a bad/malicious URL only disables forwarding,
1630
+ // matching the "forwarding failures are non-fatal" contract in
1631
+ // docs/homelab.md, rather than crashing MCP startup.
1632
+ if (config.homelabServerUrl && config.homelabToken) {
1633
+ try {
1634
+ homelabForwarder = new HomelabForwarder({
1635
+ serverUrl: config.homelabServerUrl,
1636
+ token: config.homelabToken,
1637
+ developer: config.developer,
1638
+ sessionId: sessionTraceId ?? 'unknown',
1639
+ });
1640
+ homelabForwarder.start();
1641
+ }
1642
+ catch (err) {
1643
+ homelabForwarder = null;
1644
+ logger.error('Homelab forwarder failed to start — continuing without it', {
1645
+ error: String(err),
1646
+ });
1647
+ }
1648
+ }
1549
1649
  const capturedAlertEngine = alertEngine;
1550
1650
  const capturedAlertSnapshotCollector = alertSnapshotCollector;
1551
1651
  budgetTracker.setOnThreshold((event) => {
@@ -1774,6 +1874,7 @@ async function main() {
1774
1874
  });
1775
1875
  }
1776
1876
  }
1877
+ homelabForwarder?.enqueue(record);
1777
1878
  },
1778
1879
  onTokenEvent: (tokenEvent) => {
1779
1880
  if (!costTracker || !config)
@@ -1939,6 +2040,31 @@ async function main() {
1939
2040
  : {}),
1940
2041
  });
1941
2042
  },
2043
+ onApiFailure: (frame) => {
2044
+ if (!config)
2045
+ return;
2046
+ const errorType = mapClaudeCodeErrorType(frame.rawErrorType);
2047
+ // Prefer a model already learned from real token events over the
2048
+ // config default, mirroring the estimateModel fallback above.
2049
+ const model = costTracker.getMetrics().model ?? config.model ?? 'unknown';
2050
+ const turnNumber = turnTracker.getCurrentTurnNumber();
2051
+ // totalTurnsInSession is left unspecified: there is no reliable
2052
+ // "total turns for this session" figure available at failure time
2053
+ // (the session may still be ongoing), and recordFailure() already
2054
+ // defaults it sensibly for sessionPhase bucketing.
2055
+ const duringToolExecution = eventProcessor !== undefined && eventProcessor.pendingCount > 0;
2056
+ apiFailureTracker.recordFailure({
2057
+ errorType,
2058
+ model,
2059
+ turnNumber,
2060
+ // Unobservable from StopFailure; see api-failure-tracker.ts's note constant.
2061
+ tokensInFlight: 0,
2062
+ // Known, not a placeholder: StopFailure only fires after Claude
2063
+ // Code's own retries are exhausted with no successful recovery.
2064
+ recoverySucceeded: false,
2065
+ duringToolExecution,
2066
+ });
2067
+ },
1942
2068
  });
1943
2069
  persistSession = (opts) => {
1944
2070
  if (!sessionStore || !sessionTracker || !taskDetector || !config)
@@ -2305,6 +2431,7 @@ async function main() {
2305
2431
  costTracker,
2306
2432
  efficiencyScorer,
2307
2433
  feedbackCollector,
2434
+ apiFailureTracker,
2308
2435
  turnCostAttributor,
2309
2436
  sessionTraceId: realId,
2310
2437
  });
@@ -2606,6 +2733,13 @@ async function main() {
2606
2733
  // proxied traffic instead of silently living in memory only.
2607
2734
  const proxyLocalStore = new LocalStore(config.storagePath);
2608
2735
  proxyLocalStore.initialize();
2736
+ // Proxy mode never constructs a HookEventProcessor, so nothing ever
2737
+ // calls recordFailure() on this instance (StopFailure is a Claude
2738
+ // Code-side hook, not something proxied traffic can surface) — passed
2739
+ // in anyway so NrIngestManager's emitMetrics() call is uniform across
2740
+ // modes, legitimately reporting zero failures rather than being asked
2741
+ // to handle a missing tracker.
2742
+ const apiFailureTracker = new ApiFailureTracker();
2609
2743
  nrIngest = new NrIngestManager({
2610
2744
  licenseKey: config.licenseKey,
2611
2745
  transportOptions: {
@@ -2621,6 +2755,7 @@ async function main() {
2621
2755
  localStore: proxyLocalStore,
2622
2756
  eventHarvestIntervalMs: config.harvestIntervalMs.events,
2623
2757
  metricHarvestIntervalMs: config.harvestIntervalMs.metrics,
2758
+ apiFailureTracker,
2624
2759
  sessionTraceId,
2625
2760
  // Proxy mode has no single coherent session — sessionTracker above is
2626
2761
  // never fed per-client activity, so the ai.session.* gauges would