@stacknext/cli 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collector/server.d.ts +3 -1
- package/dist/collector/server.d.ts.map +1 -1
- package/dist/collector/server.js +385 -15
- package/dist/collector/server.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +87 -19
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +36 -3
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/skill.d.ts +34 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +121 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/snapshot.d.ts +36 -0
- package/dist/commands/snapshot.d.ts.map +1 -0
- package/dist/commands/snapshot.js +286 -0
- package/dist/commands/snapshot.js.map +1 -0
- package/dist/commands/uninstall.d.ts.map +1 -1
- package/dist/commands/uninstall.js +18 -5
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +4 -0
- package/dist/program.js.map +1 -1
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +148 -4
- package/dist/runtime.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -7
- package/skills/stacknext-debugging/SKILL.md +306 -0
- package/skills/stacknext-debugging/references/setup.md +228 -0
- package/skills/stacknext-debugging/references/tools.md +369 -0
- package/skills/stacknext-debugging/references/workflows.md +332 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TelemetryStore, type HealthResponse, type NextOtelVerboseStatus, type StackNextConfig } from '@stacknext/core';
|
|
1
|
+
import { TelemetryStore, type HealthResponse, type NextOtelVerboseStatus, type StackNextConfig, type SnapshotSummary } from '@stacknext/core';
|
|
2
2
|
export interface StartCollectorOptions {
|
|
3
3
|
port: number;
|
|
4
4
|
token: string;
|
|
@@ -21,6 +21,8 @@ export interface CollectorHandle {
|
|
|
21
21
|
token: string;
|
|
22
22
|
store: TelemetryStore;
|
|
23
23
|
health: () => HealthResponse;
|
|
24
|
+
/** Saves the current sample before shutdown when snapshots.autoOnExit is on. */
|
|
25
|
+
saveSnapshotOnExit: () => Promise<SnapshotSummary | null>;
|
|
24
26
|
close: () => Promise<void>;
|
|
25
27
|
}
|
|
26
28
|
export declare function resolveDashboardDir(): Promise<string>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/collector/server.ts"],"names":[],"mappings":"AAOA,OAAO,EAKL,cAAc,EAkBd,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/collector/server.ts"],"names":[],"mappings":"AAOA,OAAO,EAKL,cAAc,EAkBd,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EAepB,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAA;AA8DxB,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,eAAe,CAAA;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oFAAoF;IACpF,eAAe,CAAC,EAAE,qBAAqB,CAAA;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,cAAc,CAAA;IACrB,MAAM,EAAE,MAAM,cAAc,CAAA;IAC5B,gFAAgF;IAChF,kBAAkB,EAAE,MAAM,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAA;IACzD,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAySD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CA2B3D;AAoBD,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CAm6C7F"}
|
package/dist/collector/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { DIAGNOSTIC_CATEGORIES, DIAGNOSTIC_RULE_IDS, DIAGNOSTIC_SEVERITIES, DIAGNOSTIC_STATUSES, TelemetryStore, computeCoverage, diagnosticReference, ingestEnvelopeSchema, nextOtelVerboseStatus, } from '@stacknext/core';
|
|
9
|
+
import { SnapshotStorageError, createSnapshotPayload, createSnapshotView, importSnapshot, listSnapshots, loadSnapshotPayload, removeSnapshot, rotateAutomaticSnapshots, saveSnapshot, snapshotExport, snapshotPath, snapshotSummary, isSnapshotId, } from '@stacknext/core/snapshots';
|
|
9
10
|
import { diagnosticOverview as buildDiagnosticOverview, generateDiagnosticReport, getDiagnosticDetail, listDiagnosticFindings, } from '@stacknext/diagnostics';
|
|
10
11
|
import { completeRunRequestSchema, createRunRequestSchema, listAllBaselines, listAllComparisons, listAllRuns, loadBaseline, loadComparison, loadRun, RegressionStorageError, submitSampleRequestSchema, } from '@stacknext/regression';
|
|
11
12
|
import { TestRunRegistry } from './test-runs.js';
|
|
@@ -19,6 +20,9 @@ const CONNECTION_RETENTION_MS = 10 * 60 * 1_000;
|
|
|
19
20
|
const MAX_SSE_CLIENTS = 50;
|
|
20
21
|
const SSE_HEARTBEAT_MS = 15_000;
|
|
21
22
|
const MAX_SESSIONS = 100;
|
|
23
|
+
const AUTO_ERROR_COOLDOWN_MS = 60_000;
|
|
24
|
+
const MAX_SNAPSHOT_BODY_BYTES = 64 * 1024;
|
|
25
|
+
const MAX_SNAPSHOT_CACHE_ENTRIES = 3;
|
|
22
26
|
function securityHeaders() {
|
|
23
27
|
return {
|
|
24
28
|
'x-content-type-options': 'nosniff',
|
|
@@ -139,6 +143,9 @@ function filtersFromSearch(searchParams) {
|
|
|
139
143
|
...(queryNumber(searchParams.get('minDuration')) !== undefined
|
|
140
144
|
? { minDuration: queryNumber(searchParams.get('minDuration')) }
|
|
141
145
|
: {}),
|
|
146
|
+
...(queryBoolean(searchParams.get('httpOnly')) !== undefined
|
|
147
|
+
? { httpOnly: queryBoolean(searchParams.get('httpOnly')) }
|
|
148
|
+
: {}),
|
|
142
149
|
...(sort === 'recent' || sort === 'oldest' || sort === 'slowest' ? { sort } : {}),
|
|
143
150
|
};
|
|
144
151
|
}
|
|
@@ -254,6 +261,8 @@ function contentTypeFor(file) {
|
|
|
254
261
|
return 'text/css; charset=utf-8';
|
|
255
262
|
case '.json':
|
|
256
263
|
return 'application/json; charset=utf-8';
|
|
264
|
+
case '.webmanifest':
|
|
265
|
+
return 'application/manifest+json';
|
|
257
266
|
case '.svg':
|
|
258
267
|
return 'image/svg+xml';
|
|
259
268
|
case '.png':
|
|
@@ -394,6 +403,12 @@ export async function startCollector(options) {
|
|
|
394
403
|
errors: summary.errorCount,
|
|
395
404
|
databaseQueries: summary.databaseQueryCount,
|
|
396
405
|
},
|
|
406
|
+
sample: {
|
|
407
|
+
lastReceivedAt: summary.lastReceivedAt,
|
|
408
|
+
firstEventTime: summary.firstEventTime,
|
|
409
|
+
lastEventTime: summary.lastEventTime,
|
|
410
|
+
retentionMs: store.retentionMs,
|
|
411
|
+
},
|
|
397
412
|
store: storeStats,
|
|
398
413
|
server: {
|
|
399
414
|
...serverStats,
|
|
@@ -430,6 +445,7 @@ export async function startCollector(options) {
|
|
|
430
445
|
projectName: options.projectName,
|
|
431
446
|
collectorVersion: options.version ?? VERSION,
|
|
432
447
|
config: options.config.diagnostics,
|
|
448
|
+
slowRequestMs: options.config.slowRequestMs,
|
|
433
449
|
generatedAt: now,
|
|
434
450
|
connected: connectionList.some((connection) => connection.connected),
|
|
435
451
|
connectedInstances: connectionList.filter((connection) => connection.connected).length,
|
|
@@ -451,6 +467,298 @@ export async function startCollector(options) {
|
|
|
451
467
|
};
|
|
452
468
|
};
|
|
453
469
|
const getDiagnosticReport = () => getDiagnosticState().report;
|
|
470
|
+
const snapshotCache = new Map();
|
|
471
|
+
let autoSaveQueue = Promise.resolve();
|
|
472
|
+
let lastAutoErrorSnapshotAt = 0;
|
|
473
|
+
const projectDir = options.projectDir;
|
|
474
|
+
const snapshotMaxBytes = options.config.snapshots.maxBytes;
|
|
475
|
+
const snapshotHasData = (payload) => payload.spans.length > 0 ||
|
|
476
|
+
payload.errors.length > 0 ||
|
|
477
|
+
payload.databaseQueries.length > 0;
|
|
478
|
+
const saveCurrentSnapshot = async (source, saveOptions = {}) => {
|
|
479
|
+
if (!projectDir) {
|
|
480
|
+
throw new SnapshotStorageError('invalid', 'Snapshot storage needs a project directory. Start StackNext with "stacknext dev" from the app directory.');
|
|
481
|
+
}
|
|
482
|
+
const payload = createSnapshotPayload({
|
|
483
|
+
store,
|
|
484
|
+
source,
|
|
485
|
+
projectName: options.projectName,
|
|
486
|
+
collectorVersion: options.version ?? VERSION,
|
|
487
|
+
config: options.config,
|
|
488
|
+
slowRequestMs: options.config.slowRequestMs,
|
|
489
|
+
...(saveOptions.label ? { label: saveOptions.label } : {}),
|
|
490
|
+
});
|
|
491
|
+
if (!saveOptions.allowEmpty && !snapshotHasData(payload))
|
|
492
|
+
return null;
|
|
493
|
+
const saved = await saveSnapshot(projectDir, payload, { maxBytes: snapshotMaxBytes });
|
|
494
|
+
if (source === 'exit' || source === 'error') {
|
|
495
|
+
await rotateAutomaticSnapshots(projectDir, options.config.snapshots.autoKeep).catch((error) => {
|
|
496
|
+
log(`could not rotate automatic snapshots: ${error instanceof Error ? error.message : String(error)}`);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return saved;
|
|
500
|
+
};
|
|
501
|
+
const enqueueAutoErrorSnapshot = () => {
|
|
502
|
+
if (!options.config.snapshots.autoOnError)
|
|
503
|
+
return;
|
|
504
|
+
const now = Date.now();
|
|
505
|
+
if (now - lastAutoErrorSnapshotAt < AUTO_ERROR_COOLDOWN_MS)
|
|
506
|
+
return;
|
|
507
|
+
lastAutoErrorSnapshotAt = now;
|
|
508
|
+
autoSaveQueue = autoSaveQueue
|
|
509
|
+
.then(async () => {
|
|
510
|
+
const saved = await saveCurrentSnapshot('error');
|
|
511
|
+
if (saved)
|
|
512
|
+
log(`automatic error snapshot saved (${saved.id})`);
|
|
513
|
+
})
|
|
514
|
+
.catch((error) => {
|
|
515
|
+
log(`automatic error snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
const snapshotView = async (id) => {
|
|
519
|
+
if (!projectDir) {
|
|
520
|
+
throw new SnapshotStorageError('not-found', 'Snapshot storage is not available.');
|
|
521
|
+
}
|
|
522
|
+
if (!isSnapshotId(id)) {
|
|
523
|
+
throw new SnapshotStorageError('invalid', `Invalid snapshot id "${id}".`);
|
|
524
|
+
}
|
|
525
|
+
const file = snapshotPath(projectDir, id);
|
|
526
|
+
let info;
|
|
527
|
+
try {
|
|
528
|
+
info = await stat(file);
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
if (error.code === 'ENOENT') {
|
|
532
|
+
throw new SnapshotStorageError('not-found', 'Snapshot not found.');
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
const cached = snapshotCache.get(id);
|
|
537
|
+
if (cached && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs) {
|
|
538
|
+
snapshotCache.delete(id);
|
|
539
|
+
snapshotCache.set(id, cached);
|
|
540
|
+
return cached;
|
|
541
|
+
}
|
|
542
|
+
const payload = await loadSnapshotPayload(projectDir, id, { maxBytes: snapshotMaxBytes });
|
|
543
|
+
const entry = {
|
|
544
|
+
ctimeMs: info.ctimeMs,
|
|
545
|
+
mtimeMs: info.mtimeMs,
|
|
546
|
+
bytes: info.size,
|
|
547
|
+
payload,
|
|
548
|
+
store: createSnapshotView(payload),
|
|
549
|
+
};
|
|
550
|
+
snapshotCache.set(id, entry);
|
|
551
|
+
while (snapshotCache.size > MAX_SNAPSHOT_CACHE_ENTRIES) {
|
|
552
|
+
const oldest = snapshotCache.keys().next().value;
|
|
553
|
+
if (oldest === undefined)
|
|
554
|
+
break;
|
|
555
|
+
snapshotCache.delete(oldest);
|
|
556
|
+
}
|
|
557
|
+
return entry;
|
|
558
|
+
};
|
|
559
|
+
const sendSnapshotError = (response, error) => {
|
|
560
|
+
if (error instanceof SnapshotStorageError) {
|
|
561
|
+
if (error.code === 'not-found') {
|
|
562
|
+
sendError(response, 404, error.message);
|
|
563
|
+
}
|
|
564
|
+
else if (error.code === 'too-large') {
|
|
565
|
+
sendError(response, 413, error.message);
|
|
566
|
+
}
|
|
567
|
+
else if (error.code === 'exists') {
|
|
568
|
+
sendError(response, 409, error.message);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
sendError(response, 422, error.message);
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
log(`snapshot operation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
576
|
+
sendError(response, 500, 'Snapshot operation failed');
|
|
577
|
+
};
|
|
578
|
+
const handleSnapshotWrite = async (request, response, url) => {
|
|
579
|
+
if (!isReadAuthorized(request)) {
|
|
580
|
+
serverStats.rejectedRequests += 1;
|
|
581
|
+
sendError(response, 401, 'Unauthorized');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (request.method === 'POST' && url.pathname === '/api/snapshots/import') {
|
|
585
|
+
if (!projectDir) {
|
|
586
|
+
sendSnapshotError(response, new SnapshotStorageError('invalid', 'Snapshot storage needs a project directory. Start StackNext with "stacknext dev" from the app directory.'));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const contentType = String(request.headers['content-type'] ?? '');
|
|
590
|
+
if (!contentType.toLowerCase().includes('application/json')) {
|
|
591
|
+
sendError(response, 415, 'Content-Type must be application/json');
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const body = await readBody(request, snapshotMaxBytes);
|
|
595
|
+
if (!body.ok) {
|
|
596
|
+
sendError(response, 413, `Snapshot import exceeds ${snapshotMaxBytes} bytes.`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const snapshot = await importSnapshot(projectDir, body.body, {
|
|
601
|
+
maxBytes: snapshotMaxBytes,
|
|
602
|
+
});
|
|
603
|
+
sendJson(response, 201, { snapshot });
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
sendSnapshotError(response, error);
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (request.method === 'DELETE') {
|
|
611
|
+
const id = decodeURIComponent(url.pathname.replace(/\/$/, '').slice('/api/snapshots/'.length));
|
|
612
|
+
if (!isSnapshotId(id)) {
|
|
613
|
+
sendError(response, 400, 'Invalid snapshot id');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!projectDir) {
|
|
617
|
+
sendError(response, 404, 'Snapshot not found');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const removed = await removeSnapshot(projectDir, id);
|
|
622
|
+
snapshotCache.delete(id);
|
|
623
|
+
if (!removed) {
|
|
624
|
+
sendError(response, 404, 'Snapshot not found');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
sendJson(response, 200, { deleted: true, id });
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
sendSnapshotError(response, error);
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const contentType = String(request.headers['content-type'] ?? '');
|
|
635
|
+
if (!contentType.toLowerCase().includes('application/json')) {
|
|
636
|
+
sendError(response, 415, 'Content-Type must be application/json');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const body = await readBody(request, MAX_SNAPSHOT_BODY_BYTES);
|
|
640
|
+
if (!body.ok) {
|
|
641
|
+
sendError(response, 413, `Snapshot request exceeds ${MAX_SNAPSHOT_BODY_BYTES} bytes`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
let label;
|
|
645
|
+
if (body.body.trim() !== '') {
|
|
646
|
+
let parsed;
|
|
647
|
+
try {
|
|
648
|
+
parsed = JSON.parse(body.body);
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
sendError(response, 400, 'Malformed JSON payload');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (parsed && typeof parsed === 'object' && 'label' in parsed) {
|
|
655
|
+
const value = parsed.label;
|
|
656
|
+
if (value !== undefined && value !== null) {
|
|
657
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.length > 200) {
|
|
658
|
+
sendError(response, 422, 'Snapshot label must be 1-200 characters');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
label = value.trim();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const saved = await saveCurrentSnapshot('manual', {
|
|
667
|
+
...(label ? { label } : {}),
|
|
668
|
+
});
|
|
669
|
+
if (!saved) {
|
|
670
|
+
sendError(response, 409, 'No telemetry in the current sample to save');
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
sendJson(response, 201, { snapshot: saved });
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
sendSnapshotError(response, error);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
const handleSnapshotApi = async (request, response, url, id, suffix) => {
|
|
680
|
+
try {
|
|
681
|
+
if (suffix === '' || suffix === '/') {
|
|
682
|
+
const entry = await snapshotView(id);
|
|
683
|
+
const detail = {
|
|
684
|
+
snapshot: snapshotSummary(entry.payload, entry.bytes),
|
|
685
|
+
config: entry.payload.config,
|
|
686
|
+
limitations: entry.payload.limitations,
|
|
687
|
+
traces: entry.store.listTraces({ limit: 20 }, entry.payload.config.slowRequestMs).items,
|
|
688
|
+
errors: entry.store.listErrors({ limit: 20 }).items,
|
|
689
|
+
databaseQueries: entry.store.listDatabaseQueries({ limit: 20 }).items,
|
|
690
|
+
};
|
|
691
|
+
sendJson(response, 200, detail);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (suffix === '/export') {
|
|
695
|
+
const entry = await snapshotView(id);
|
|
696
|
+
sendJson(response, 200, snapshotExport(entry.payload), {
|
|
697
|
+
'content-disposition': `attachment; filename="stacknext-${entry.payload.projectName.replace(/[^a-z0-9_-]+/gi, '-')}-${id}.json"`,
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (suffix === '/traces') {
|
|
702
|
+
const entry = await snapshotView(id);
|
|
703
|
+
sendJson(response, 200, entry.store.listTraces(filtersFromSearch(url.searchParams), entry.payload.config.slowRequestMs));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (suffix.startsWith('/traces/')) {
|
|
707
|
+
const traceId = decodeURIComponent(suffix.slice('/traces/'.length));
|
|
708
|
+
if (!/^[0-9a-f]{32}$/i.test(traceId)) {
|
|
709
|
+
sendError(response, 400, 'Invalid trace id');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const entry = await snapshotView(id);
|
|
713
|
+
const detail = entry.store.getTrace(traceId, entry.payload.config.slowRequestMs);
|
|
714
|
+
if (!detail) {
|
|
715
|
+
sendError(response, 404, 'Trace not found in snapshot');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
sendJson(response, 200, detail);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (suffix === '/errors') {
|
|
722
|
+
const entry = await snapshotView(id);
|
|
723
|
+
const result = entry.store.listErrors(errorFiltersFromSearch(url.searchParams));
|
|
724
|
+
const payload = {
|
|
725
|
+
items: result.items,
|
|
726
|
+
total: result.total,
|
|
727
|
+
limit: result.limit,
|
|
728
|
+
offset: result.offset,
|
|
729
|
+
groups: result.groups,
|
|
730
|
+
};
|
|
731
|
+
sendJson(response, 200, payload);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (suffix.startsWith('/errors/')) {
|
|
735
|
+
const errorId = decodeURIComponent(suffix.slice('/errors/'.length));
|
|
736
|
+
if (!errorId || errorId.length > 128) {
|
|
737
|
+
sendError(response, 400, 'Invalid error id');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const entry = await snapshotView(id);
|
|
741
|
+
const detail = entry.store.getError(errorId, options.config.slowRequestMs);
|
|
742
|
+
if (!detail) {
|
|
743
|
+
sendError(response, 404, 'Error not found in snapshot');
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const payload = detail;
|
|
747
|
+
sendJson(response, 200, payload);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (suffix === '/database') {
|
|
751
|
+
const entry = await snapshotView(id);
|
|
752
|
+
const result = entry.store.listDatabaseQueries(databaseFiltersFromSearch(url.searchParams));
|
|
753
|
+
sendJson(response, 200, result);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
sendError(response, 404, 'Not found');
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
sendSnapshotError(response, error);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
454
762
|
const testRuns = new TestRunRegistry({
|
|
455
763
|
slowRequestMs: options.config.slowRequestMs,
|
|
456
764
|
isConnected: () => getConnections(Date.now()).some((connection) => connection.connected),
|
|
@@ -681,6 +989,8 @@ export async function startCollector(options) {
|
|
|
681
989
|
time: now,
|
|
682
990
|
});
|
|
683
991
|
}
|
|
992
|
+
if (envelope.errors.length > 0)
|
|
993
|
+
enqueueAutoErrorSnapshot();
|
|
684
994
|
sendJson(response, 202, { ok: true, accepted, serverTime: now }, { 'x-stacknext-sequence': String(sequence) });
|
|
685
995
|
};
|
|
686
996
|
const handleTestRunWrite = async (request, response, url) => {
|
|
@@ -1017,20 +1327,6 @@ export async function startCollector(options) {
|
|
|
1017
1327
|
sendJson(response, 200, store.listRequests(filtersFromSearch(url.searchParams), options.config.slowRequestMs));
|
|
1018
1328
|
return;
|
|
1019
1329
|
}
|
|
1020
|
-
if (pathname.startsWith('/api/requests/')) {
|
|
1021
|
-
const traceId = pathname.slice('/api/requests/'.length);
|
|
1022
|
-
if (!/^[0-9a-f]{32}$/i.test(traceId)) {
|
|
1023
|
-
sendError(response, 400, 'Invalid trace id');
|
|
1024
|
-
return;
|
|
1025
|
-
}
|
|
1026
|
-
const detail = store.getRequest(traceId, options.config.slowRequestMs);
|
|
1027
|
-
if (!detail) {
|
|
1028
|
-
sendError(response, 404, 'Request not found');
|
|
1029
|
-
return;
|
|
1030
|
-
}
|
|
1031
|
-
sendJson(response, 200, traceWithDiagnostics(detail));
|
|
1032
|
-
return;
|
|
1033
|
-
}
|
|
1034
1330
|
if (pathname === '/api/traces') {
|
|
1035
1331
|
const result = store.listTraces(filtersFromSearch(url.searchParams), options.config.slowRequestMs);
|
|
1036
1332
|
sendJson(response, 200, result);
|
|
@@ -1082,6 +1378,45 @@ export async function startCollector(options) {
|
|
|
1082
1378
|
sendJson(response, 200, result);
|
|
1083
1379
|
return;
|
|
1084
1380
|
}
|
|
1381
|
+
if (pathname === '/api/snapshots') {
|
|
1382
|
+
if (!projectDir) {
|
|
1383
|
+
const empty = {
|
|
1384
|
+
items: [],
|
|
1385
|
+
total: 0,
|
|
1386
|
+
limit: 50,
|
|
1387
|
+
offset: 0,
|
|
1388
|
+
invalid: 0,
|
|
1389
|
+
};
|
|
1390
|
+
sendJson(response, 200, empty);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
const page = await listSnapshots(projectDir, {
|
|
1395
|
+
...(queryNumber(url.searchParams.get('limit')) !== undefined
|
|
1396
|
+
? { limit: queryNumber(url.searchParams.get('limit')) }
|
|
1397
|
+
: {}),
|
|
1398
|
+
...(queryNumber(url.searchParams.get('offset')) !== undefined
|
|
1399
|
+
? { offset: queryNumber(url.searchParams.get('offset')) }
|
|
1400
|
+
: {}),
|
|
1401
|
+
});
|
|
1402
|
+
const payload = page;
|
|
1403
|
+
sendJson(response, 200, payload);
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
sendSnapshotError(response, error);
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (pathname.startsWith('/api/snapshots/')) {
|
|
1411
|
+
const match = /^\/api\/snapshots\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
1412
|
+
const id = match?.[1] ? decodeURIComponent(match[1]) : '';
|
|
1413
|
+
if (!isSnapshotId(id)) {
|
|
1414
|
+
sendError(response, 400, 'Invalid snapshot id');
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
await handleSnapshotApi(request, response, url, id, match?.[2] ?? '');
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1085
1420
|
if (pathname === '/api/export') {
|
|
1086
1421
|
const data = store.exportAll();
|
|
1087
1422
|
const payload = {
|
|
@@ -1146,12 +1481,31 @@ export async function startCollector(options) {
|
|
|
1146
1481
|
if (url.pathname.startsWith('/api/')) {
|
|
1147
1482
|
const testRunWrite = url.pathname === '/api/test-runs' ||
|
|
1148
1483
|
url.pathname.startsWith('/api/test-runs/');
|
|
1484
|
+
const snapshotWrite = url.pathname === '/api/snapshots' ||
|
|
1485
|
+
url.pathname.startsWith('/api/snapshots/');
|
|
1149
1486
|
if (request.method === 'POST' && testRunWrite) {
|
|
1150
1487
|
await handleTestRunWrite(request, response, url);
|
|
1151
1488
|
return;
|
|
1152
1489
|
}
|
|
1490
|
+
if (request.method === 'POST' && url.pathname === '/api/snapshots/import') {
|
|
1491
|
+
await handleSnapshotWrite(request, response, url);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (request.method === 'POST' &&
|
|
1495
|
+
(url.pathname === '/api/snapshots' || url.pathname === '/api/snapshots/')) {
|
|
1496
|
+
await handleSnapshotWrite(request, response, url);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
if (request.method === 'DELETE' && /^\/api\/snapshots\/[^/]+\/?$/.test(url.pathname)) {
|
|
1500
|
+
await handleSnapshotWrite(request, response, url);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1153
1503
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
1154
|
-
response.setHeader('allow', testRunWrite
|
|
1504
|
+
response.setHeader('allow', testRunWrite
|
|
1505
|
+
? 'GET, HEAD, POST'
|
|
1506
|
+
: snapshotWrite
|
|
1507
|
+
? 'GET, HEAD, POST, DELETE'
|
|
1508
|
+
: 'GET, HEAD');
|
|
1155
1509
|
sendError(response, 405, 'Method not allowed');
|
|
1156
1510
|
return;
|
|
1157
1511
|
}
|
|
@@ -1214,6 +1568,21 @@ export async function startCollector(options) {
|
|
|
1214
1568
|
}
|
|
1215
1569
|
port = actualPort;
|
|
1216
1570
|
dashboardUrl = `http://${host === '127.0.0.1' ? '127.0.0.1' : host}:${port}`;
|
|
1571
|
+
const saveSnapshotOnExit = async () => {
|
|
1572
|
+
if (!options.config.snapshots.autoOnExit)
|
|
1573
|
+
return null;
|
|
1574
|
+
await autoSaveQueue;
|
|
1575
|
+
try {
|
|
1576
|
+
const saved = await saveCurrentSnapshot('exit');
|
|
1577
|
+
if (saved)
|
|
1578
|
+
log(`exit snapshot saved (${saved.id})`);
|
|
1579
|
+
return saved;
|
|
1580
|
+
}
|
|
1581
|
+
catch (error) {
|
|
1582
|
+
log(`exit snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1217
1586
|
let closing = false;
|
|
1218
1587
|
const close = async () => {
|
|
1219
1588
|
if (closing)
|
|
@@ -1252,6 +1621,7 @@ export async function startCollector(options) {
|
|
|
1252
1621
|
token: options.token,
|
|
1253
1622
|
store,
|
|
1254
1623
|
health: getHealth,
|
|
1624
|
+
saveSnapshotOnExit,
|
|
1255
1625
|
close,
|
|
1256
1626
|
};
|
|
1257
1627
|
}
|