@newrelic/preflight 1.0.1 → 1.0.2
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/dashboard/live-event-bus.d.ts.map +1 -1
- package/dist/dashboard/live-event-bus.js.map +1 -1
- package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
- package/dist/dashboard/routes/api-handler.js +7 -8
- package/dist/dashboard/routes/api-handler.js.map +1 -1
- package/dist/dashboard/routes/sse-handler.js +11 -11
- package/dist/dashboard/routes/sse-handler.js.map +1 -1
- package/dist/hooks/event-processor.d.ts +9 -2
- package/dist/hooks/event-processor.d.ts.map +1 -1
- package/dist/hooks/event-processor.js +12 -0
- package/dist/hooks/event-processor.js.map +1 -1
- package/dist/hooks/session-resolver.js +1 -1
- package/dist/hooks/session-resolver.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -153
- package/dist/index.js.map +1 -1
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +5 -1
- package/dist/install/cli.js.map +1 -1
- package/dist/install/schedule.d.ts +7 -0
- package/dist/install/schedule.d.ts.map +1 -1
- package/dist/install/schedule.js +75 -0
- package/dist/install/schedule.js.map +1 -1
- package/dist/install/setup-wizard.d.ts.map +1 -1
- package/dist/install/setup-wizard.js +29 -1
- package/dist/install/setup-wizard.js.map +1 -1
- package/dist/metrics/live-session-registry.js +4 -4
- package/dist/metrics/live-session-registry.js.map +1 -1
- package/dist/metrics/session-tracker.d.ts +2 -0
- package/dist/metrics/session-tracker.d.ts.map +1 -1
- package/dist/metrics/session-tracker.js +9 -2
- package/dist/metrics/session-tracker.js.map +1 -1
- package/dist/storage/local-store.js +1 -1
- package/dist/storage/local-store.js.map +1 -1
- package/dist/transport/nr-ingest.js +1 -1
- package/dist/transport/nr-ingest.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -53,7 +53,7 @@ import { parseLocalAlertRules } from './alerts/local-alert-rule.js';
|
|
|
53
53
|
import { localDateKey, todayPortionOfSessionCost } from './lib/date.js';
|
|
54
54
|
import { FeedbackCollector } from './tools/workflow-tools.js';
|
|
55
55
|
import { registerTools, registerPendingTools } from './tools/session-stats.js';
|
|
56
|
-
import { resolveSessionId, resolveFromJobDir, resolveFromBreadcrumb, } from './hooks/session-resolver.js';
|
|
56
|
+
import { resolveSessionId, resolveFromJobDir, resolveFromBreadcrumb, isSyntheticSessionId, } from './hooks/session-resolver.js';
|
|
57
57
|
import { initMcpTracer } from './tracing/mcp-tracer.js';
|
|
58
58
|
import { SessionSpan } from './tracing/session-span.js';
|
|
59
59
|
import { TaskSpanTracker } from './tracing/task-span-tracker.js';
|
|
@@ -97,7 +97,7 @@ export function classifyDashboardStartError(err, host, port) {
|
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
99
|
* Default interval (ms) between dashboard re-bind attempts when this MCP
|
|
100
|
-
* started in headless mode (
|
|
100
|
+
* started in headless mode (EADDRINUSE skip). Overridable via
|
|
101
101
|
* NR_AI_DASHBOARD_REPOLL_MS — kept simple to avoid threading a new config
|
|
102
102
|
* field through the loader for what is essentially a knob for tests.
|
|
103
103
|
*/
|
|
@@ -114,10 +114,10 @@ export function getDashboardRepollIntervalMs() {
|
|
|
114
114
|
export function setupDashboardPostBind(addr, deps) {
|
|
115
115
|
const log = createLogger('mcp-cli');
|
|
116
116
|
log.info(`Dashboard ready at http://${addr.address}:${addr.port}`);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
117
|
+
// Only the dashboard owner runs orphan-buffer/breadcrumb GC — running it
|
|
118
|
+
// from every MCP would race with itself and re-archive files repeatedly.
|
|
119
|
+
// Run once at startup, then every 5 minutes. The interval is unref'd so
|
|
120
|
+
// it doesn't keep the event loop alive.
|
|
121
121
|
const { localStore, liveSessionRegistry } = deps;
|
|
122
122
|
const runGc = () => {
|
|
123
123
|
try {
|
|
@@ -370,15 +370,21 @@ async function main() {
|
|
|
370
370
|
let alertRulesWatchTimer;
|
|
371
371
|
let localStoreForShutdown;
|
|
372
372
|
let gcInterval;
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
373
|
+
// When this MCP starts headless (EADDRINUSE skip), this interval retries
|
|
374
|
+
// dashboardServer.start() periodically so we can take over if the current
|
|
375
|
+
// owner exits. Cleared in the shutdown handler.
|
|
376
376
|
let dashboardRepollInterval;
|
|
377
|
+
// Aborts the async resolveSessionId polling loop when shutdown fires so
|
|
378
|
+
// the breadcrumb poll does not outlive the process.
|
|
379
|
+
let sessionResolutionAbort;
|
|
377
380
|
let shuttingDown = false;
|
|
378
381
|
const shutdown = async () => {
|
|
379
382
|
if (shuttingDown)
|
|
380
383
|
return;
|
|
381
384
|
shuttingDown = true;
|
|
385
|
+
// Abort any in-progress session resolution so its polling loop exits
|
|
386
|
+
// cleanly rather than continuing after process.exit() is called.
|
|
387
|
+
sessionResolutionAbort?.abort();
|
|
382
388
|
logger.info('Shutting down...');
|
|
383
389
|
try {
|
|
384
390
|
persistSession?.();
|
|
@@ -394,8 +400,8 @@ async function main() {
|
|
|
394
400
|
clearInterval(gcInterval);
|
|
395
401
|
if (dashboardRepollInterval)
|
|
396
402
|
clearInterval(dashboardRepollInterval);
|
|
397
|
-
//
|
|
398
|
-
//
|
|
403
|
+
// Remove this MCP's heartbeat so the next dashboard-owner GC pass
|
|
404
|
+
// doesn't have to mtime-archive our buffer file.
|
|
399
405
|
localStoreForShutdown?.removeHeartbeat();
|
|
400
406
|
if (alertRulesWatchTimer)
|
|
401
407
|
clearTimeout(alertRulesWatchTimer);
|
|
@@ -463,24 +469,6 @@ async function main() {
|
|
|
463
469
|
await mcpServer.close();
|
|
464
470
|
process.exit(0);
|
|
465
471
|
}
|
|
466
|
-
// Fix 3 / D2: resolve the Claude Code session_id BEFORE constructing
|
|
467
|
-
// anything that takes sessionTraceId as input. We try the cheap
|
|
468
|
-
// synchronous paths first (CLAUDE_JOB_DIR, then a one-shot breadcrumb
|
|
469
|
-
// probe). If both miss, register a "pending" tool handler so the MCP
|
|
470
|
-
// can answer health/config requests while we poll for the breadcrumb,
|
|
471
|
-
// then await full resolution.
|
|
472
|
-
const configFilePathEarly = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
|
|
473
|
-
const configSummaryEarly = {
|
|
474
|
-
mode: config.mode,
|
|
475
|
-
developer: config.developer,
|
|
476
|
-
accountId: config.accountId ?? null,
|
|
477
|
-
licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
|
|
478
|
-
nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
|
|
479
|
-
region: config.collectorHost ?? 'us',
|
|
480
|
-
storagePath: config.storagePath,
|
|
481
|
-
dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
|
|
482
|
-
configFilePath: configFilePathEarly,
|
|
483
|
-
};
|
|
484
472
|
const synchronouslyResolved = resolveFromJobDir(process.env.CLAUDE_JOB_DIR ?? null) ??
|
|
485
473
|
resolveFromBreadcrumb(config.storagePath, process.ppid);
|
|
486
474
|
if (synchronouslyResolved) {
|
|
@@ -488,31 +476,25 @@ async function main() {
|
|
|
488
476
|
logger.info('Session ID resolved synchronously', { sessionTraceId });
|
|
489
477
|
}
|
|
490
478
|
else {
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
developer: config.developer,
|
|
497
|
-
configSummary: configSummaryEarly,
|
|
498
|
-
});
|
|
499
|
-
logger.info('Awaiting session_id resolution (breadcrumb poll)');
|
|
500
|
-
try {
|
|
501
|
-
sessionTraceId = await resolveSessionId({ storagePath: config.storagePath });
|
|
502
|
-
}
|
|
503
|
-
catch (err) {
|
|
504
|
-
logger.error('Session ID resolution failed; shutting down', { error: String(err) });
|
|
505
|
-
await shutdown();
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
479
|
+
// Use a provisional ID so the shared section (including dashboard) can
|
|
480
|
+
// start immediately. The real session ID is resolved asynchronously in
|
|
481
|
+
// the tail section below after all shared infrastructure is ready.
|
|
482
|
+
sessionTraceId = `pending-${Date.now()}`;
|
|
483
|
+
logger.info('Session ID not yet available; using provisional ID, dashboard will start early');
|
|
508
484
|
}
|
|
485
|
+
// Create the span objects in Phase A so shutdown always has a valid
|
|
486
|
+
// sessionSpan reference. For the provisional case (pending-{ts}), defer
|
|
487
|
+
// start() to Phase B when the real session ID is known — starting here
|
|
488
|
+
// would emit a ghost span with a placeholder ID to the OTLP backend.
|
|
489
|
+
// SessionSpan.end() guards on started=false, so an unstarted provisional
|
|
490
|
+
// span is a safe no-op on shutdown.
|
|
509
491
|
if (config.transport !== 'nr-events-api') {
|
|
510
492
|
initMcpTracer();
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
493
|
+
sessionSpan = new SessionSpan(sessionTraceId, config.developer);
|
|
494
|
+
taskSpanTracker = new TaskSpanTracker();
|
|
495
|
+
if (!sessionTraceId.startsWith('pending-')) {
|
|
496
|
+
sessionSpan.start();
|
|
497
|
+
}
|
|
516
498
|
}
|
|
517
499
|
}
|
|
518
500
|
else {
|
|
@@ -530,17 +512,18 @@ async function main() {
|
|
|
530
512
|
}
|
|
531
513
|
// Per-session buffer scoping: in --stdio mode the LocalStore is bound to
|
|
532
514
|
// this MCP's resolved session_id so drainBuffer() only sees this session's
|
|
533
|
-
// events. In --local mode
|
|
534
|
-
//
|
|
535
|
-
const
|
|
515
|
+
// events. In --local mode (or the provisional window before session ID
|
|
516
|
+
// resolution) we use an unscoped store that drains all session buffers.
|
|
517
|
+
const isProvisional = options.stdio && sessionTraceId.startsWith('pending-');
|
|
518
|
+
const localStore = options.stdio && !isProvisional
|
|
536
519
|
? new LocalStore(config.storagePath, sessionTraceId)
|
|
537
520
|
: new LocalStore(config.storagePath);
|
|
538
521
|
localStore.initialize();
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
if (options.stdio)
|
|
522
|
+
// Every MCP writes its heartbeat once it has bound a session_id so the
|
|
523
|
+
// dashboard owner's GC pass can tell which buffer files still have a live
|
|
524
|
+
// owner. Removed in the shutdown handler below. Skipped during the
|
|
525
|
+
// provisional window — the real heartbeat is written after resolution.
|
|
526
|
+
if (options.stdio && !isProvisional)
|
|
544
527
|
localStore.writeHeartbeat();
|
|
545
528
|
localStoreForShutdown = localStore;
|
|
546
529
|
// Migrate any pre-Fix-3 events from the legacy shared `buffer.jsonl` into
|
|
@@ -868,8 +851,8 @@ async function main() {
|
|
|
868
851
|
contextTracker,
|
|
869
852
|
config,
|
|
870
853
|
configFilePath: options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json'),
|
|
871
|
-
//
|
|
872
|
-
//
|
|
854
|
+
// The dashboard owner reads every per-session buffer file in
|
|
855
|
+
// read-only mode for the Today aggregate endpoint.
|
|
873
856
|
// peekAllBuffers() returns HookEvent[] — widen at the boundary
|
|
874
857
|
// so the dashboard tree stays decoupled from storage internals.
|
|
875
858
|
localStore: {
|
|
@@ -894,21 +877,18 @@ async function main() {
|
|
|
894
877
|
if (decision.kind === 'rethrow') {
|
|
895
878
|
throw decision.error;
|
|
896
879
|
}
|
|
897
|
-
// In --local mode
|
|
898
|
-
//
|
|
899
|
-
// the
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
`Stop the existing --local instance before starting another.`);
|
|
903
|
-
process.exit(1);
|
|
904
|
-
}
|
|
880
|
+
// In --local mode (e.g. a launchd daemon) EADDRINUSE means the port is
|
|
881
|
+
// owned by a --stdio MCP instance. Instead of exiting fatally, poll
|
|
882
|
+
// until the port is free and take over — same as the --stdio repoll
|
|
883
|
+
// path. This lets the daemon coexist with active Claude Code sessions
|
|
884
|
+
// and seamlessly reclaim the dashboard when sessions end.
|
|
905
885
|
logger.info(decision.message);
|
|
906
886
|
addr = undefined;
|
|
907
887
|
}
|
|
908
888
|
// Capture deps for the post-bind helper. Both the initial-bind path
|
|
909
889
|
// and the re-poll takeover path call this; keeping the closure small
|
|
910
890
|
// ensures the two paths produce identical side effects (GC interval,
|
|
911
|
-
// openOnStart warning, etc.
|
|
891
|
+
// openOnStart warning, etc.).
|
|
912
892
|
const postBindDeps = {
|
|
913
893
|
localStore,
|
|
914
894
|
liveSessionRegistry,
|
|
@@ -919,9 +899,9 @@ async function main() {
|
|
|
919
899
|
gcInterval = runPostBind(addr);
|
|
920
900
|
}
|
|
921
901
|
else {
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
902
|
+
// This MCP is headless. Schedule periodic re-bind attempts so it can
|
|
903
|
+
// take over if the current dashboard owner exits. The interval is
|
|
904
|
+
// unref'd and cleared by the shutdown handler.
|
|
925
905
|
dashboardRepollInterval = startDashboardRepoll({
|
|
926
906
|
dashboardServer,
|
|
927
907
|
host: config.dashboard.host,
|
|
@@ -932,10 +912,18 @@ async function main() {
|
|
|
932
912
|
},
|
|
933
913
|
logger,
|
|
934
914
|
});
|
|
915
|
+
// In --local mode the dashboard IS the process — the HTTP listener is
|
|
916
|
+
// the only thing that keeps the event loop alive. When EADDRINUSE fires
|
|
917
|
+
// the listener is never bound, so the repoll interval must be ref'd or
|
|
918
|
+
// Node exits immediately before it ever fires. In --stdio mode stdin
|
|
919
|
+
// acts as the keepalive, so leaving the interval unref'd is correct.
|
|
920
|
+
if (options.local) {
|
|
921
|
+
dashboardRepollInterval.ref?.();
|
|
922
|
+
}
|
|
935
923
|
}
|
|
936
924
|
}
|
|
937
925
|
let capturedNrIngest;
|
|
938
|
-
if (config.mode !== 'local') {
|
|
926
|
+
if (config.mode !== 'local' && !isProvisional) {
|
|
939
927
|
if (!config.licenseKey || !config.accountId) {
|
|
940
928
|
throw new Error('licenseKey and accountId must be defined. ' +
|
|
941
929
|
'This should have been caught by config validation. ' +
|
|
@@ -996,9 +984,11 @@ async function main() {
|
|
|
996
984
|
});
|
|
997
985
|
eventProcessor = new HookEventProcessor({
|
|
998
986
|
store: localStore,
|
|
999
|
-
// --local mode
|
|
1000
|
-
// per-session buffer so the dashboard sees all
|
|
1001
|
-
|
|
987
|
+
// --local mode and the provisional --stdio window own no specific Claude
|
|
988
|
+
// Code session; drain every per-session buffer so the dashboard sees all
|
|
989
|
+
// live sessions' events. After real session ID resolution the processor
|
|
990
|
+
// is hot-swapped to the scoped store via replaceStore().
|
|
991
|
+
drainAllSessions: !options.stdio || isProvisional,
|
|
1002
992
|
onRecord: (record) => {
|
|
1003
993
|
if (!config || !sessionTracker || !taskDetector) {
|
|
1004
994
|
logger.warn('onRecord called before full initialization; skipping');
|
|
@@ -1042,12 +1032,12 @@ async function main() {
|
|
|
1042
1032
|
// returned AuditRecord rather than recording a second time.
|
|
1043
1033
|
const auditRecord = auditTrail.recordToolCall(record);
|
|
1044
1034
|
capturedNrIngest?.ingestToolCall(record, auditRecord);
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1035
|
+
// SSE consumers filter by sessionId for the per-session live tail.
|
|
1036
|
+
// Records without a sessionId are legacy buffer entries that surfaced
|
|
1037
|
+
// during the migrateLegacyBuffer() window on first boot — skip the
|
|
1038
|
+
// live emit rather than fabricate a session by falling back to the
|
|
1039
|
+
// MCP's resolved sessionTraceId, which would re-introduce the
|
|
1040
|
+
// fictional-session-ID bug the session-ID resolver removed.
|
|
1051
1041
|
if (record.sessionId) {
|
|
1052
1042
|
liveBus.emit('tool-call', {
|
|
1053
1043
|
id: record.id,
|
|
@@ -1093,8 +1083,8 @@ async function main() {
|
|
|
1093
1083
|
dailyFirstActivityMs,
|
|
1094
1084
|
});
|
|
1095
1085
|
liveBus.emit('cost-update', {
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1086
|
+
// sessionId is always the resolved Claude Code session_id for
|
|
1087
|
+
// this MCP instance so cost totals can be attributed per-session.
|
|
1098
1088
|
sessionId: sessionTraceId,
|
|
1099
1089
|
sessionTotalUsd: costMetrics.sessionTotalCostUsd,
|
|
1100
1090
|
todayTotalUsd,
|
|
@@ -1113,9 +1103,9 @@ async function main() {
|
|
|
1113
1103
|
taskSpanTracker.closeTask(task.taskId, task.toolCallCount);
|
|
1114
1104
|
}
|
|
1115
1105
|
const firstRecord = task.toolCalls[0];
|
|
1116
|
-
//
|
|
1106
|
+
// sessionTraceId is the resolved Claude Code session_id and is
|
|
1117
1107
|
// shared across the whole MCP, so we use it directly rather than
|
|
1118
|
-
// peeking at the first record's sessionId (which may
|
|
1108
|
+
// peeking at the first record's sessionId (which may be null).
|
|
1119
1109
|
const context = {
|
|
1120
1110
|
sessionId: sessionTraceId,
|
|
1121
1111
|
platform: typeof firstRecord?.platform === 'string' ? firstRecord.platform : undefined,
|
|
@@ -1126,8 +1116,8 @@ async function main() {
|
|
|
1126
1116
|
for (const pattern of patterns) {
|
|
1127
1117
|
capturedNrIngest?.ingestAntiPattern(pattern, context);
|
|
1128
1118
|
liveBus.emit('anti-pattern', {
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1119
|
+
// Tag with the originating session so the Today view can render
|
|
1120
|
+
// a "Session: <name>" pill on each alert row.
|
|
1131
1121
|
sessionId: sessionTraceId,
|
|
1132
1122
|
type: pattern.type,
|
|
1133
1123
|
target: pattern.file ?? pattern.command ?? 'unknown',
|
|
@@ -1205,8 +1195,8 @@ async function main() {
|
|
|
1205
1195
|
dailyFirstActivityMs,
|
|
1206
1196
|
});
|
|
1207
1197
|
liveBus.emit('cost-update', {
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1198
|
+
// Same as the per-tool-call cost-update emission — tag with the
|
|
1199
|
+
// MCP's owning session_id for per-session attribution.
|
|
1210
1200
|
sessionId: sessionTraceId,
|
|
1211
1201
|
sessionTotalUsd: costMetrics.sessionTotalCostUsd,
|
|
1212
1202
|
todayTotalUsd,
|
|
@@ -1235,7 +1225,7 @@ async function main() {
|
|
|
1235
1225
|
// bookkeeping; they don't correspond to a real Claude Code session
|
|
1236
1226
|
// and produce confusing `local-...` rows in the dashboard's history
|
|
1237
1227
|
// view that have no useful content to show.
|
|
1238
|
-
const isSyntheticId =
|
|
1228
|
+
const isSyntheticId = isSyntheticSessionId(summary.sessionId);
|
|
1239
1229
|
if (isSyntheticId) {
|
|
1240
1230
|
logger.info('Skipping synthetic session JSON persistence', {
|
|
1241
1231
|
sessionId: summary.sessionId,
|
|
@@ -1257,66 +1247,227 @@ async function main() {
|
|
|
1257
1247
|
// Same instance is shared with the DashboardServer and NrIngestManager so all
|
|
1258
1248
|
// three see the same audit log.
|
|
1259
1249
|
mcpServer.auditTrailManager = auditTrail;
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1250
|
+
if (isProvisional) {
|
|
1251
|
+
// Dashboard is already live. Register pending tools so the MCP can
|
|
1252
|
+
// respond to health/config requests while the real session ID resolves.
|
|
1253
|
+
const pendingConfigFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
|
|
1254
|
+
registerPendingTools(mcpServer.server, {
|
|
1255
|
+
sessionStartMs: Date.now(),
|
|
1256
|
+
developer: config.developer,
|
|
1257
|
+
configSummary: {
|
|
1258
|
+
mode: config.mode,
|
|
1259
|
+
developer: config.developer,
|
|
1260
|
+
accountId: config.accountId ?? null,
|
|
1261
|
+
licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
|
|
1262
|
+
nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
|
|
1263
|
+
region: config.collectorHost ?? 'us',
|
|
1264
|
+
storagePath: config.storagePath,
|
|
1265
|
+
dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
|
|
1266
|
+
configFilePath: pendingConfigFilePath,
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
logger.info('Dashboard started early; awaiting session_id resolution (breadcrumb poll)');
|
|
1270
|
+
sessionResolutionAbort = new AbortController();
|
|
1271
|
+
void (async () => {
|
|
1272
|
+
try {
|
|
1273
|
+
const realId = await resolveSessionId({
|
|
1274
|
+
storagePath: config.storagePath,
|
|
1275
|
+
signal: sessionResolutionAbort.signal,
|
|
1276
|
+
});
|
|
1277
|
+
// Adopt the real session ID without clearing accumulated metrics.
|
|
1278
|
+
sessionTraceId = realId;
|
|
1279
|
+
sessionTracker.adoptSessionId(realId);
|
|
1280
|
+
// Replace the provisional unscoped LocalStore with the session-scoped one.
|
|
1281
|
+
const realLocalStore = new LocalStore(config.storagePath, realId);
|
|
1282
|
+
realLocalStore.initialize();
|
|
1283
|
+
realLocalStore.writeHeartbeat();
|
|
1284
|
+
localStoreForShutdown = realLocalStore;
|
|
1285
|
+
try {
|
|
1286
|
+
realLocalStore.migrateLegacyBuffer();
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
logger.warn('Legacy buffer migration failed (continuing)', { error: String(err) });
|
|
1290
|
+
}
|
|
1291
|
+
// Hot-swap the event processor to the scoped store so it only
|
|
1292
|
+
// drains this session's events going forward.
|
|
1293
|
+
eventProcessor.replaceStore(realLocalStore, false);
|
|
1294
|
+
// Replace the provisional span with a real-ID span. End the
|
|
1295
|
+
// provisional one first (end() is a no-op if never started).
|
|
1296
|
+
// initMcpTracer() was already called in Phase A — skip it here.
|
|
1297
|
+
if (config.transport !== 'nr-events-api') {
|
|
1298
|
+
sessionSpan?.end(0, 0);
|
|
1299
|
+
// Close any task spans opened against the provisional tracker
|
|
1300
|
+
// (cross-session events can open them during Phase A) before
|
|
1301
|
+
// replacing it with a clean real-session instance.
|
|
1302
|
+
taskSpanTracker?.closeAll();
|
|
1303
|
+
sessionSpan = new SessionSpan(realId, config.developer);
|
|
1304
|
+
taskSpanTracker = new TaskSpanTracker();
|
|
1305
|
+
sessionSpan.start();
|
|
1306
|
+
}
|
|
1307
|
+
// Complete NrIngest setup.
|
|
1308
|
+
if (config.mode !== 'local') {
|
|
1309
|
+
if (!config.licenseKey || !config.accountId) {
|
|
1310
|
+
throw new Error('licenseKey and accountId must be defined for non-local mode. ' +
|
|
1311
|
+
'This should have been caught by config validation.');
|
|
1312
|
+
}
|
|
1313
|
+
nrIngest = new NrIngestManager({
|
|
1314
|
+
licenseKey: config.licenseKey,
|
|
1315
|
+
transportOptions: {
|
|
1316
|
+
accountId: config.accountId,
|
|
1317
|
+
collectorHost: config.collectorHost,
|
|
1318
|
+
},
|
|
1319
|
+
developer: config.developer,
|
|
1320
|
+
appName: config.appName,
|
|
1321
|
+
teamId: config.teamId,
|
|
1322
|
+
projectId: config.projectId,
|
|
1323
|
+
orgId: config.orgId,
|
|
1324
|
+
sessionTracker: sessionTracker,
|
|
1325
|
+
localStore: realLocalStore,
|
|
1326
|
+
auditTrail,
|
|
1327
|
+
eventHarvestIntervalMs: config.harvestIntervalMs.events,
|
|
1328
|
+
metricHarvestIntervalMs: config.harvestIntervalMs.metrics,
|
|
1329
|
+
costTracker,
|
|
1330
|
+
efficiencyScorer,
|
|
1331
|
+
turnCostAttributor,
|
|
1332
|
+
sessionTraceId: realId,
|
|
1333
|
+
});
|
|
1334
|
+
capturedNrIngest = nrIngest;
|
|
1335
|
+
nrIngest.start();
|
|
1336
|
+
}
|
|
1337
|
+
// Register full tools, replacing the pending handlers.
|
|
1338
|
+
const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
|
|
1339
|
+
const configSummary = {
|
|
1340
|
+
mode: config.mode,
|
|
1341
|
+
developer: config.developer,
|
|
1342
|
+
accountId: config.accountId ?? null,
|
|
1343
|
+
licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
|
|
1344
|
+
nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
|
|
1345
|
+
region: config.collectorHost ?? 'us',
|
|
1346
|
+
storagePath: config.storagePath,
|
|
1347
|
+
dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
|
|
1348
|
+
configFilePath,
|
|
1349
|
+
};
|
|
1350
|
+
registerTools(mcpServer.server, {
|
|
1351
|
+
sessionTracker: sessionTracker,
|
|
1352
|
+
costTracker,
|
|
1353
|
+
budgetTracker,
|
|
1354
|
+
taskDetector: taskDetector,
|
|
1355
|
+
antiPatternDetector,
|
|
1356
|
+
efficiencyScorer,
|
|
1357
|
+
feedbackCollector,
|
|
1358
|
+
sessionStore,
|
|
1359
|
+
weeklySummaryGenerator,
|
|
1360
|
+
trendAnalyzer,
|
|
1361
|
+
collaborationProfiler,
|
|
1362
|
+
claudeMdTracker,
|
|
1363
|
+
costPerOutcomeAnalyzer,
|
|
1364
|
+
recommendationEngine,
|
|
1365
|
+
contextWindowTracker,
|
|
1366
|
+
contextTracker,
|
|
1367
|
+
latencyTracker,
|
|
1368
|
+
taskCompletionTracker,
|
|
1369
|
+
modelUsageTracker,
|
|
1370
|
+
retryDetector,
|
|
1371
|
+
contextCompositionTracker,
|
|
1372
|
+
latencyDecompositionTracker,
|
|
1373
|
+
decisionTracker,
|
|
1374
|
+
instructionDriftTracker,
|
|
1375
|
+
toolSelectionScorer,
|
|
1376
|
+
toolCallBuffer: toolCallBufferAccessor,
|
|
1377
|
+
qualityProxyTracker,
|
|
1378
|
+
apiFailureTracker,
|
|
1379
|
+
turnCostAttributor,
|
|
1380
|
+
turnTracker,
|
|
1381
|
+
gitEfficiencyTracker,
|
|
1382
|
+
sessionTraceId: realId,
|
|
1383
|
+
sessionStartMs,
|
|
1384
|
+
accountId: config.accountId,
|
|
1385
|
+
teamId: config.teamId,
|
|
1386
|
+
projectId: config.projectId,
|
|
1387
|
+
developer: config.developer,
|
|
1388
|
+
nrApiKey: config.nrApiKey,
|
|
1389
|
+
collectorHost: config.collectorHost,
|
|
1390
|
+
configFilePath,
|
|
1391
|
+
configSummary,
|
|
1392
|
+
});
|
|
1393
|
+
logger.info('Session ID resolved, full initialization complete', {
|
|
1394
|
+
sessionTraceId: realId,
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
// Use the signal's own aborted flag rather than matching the error
|
|
1399
|
+
// message string — robust against future changes to the throw site.
|
|
1400
|
+
if (sessionResolutionAbort?.signal.aborted) {
|
|
1401
|
+
logger.info('Session ID resolution aborted by shutdown');
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
logger.error('Session ID resolution failed; shutting down', { error: String(err) });
|
|
1405
|
+
await shutdown();
|
|
1406
|
+
}
|
|
1407
|
+
})();
|
|
1408
|
+
}
|
|
1409
|
+
else {
|
|
1410
|
+
// Session ID resolved synchronously — proceed as normal.
|
|
1411
|
+
const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
|
|
1412
|
+
const configSummary = {
|
|
1413
|
+
mode: config.mode,
|
|
1414
|
+
developer: config.developer,
|
|
1415
|
+
accountId: config.accountId ?? null,
|
|
1416
|
+
licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
|
|
1417
|
+
nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
|
|
1418
|
+
region: config.collectorHost ?? 'us',
|
|
1419
|
+
storagePath: config.storagePath,
|
|
1420
|
+
dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
|
|
1421
|
+
configFilePath,
|
|
1422
|
+
};
|
|
1423
|
+
registerTools(mcpServer.server, {
|
|
1424
|
+
sessionTracker,
|
|
1425
|
+
costTracker,
|
|
1426
|
+
budgetTracker,
|
|
1427
|
+
taskDetector,
|
|
1428
|
+
antiPatternDetector,
|
|
1429
|
+
efficiencyScorer,
|
|
1430
|
+
feedbackCollector,
|
|
1431
|
+
sessionStore,
|
|
1432
|
+
weeklySummaryGenerator,
|
|
1433
|
+
trendAnalyzer,
|
|
1434
|
+
collaborationProfiler,
|
|
1435
|
+
claudeMdTracker,
|
|
1436
|
+
costPerOutcomeAnalyzer,
|
|
1437
|
+
recommendationEngine,
|
|
1438
|
+
contextWindowTracker,
|
|
1439
|
+
contextTracker,
|
|
1440
|
+
latencyTracker,
|
|
1441
|
+
taskCompletionTracker,
|
|
1442
|
+
modelUsageTracker,
|
|
1443
|
+
retryDetector,
|
|
1444
|
+
contextCompositionTracker,
|
|
1445
|
+
latencyDecompositionTracker,
|
|
1446
|
+
decisionTracker,
|
|
1447
|
+
instructionDriftTracker,
|
|
1448
|
+
toolSelectionScorer,
|
|
1449
|
+
toolCallBuffer: toolCallBufferAccessor,
|
|
1450
|
+
qualityProxyTracker,
|
|
1451
|
+
apiFailureTracker,
|
|
1452
|
+
turnCostAttributor,
|
|
1453
|
+
turnTracker,
|
|
1454
|
+
gitEfficiencyTracker,
|
|
1455
|
+
sessionTraceId,
|
|
1456
|
+
sessionStartMs,
|
|
1457
|
+
accountId: config.accountId,
|
|
1458
|
+
teamId: config.teamId,
|
|
1459
|
+
projectId: config.projectId,
|
|
1460
|
+
developer: config.developer,
|
|
1461
|
+
nrApiKey: config.nrApiKey,
|
|
1462
|
+
collectorHost: config.collectorHost,
|
|
1463
|
+
configFilePath,
|
|
1464
|
+
configSummary,
|
|
1465
|
+
});
|
|
1466
|
+
nrIngest?.start();
|
|
1467
|
+
logger.info('Server running on stdio transport');
|
|
1468
|
+
// stdin 'end' and 'error' handlers are registered immediately after
|
|
1469
|
+
// connectStdio() above so shutdown fires even during session-ID resolution.
|
|
1470
|
+
}
|
|
1320
1471
|
}
|
|
1321
1472
|
else {
|
|
1322
1473
|
logger.info('Server running in local dashboard mode (Ctrl+C to stop)');
|
|
@@ -1338,7 +1489,7 @@ async function main() {
|
|
|
1338
1489
|
}
|
|
1339
1490
|
// Proxy mode has no Claude Code session to resolve; use a deterministic
|
|
1340
1491
|
// identifier instead of randomUUID so we don't fabricate something that
|
|
1341
|
-
// looks like a real session id
|
|
1492
|
+
// looks like a real session id.
|
|
1342
1493
|
const sessionTraceId = `proxy-${Date.now()}`;
|
|
1343
1494
|
proxyManager = new ProxyManager({
|
|
1344
1495
|
port: config.port,
|