@saptools/cf-live-trace 0.1.5 → 0.1.8
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/README.md +26 -7
- package/dist/cli.js +1267 -92
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +139 -7
- package/dist/index.js +1085 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -112,6 +112,8 @@ function spawnPortForward(params) {
|
|
|
112
112
|
env: buildCfEnv(params.cfHomeDir),
|
|
113
113
|
stdio: ["ignore", "pipe", "pipe"]
|
|
114
114
|
});
|
|
115
|
+
child.stdout.resume();
|
|
116
|
+
child.stderr.resume();
|
|
115
117
|
return {
|
|
116
118
|
process: child,
|
|
117
119
|
localPort: params.localPort,
|
|
@@ -265,7 +267,11 @@ function extractErrorDetail(error) {
|
|
|
265
267
|
if (stderr.length > 0) {
|
|
266
268
|
return stderr;
|
|
267
269
|
}
|
|
268
|
-
|
|
270
|
+
const message = error["message"];
|
|
271
|
+
if (typeof message === "string") {
|
|
272
|
+
return message.trim();
|
|
273
|
+
}
|
|
274
|
+
return message instanceof Error ? message.message.trim() : "";
|
|
269
275
|
}
|
|
270
276
|
function formatErrorMessage(error) {
|
|
271
277
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -513,29 +519,48 @@ function extractEvaluateValue(result) {
|
|
|
513
519
|
}
|
|
514
520
|
|
|
515
521
|
// src/preview.ts
|
|
516
|
-
|
|
517
|
-
|
|
522
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
523
|
+
function truncatePreview(preview, maxBytes) {
|
|
524
|
+
if (maxBytes <= 0) {
|
|
518
525
|
return { preview, truncated: false };
|
|
519
526
|
}
|
|
520
|
-
|
|
527
|
+
const encoded = Buffer2.from(preview);
|
|
528
|
+
if (encoded.length <= maxBytes) {
|
|
521
529
|
return { preview, truncated: false };
|
|
522
530
|
}
|
|
523
|
-
return {
|
|
531
|
+
return {
|
|
532
|
+
preview: encoded.subarray(0, utf8Boundary(encoded, maxBytes)).toString("utf8"),
|
|
533
|
+
truncated: true
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function utf8Boundary(encoded, maxBytes) {
|
|
537
|
+
let boundary = Math.min(maxBytes, encoded.length);
|
|
538
|
+
while (boundary > 0 && isContinuationByte(encoded[boundary])) {
|
|
539
|
+
boundary -= 1;
|
|
540
|
+
}
|
|
541
|
+
return boundary;
|
|
542
|
+
}
|
|
543
|
+
function isContinuationByte(value) {
|
|
544
|
+
return value !== void 0 && (value & 192) === 128;
|
|
524
545
|
}
|
|
525
546
|
|
|
526
547
|
// src/payload.ts
|
|
527
548
|
var fallbackEventId = 0;
|
|
528
549
|
function parseDrainResult(payload, options) {
|
|
529
550
|
if (!isRecord2(payload)) {
|
|
530
|
-
return { events: [], droppedCount: 0, queueSize: 0 };
|
|
551
|
+
return { drainId: null, events: [], droppedCount: 0, queueSize: 0 };
|
|
531
552
|
}
|
|
532
553
|
const rawEvents = Array.isArray(payload["events"]) ? payload["events"] : [];
|
|
533
554
|
return {
|
|
555
|
+
drainId: readDrainId(payload["drainId"]),
|
|
534
556
|
events: rawEvents.map((event) => parseRuntimeEvent(event, options)).filter((event) => event !== null),
|
|
535
557
|
droppedCount: readNonNegativeNumber(payload["droppedCount"]),
|
|
536
558
|
queueSize: readNonNegativeNumber(payload["queueSize"])
|
|
537
559
|
};
|
|
538
560
|
}
|
|
561
|
+
function readDrainId(value) {
|
|
562
|
+
return typeof value === "string" && /^d\d+$/.test(value) ? value : null;
|
|
563
|
+
}
|
|
539
564
|
function parseRuntimeEvent(payload, options) {
|
|
540
565
|
if (!isRecord2(payload)) {
|
|
541
566
|
return null;
|
|
@@ -629,16 +654,20 @@ function isRecord2(value) {
|
|
|
629
654
|
|
|
630
655
|
// src/runtime-source.ts
|
|
631
656
|
var CF_LIVE_TRACE_GLOBAL_NAME = "__SAPTOOLS_CF_LIVE_TRACE__";
|
|
632
|
-
var CF_LIVE_TRACE_RUNTIME_VERSION =
|
|
657
|
+
var CF_LIVE_TRACE_RUNTIME_VERSION = 3;
|
|
633
658
|
var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
634
659
|
(() => {
|
|
635
660
|
const name = '${CF_LIVE_TRACE_GLOBAL_NAME}';
|
|
636
661
|
const runtimeVersion = ${String(CF_LIVE_TRACE_RUNTIME_VERSION)};
|
|
637
662
|
const existing = globalThis[name];
|
|
638
663
|
if (existing && typeof existing.version === 'number' && existing.version >= runtimeVersion) return existing;
|
|
664
|
+
let staleCleanup = null;
|
|
639
665
|
if (existing && typeof existing.uninstall === 'function') {
|
|
640
666
|
try {
|
|
641
|
-
existing.uninstall();
|
|
667
|
+
const cleanup = existing.uninstall();
|
|
668
|
+
if (cleanup && typeof cleanup.then === 'function') {
|
|
669
|
+
staleCleanup = Promise.resolve(cleanup).catch(() => undefined);
|
|
670
|
+
}
|
|
642
671
|
} catch {}
|
|
643
672
|
}
|
|
644
673
|
let BufferCtor = globalThis.Buffer;
|
|
@@ -651,7 +680,9 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
651
680
|
droppedCount: 0,
|
|
652
681
|
originals: {},
|
|
653
682
|
seen: new WeakSet(),
|
|
654
|
-
nextId: 1
|
|
683
|
+
nextId: 1,
|
|
684
|
+
nextDrainId: 1,
|
|
685
|
+
pendingDrain: null
|
|
655
686
|
};
|
|
656
687
|
const loadRequire = () => {
|
|
657
688
|
if (typeof require === 'function') return require;
|
|
@@ -684,13 +715,62 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
684
715
|
if (chunk instanceof Uint8Array && BufferCtor) return BufferCtor.from(chunk).toString('utf8');
|
|
685
716
|
return '';
|
|
686
717
|
};
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
if (
|
|
718
|
+
const chunkBuffer = (chunk) => {
|
|
719
|
+
if (!BufferCtor || chunk === undefined || chunk === null) return null;
|
|
720
|
+
if (BufferCtor.isBuffer(chunk)) return chunk;
|
|
721
|
+
if (typeof chunk === 'string' || chunk instanceof Uint8Array) return BufferCtor.from(chunk);
|
|
722
|
+
return null;
|
|
723
|
+
};
|
|
724
|
+
const textByteLength = (text) => BufferCtor ? BufferCtor.byteLength(text) : text.length;
|
|
725
|
+
const chunkByteLength = (chunk) => {
|
|
726
|
+
if (chunk === undefined || chunk === null) return 0;
|
|
727
|
+
if (BufferCtor && BufferCtor.isBuffer(chunk)) return chunk.length;
|
|
728
|
+
if (chunk instanceof Uint8Array) return chunk.byteLength;
|
|
729
|
+
return textByteLength(typeof chunk === 'string' ? chunk : '');
|
|
730
|
+
};
|
|
731
|
+
const truncateTextToBytes = (text, maxBytes) => {
|
|
732
|
+
if (!BufferCtor) return text.slice(0, maxBytes);
|
|
733
|
+
const encoded = BufferCtor.from(text);
|
|
734
|
+
if (encoded.length <= maxBytes) return text;
|
|
735
|
+
let boundary = Math.min(maxBytes, encoded.length);
|
|
736
|
+
while (boundary > 0 && encoded[boundary] !== undefined && (encoded[boundary] & 0xc0) === 0x80) {
|
|
737
|
+
boundary -= 1;
|
|
738
|
+
}
|
|
739
|
+
return encoded.subarray(0, boundary).toString('utf8');
|
|
740
|
+
};
|
|
741
|
+
const appendPreview = (chunks, currentBytes, chunk, enabled) => {
|
|
742
|
+
if (!enabled) return currentBytes;
|
|
743
|
+
const maxBytes = Math.floor(Number(state.options.maxBodyBytes) || 0);
|
|
744
|
+
if (maxBytes <= 0) return currentBytes;
|
|
745
|
+
const remaining = maxBytes - currentBytes;
|
|
746
|
+
if (remaining <= 0) return currentBytes;
|
|
747
|
+
const buffer = chunkBuffer(chunk);
|
|
748
|
+
if (buffer) {
|
|
749
|
+
const addition = buffer.subarray(0, remaining);
|
|
750
|
+
chunks.push(addition);
|
|
751
|
+
return currentBytes + addition.length;
|
|
752
|
+
}
|
|
690
753
|
const text = chunkText(chunk);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
return
|
|
754
|
+
const addition = truncateTextToBytes(text, remaining);
|
|
755
|
+
chunks.push(addition);
|
|
756
|
+
return currentBytes + textByteLength(addition);
|
|
757
|
+
};
|
|
758
|
+
const completeUtf8Length = (buffer) => {
|
|
759
|
+
if (!buffer || buffer.length === 0) return 0;
|
|
760
|
+
let start = buffer.length - 1;
|
|
761
|
+
while (start > 0 && (buffer[start] & 0xc0) === 0x80) start -= 1;
|
|
762
|
+
const lead = buffer[start];
|
|
763
|
+
const expected = lead <= 0x7f ? 1 : lead <= 0xdf ? 2 : lead <= 0xef ? 3 : lead <= 0xf7 ? 4 : 1;
|
|
764
|
+
return buffer.length - start < expected ? start : buffer.length;
|
|
765
|
+
};
|
|
766
|
+
const previewText = (chunks, retainedBytes) => {
|
|
767
|
+
if (!BufferCtor) {
|
|
768
|
+
const text = chunks.join('');
|
|
769
|
+
return { text, bytes: textByteLength(text) };
|
|
770
|
+
}
|
|
771
|
+
const buffer = BufferCtor.concat(chunks, retainedBytes);
|
|
772
|
+
const completeBytes = completeUtf8Length(buffer);
|
|
773
|
+
return { text: buffer.subarray(0, completeBytes).toString('utf8'), bytes: completeBytes };
|
|
694
774
|
};
|
|
695
775
|
const enqueue = (event) => {
|
|
696
776
|
if (state.queue.length >= state.options.maxEvents) {
|
|
@@ -707,31 +787,30 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
707
787
|
const traceId = String(state.nextId++);
|
|
708
788
|
let requestBytes = 0;
|
|
709
789
|
let responseBytes = 0;
|
|
710
|
-
|
|
711
|
-
|
|
790
|
+
const requestPreviewChunks = [];
|
|
791
|
+
const responsePreviewChunks = [];
|
|
792
|
+
let requestPreviewBytes = 0;
|
|
793
|
+
let responsePreviewBytes = 0;
|
|
712
794
|
let finished = false;
|
|
713
795
|
const originalReqEmit = req.emit;
|
|
714
796
|
const originalWrite = res.write;
|
|
715
797
|
const originalEnd = res.end;
|
|
716
798
|
req.emit = function patchedReqEmit(eventName, ...args) {
|
|
717
799
|
if (eventName === 'data' && args[0] !== undefined) {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
requestPreview = appendPreview(requestPreview, args[0], state.options.captureRequestBody);
|
|
800
|
+
requestBytes += chunkByteLength(args[0]);
|
|
801
|
+
requestPreviewBytes = appendPreview(requestPreviewChunks, requestPreviewBytes, args[0], state.options.captureRequestBody);
|
|
721
802
|
}
|
|
722
803
|
return originalReqEmit.apply(this, [eventName, ...args]);
|
|
723
804
|
};
|
|
724
805
|
res.write = function patchedWrite(chunk, ...args) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
|
|
806
|
+
responseBytes += chunkByteLength(chunk);
|
|
807
|
+
responsePreviewBytes = appendPreview(responsePreviewChunks, responsePreviewBytes, chunk, state.options.captureResponseBody);
|
|
728
808
|
return originalWrite.apply(this, [chunk, ...args]);
|
|
729
809
|
};
|
|
730
810
|
res.end = function patchedEnd(chunk, ...args) {
|
|
731
811
|
if (chunk !== undefined) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
|
|
812
|
+
responseBytes += chunkByteLength(chunk);
|
|
813
|
+
responsePreviewBytes = appendPreview(responsePreviewChunks, responsePreviewBytes, chunk, state.options.captureResponseBody);
|
|
735
814
|
}
|
|
736
815
|
return originalEnd.apply(this, [chunk, ...args]);
|
|
737
816
|
};
|
|
@@ -742,6 +821,8 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
742
821
|
res.write = originalWrite;
|
|
743
822
|
res.end = originalEnd;
|
|
744
823
|
const rawUrl = initialUrl || String(req.url || '');
|
|
824
|
+
const requestPreview = previewText(requestPreviewChunks, requestPreviewBytes);
|
|
825
|
+
const responsePreview = previewText(responsePreviewChunks, responsePreviewBytes);
|
|
745
826
|
enqueue({
|
|
746
827
|
id: traceId,
|
|
747
828
|
timestamp: new Date().toISOString(),
|
|
@@ -756,10 +837,10 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
756
837
|
responseBytes,
|
|
757
838
|
requestHeaders: toHeaderRecord(req.headers),
|
|
758
839
|
responseHeaders: toHeaderRecord(typeof res.getHeaders === 'function' ? res.getHeaders() : {}),
|
|
759
|
-
requestBodyPreview: requestPreview,
|
|
760
|
-
responseBodyPreview: responsePreview,
|
|
761
|
-
requestBodyTruncated: state.options.maxBodyBytes > 0 &&
|
|
762
|
-
responseBodyTruncated: state.options.maxBodyBytes > 0 &&
|
|
840
|
+
requestBodyPreview: requestPreview.text,
|
|
841
|
+
responseBodyPreview: responsePreview.text,
|
|
842
|
+
requestBodyTruncated: state.options.captureRequestBody && state.options.maxBodyBytes > 0 && requestBytes > requestPreview.bytes,
|
|
843
|
+
responseBodyTruncated: state.options.captureResponseBody && state.options.maxBodyBytes > 0 && responseBytes > responsePreview.bytes,
|
|
763
844
|
droppedBeforeEvent: state.droppedCount,
|
|
764
845
|
traceId,
|
|
765
846
|
correlationId: req.headers && typeof req.headers['x-saptools-trace-id'] === 'string' ? req.headers['x-saptools-trace-id'] : null
|
|
@@ -783,10 +864,10 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
783
864
|
const numeric = Number(value);
|
|
784
865
|
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
|
|
785
866
|
};
|
|
786
|
-
const limitPreview = (event, previewKey, truncatedKey,
|
|
867
|
+
const limitPreview = (event, previewKey, truncatedKey, maxBytes) => {
|
|
787
868
|
const preview = event[previewKey];
|
|
788
|
-
if (
|
|
789
|
-
return { ...event, [previewKey]: preview
|
|
869
|
+
if (maxBytes <= 0 || typeof preview !== 'string' || textByteLength(preview) <= maxBytes) return event;
|
|
870
|
+
return { ...event, [previewKey]: truncateTextToBytes(preview, maxBytes), [truncatedKey]: true };
|
|
790
871
|
};
|
|
791
872
|
const eventForDrain = (event, maxChars) => {
|
|
792
873
|
if (!event || typeof event !== 'object') return event;
|
|
@@ -795,10 +876,44 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
795
876
|
output = limitPreview(output, 'responseBodyPreview', 'responseBodyTruncated', maxChars);
|
|
796
877
|
return output;
|
|
797
878
|
};
|
|
879
|
+
const acknowledgeDrain = (drainId) => {
|
|
880
|
+
const pending = state.pendingDrain;
|
|
881
|
+
if (!pending || drainId !== pending.id) return;
|
|
882
|
+
const drainedEvents = new Set(pending.sourceEvents);
|
|
883
|
+
state.queue = state.queue.filter((event) => !drainedEvents.has(event));
|
|
884
|
+
state.pendingDrain = null;
|
|
885
|
+
};
|
|
886
|
+
const drainResult = () => {
|
|
887
|
+
const pending = state.pendingDrain;
|
|
888
|
+
if (!pending) {
|
|
889
|
+
return { drainId: null, events: [], droppedCount: state.droppedCount, queueSize: state.queue.length };
|
|
890
|
+
}
|
|
891
|
+
return {
|
|
892
|
+
drainId: pending.id,
|
|
893
|
+
events: pending.events,
|
|
894
|
+
droppedCount: state.droppedCount,
|
|
895
|
+
queueSize: state.queue.length
|
|
896
|
+
};
|
|
897
|
+
};
|
|
798
898
|
const api = {
|
|
799
899
|
version: runtimeVersion,
|
|
800
900
|
async install(options) {
|
|
801
|
-
|
|
901
|
+
if (staleCleanup) {
|
|
902
|
+
await staleCleanup;
|
|
903
|
+
staleCleanup = null;
|
|
904
|
+
}
|
|
905
|
+
state.options = {
|
|
906
|
+
...state.options,
|
|
907
|
+
...options,
|
|
908
|
+
maxBodyBytes: toTransportLimit(options && options.maxBodyBytes),
|
|
909
|
+
maxEvents: Math.max(1, Math.floor(Number(options && options.maxEvents) || 1))
|
|
910
|
+
};
|
|
911
|
+
state.queue = [];
|
|
912
|
+
state.pendingDrain = null;
|
|
913
|
+
state.droppedCount = 0;
|
|
914
|
+
state.seen = new WeakSet();
|
|
915
|
+
state.nextId = 1;
|
|
916
|
+
state.nextDrainId = 1;
|
|
802
917
|
if (!state.installed) {
|
|
803
918
|
if (!BufferCtor) {
|
|
804
919
|
const bufferModule = await loadModule('buffer');
|
|
@@ -817,25 +932,32 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
817
932
|
state.enabled = false;
|
|
818
933
|
return api.status();
|
|
819
934
|
},
|
|
820
|
-
drainEvents(maxCount, maxTransportBodyBytes) {
|
|
935
|
+
drainEvents(maxCount, maxTransportBodyBytes, acknowledgedDrainId) {
|
|
936
|
+
acknowledgeDrain(acknowledgedDrainId);
|
|
937
|
+
if (state.pendingDrain) return drainResult();
|
|
821
938
|
const count = Math.max(0, Math.min(Number(maxCount) || 0, state.queue.length));
|
|
822
939
|
const transportLimit = toTransportLimit(maxTransportBodyBytes);
|
|
823
|
-
|
|
824
|
-
|
|
940
|
+
if (count === 0) return drainResult();
|
|
941
|
+
const sourceEvents = state.queue.slice(0, count);
|
|
942
|
+
state.pendingDrain = {
|
|
943
|
+
id: 'd' + String(state.nextDrainId++),
|
|
944
|
+
sourceEvents,
|
|
945
|
+
events: sourceEvents.map((event) => eventForDrain(event, transportLimit))
|
|
946
|
+
};
|
|
947
|
+
return drainResult();
|
|
825
948
|
},
|
|
826
949
|
status() {
|
|
827
950
|
return { installed: state.installed, enabled: state.enabled, queueSize: state.queue.length, droppedCount: state.droppedCount, maxEvents: state.options.maxEvents };
|
|
828
951
|
},
|
|
829
|
-
uninstall() {
|
|
952
|
+
async uninstall() {
|
|
830
953
|
state.enabled = false;
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (state.originals.httpServerEmit && http && http.Server) http.Server.prototype.emit = state.originals.httpServerEmit;
|
|
836
|
-
if (state.originals.httpsServerEmit && https && https.Server) https.Server.prototype.emit = state.originals.httpsServerEmit;
|
|
837
|
-
}
|
|
954
|
+
const http = await loadModule('http');
|
|
955
|
+
const https = await loadModule('https');
|
|
956
|
+
if (state.originals.httpServerEmit && http && http.Server) http.Server.prototype.emit = state.originals.httpServerEmit;
|
|
957
|
+
if (state.originals.httpsServerEmit && https && https.Server) https.Server.prototype.emit = state.originals.httpsServerEmit;
|
|
838
958
|
state.installed = false;
|
|
959
|
+
state.queue = [];
|
|
960
|
+
state.pendingDrain = null;
|
|
839
961
|
return api.status();
|
|
840
962
|
}
|
|
841
963
|
};
|
|
@@ -846,8 +968,9 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
|
846
968
|
function buildInstallExpression(options) {
|
|
847
969
|
return `${CF_LIVE_TRACE_RUNTIME_SOURCE}.install(${JSON.stringify(options)})`;
|
|
848
970
|
}
|
|
849
|
-
function buildDrainExpression(maxCount, maxTransportBodyBytes) {
|
|
850
|
-
|
|
971
|
+
function buildDrainExpression(maxCount, maxTransportBodyBytes, acknowledgedDrainId = null) {
|
|
972
|
+
const acknowledgement = JSON.stringify(acknowledgedDrainId);
|
|
973
|
+
return `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.drainEvents(${String(maxCount)}, ${String(maxTransportBodyBytes)}, ${acknowledgement}) ?? { drainId: null, events: [], droppedCount: 0, queueSize: 0 }`;
|
|
851
974
|
}
|
|
852
975
|
function buildStopExpression(options) {
|
|
853
976
|
return options.uninstallRuntimeHook ? `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.uninstall() ?? { installed: false, enabled: false }` : `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.disable() ?? { installed: false, enabled: false }`;
|
|
@@ -939,7 +1062,8 @@ var RUNTIME_QUEUE_SIZE = 1e3;
|
|
|
939
1062
|
var CONTROL_EVALUATE_TIMEOUT_MS = 5e3;
|
|
940
1063
|
var DRAIN_EVALUATE_TIMEOUT_MS = 1e4;
|
|
941
1064
|
var DRAIN_TIMEOUT_RETRY_LIMIT = 3;
|
|
942
|
-
var
|
|
1065
|
+
var MAX_DRAIN_EVALUATE_TIMEOUT_MS = 6e4;
|
|
1066
|
+
var DRAIN_BODY_BUDGET_BYTES = 8 * 1024 * 1024;
|
|
943
1067
|
var defaultDependencies = {
|
|
944
1068
|
prepareCfSession,
|
|
945
1069
|
ensureSshEnabled,
|
|
@@ -956,11 +1080,12 @@ var LiveTraceSession = class {
|
|
|
956
1080
|
}
|
|
957
1081
|
options;
|
|
958
1082
|
dependencies;
|
|
959
|
-
|
|
1083
|
+
summaryEvents = [];
|
|
960
1084
|
consecutiveDrainTimeouts = 0;
|
|
961
|
-
|
|
1085
|
+
drainTask;
|
|
962
1086
|
inspectorClient;
|
|
963
1087
|
pollTimer;
|
|
1088
|
+
pendingDrainAcknowledgement = null;
|
|
964
1089
|
state = "idle";
|
|
965
1090
|
stopRequested = false;
|
|
966
1091
|
tunnelHandle;
|
|
@@ -969,11 +1094,16 @@ var LiveTraceSession = class {
|
|
|
969
1094
|
return;
|
|
970
1095
|
}
|
|
971
1096
|
this.stopRequested = false;
|
|
1097
|
+
this.pendingDrainAcknowledgement = null;
|
|
1098
|
+
this.summaryEvents.splice(0);
|
|
972
1099
|
await this.startRuntimeTrace(resolveStartOptions(options));
|
|
973
1100
|
}
|
|
974
1101
|
async stop(options) {
|
|
975
1102
|
this.stopRequested = true;
|
|
976
1103
|
if (!this.isRunning()) {
|
|
1104
|
+
if (this.state === "error" && options.reason === "error") {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
977
1107
|
this.postState("stopped", `Trace stopped (${options.reason}).`, false, false);
|
|
978
1108
|
return;
|
|
979
1109
|
}
|
|
@@ -1051,36 +1181,65 @@ var LiveTraceSession = class {
|
|
|
1051
1181
|
this.stopPolling();
|
|
1052
1182
|
this.consecutiveDrainTimeouts = 0;
|
|
1053
1183
|
this.pollTimer = this.dependencies.setInterval(() => {
|
|
1054
|
-
|
|
1184
|
+
this.startDrain(maxBodyBytes);
|
|
1055
1185
|
}, DRAIN_INTERVAL_MS);
|
|
1056
1186
|
}
|
|
1057
|
-
|
|
1058
|
-
if (this.
|
|
1187
|
+
startDrain(maxBodyBytes) {
|
|
1188
|
+
if (this.drainTask !== void 0 || this.inspectorClient === void 0 || this.state !== "streaming") {
|
|
1059
1189
|
return;
|
|
1060
1190
|
}
|
|
1061
|
-
|
|
1191
|
+
const task = this.drainTraceEvents(maxBodyBytes);
|
|
1192
|
+
this.drainTask = task;
|
|
1193
|
+
void task.then(
|
|
1194
|
+
() => {
|
|
1195
|
+
this.clearDrainTask(task);
|
|
1196
|
+
},
|
|
1197
|
+
() => {
|
|
1198
|
+
this.clearDrainTask(task);
|
|
1199
|
+
}
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
clearDrainTask(task) {
|
|
1203
|
+
if (this.drainTask === task) {
|
|
1204
|
+
this.drainTask = void 0;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
async drainTraceEvents(maxBodyBytes) {
|
|
1208
|
+
const batchSize = resolveDrainBatchSize(maxBodyBytes);
|
|
1062
1209
|
try {
|
|
1063
|
-
const payload = await this.
|
|
1064
|
-
buildDrainExpression(
|
|
1065
|
-
|
|
1210
|
+
const payload = await this.requireInspector().evaluate(
|
|
1211
|
+
buildDrainExpression(
|
|
1212
|
+
batchSize,
|
|
1213
|
+
resolveDrainTransportBodyLimit(maxBodyBytes),
|
|
1214
|
+
this.pendingDrainAcknowledgement
|
|
1215
|
+
),
|
|
1216
|
+
resolveDrainEvaluateTimeout(maxBodyBytes, batchSize)
|
|
1066
1217
|
);
|
|
1067
1218
|
this.consecutiveDrainTimeouts = 0;
|
|
1068
|
-
|
|
1219
|
+
try {
|
|
1220
|
+
await this.publishDrainedEvents(payload, maxBodyBytes);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
await this.handleEventHandlerFailure(error);
|
|
1223
|
+
}
|
|
1069
1224
|
} catch (error) {
|
|
1070
1225
|
await this.handleDrainFailure(error);
|
|
1071
|
-
} finally {
|
|
1072
|
-
this.drainInFlight = false;
|
|
1073
1226
|
}
|
|
1074
1227
|
}
|
|
1075
|
-
publishDrainedEvents(payload, maxBodyBytes) {
|
|
1228
|
+
async publishDrainedEvents(payload, maxBodyBytes) {
|
|
1076
1229
|
const drained = parseDrainResult(payload, { appId: this.options.target.app, maxBodyBytes });
|
|
1077
|
-
if (drained.events.length
|
|
1230
|
+
if (drained.events.length > 0) {
|
|
1231
|
+
await this.options.onEvents?.(drained.events);
|
|
1232
|
+
this.publishSummary(drained.events);
|
|
1233
|
+
}
|
|
1234
|
+
this.pendingDrainAcknowledgement = drained.drainId;
|
|
1235
|
+
}
|
|
1236
|
+
publishSummary(events) {
|
|
1237
|
+
if (this.options.onSummary === void 0) {
|
|
1078
1238
|
return;
|
|
1079
1239
|
}
|
|
1080
|
-
this.
|
|
1081
|
-
this.
|
|
1082
|
-
this.options.
|
|
1083
|
-
this.options.onSummary?.(buildUrlSummaries(this.events));
|
|
1240
|
+
this.summaryEvents.push(...events.map(toSummaryEvent));
|
|
1241
|
+
this.summaryEvents.splice(0, Math.max(0, this.summaryEvents.length - RUNTIME_QUEUE_SIZE));
|
|
1242
|
+
this.options.onSummary(buildUrlSummaries(this.summaryEvents));
|
|
1084
1243
|
}
|
|
1085
1244
|
async handleDrainFailure(error) {
|
|
1086
1245
|
if (isEvaluateTimeout(error)) {
|
|
@@ -1091,11 +1250,19 @@ var LiveTraceSession = class {
|
|
|
1091
1250
|
}
|
|
1092
1251
|
}
|
|
1093
1252
|
this.log(`Live Trace stream failed for ${this.options.target.app}: ${formatError(error)}`);
|
|
1094
|
-
await this.stopRuntimeTrace(false);
|
|
1253
|
+
await this.stopRuntimeTrace(false, false);
|
|
1095
1254
|
this.postState("error", "Runtime HTTP trace connection was lost.", false, true);
|
|
1096
1255
|
}
|
|
1097
|
-
async
|
|
1256
|
+
async handleEventHandlerFailure(error) {
|
|
1257
|
+
this.log(`Live Trace event handler failed for ${this.options.target.app}: ${formatError(error)}`);
|
|
1258
|
+
await this.stopRuntimeTrace(false, false);
|
|
1259
|
+
this.postState("error", "Trace event handler failed.", false, true);
|
|
1260
|
+
}
|
|
1261
|
+
async stopRuntimeTrace(uninstallRuntimeHook, waitForDrain = true) {
|
|
1098
1262
|
this.stopPolling();
|
|
1263
|
+
if (waitForDrain) {
|
|
1264
|
+
await this.drainTask;
|
|
1265
|
+
}
|
|
1099
1266
|
this.consecutiveDrainTimeouts = 0;
|
|
1100
1267
|
const uninstalled = await this.stopInspectorHook(uninstallRuntimeHook);
|
|
1101
1268
|
await this.closeInspectorClient();
|
|
@@ -1179,12 +1346,20 @@ var LiveTraceStartupError = class extends Error {
|
|
|
1179
1346
|
stateMessage;
|
|
1180
1347
|
};
|
|
1181
1348
|
function resolveStartOptions(options) {
|
|
1349
|
+
const maxBodyBytes = options.maxBodyBytes ?? 4096;
|
|
1350
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
|
|
1351
|
+
throw new Error("maxBodyBytes must be a positive safe integer.");
|
|
1352
|
+
}
|
|
1353
|
+
const runtimeQueueSize = options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE;
|
|
1354
|
+
if (!Number.isSafeInteger(runtimeQueueSize) || runtimeQueueSize <= 0) {
|
|
1355
|
+
throw new Error("runtimeQueueSize must be a positive safe integer.");
|
|
1356
|
+
}
|
|
1182
1357
|
return {
|
|
1183
1358
|
captureHeaders: options.captureHeaders ?? true,
|
|
1184
1359
|
captureRequestBody: options.captureRequestBody ?? true,
|
|
1185
1360
|
captureResponseBody: options.captureResponseBody ?? true,
|
|
1186
|
-
maxBodyBytes
|
|
1187
|
-
runtimeQueueSize
|
|
1361
|
+
maxBodyBytes,
|
|
1362
|
+
runtimeQueueSize
|
|
1188
1363
|
};
|
|
1189
1364
|
}
|
|
1190
1365
|
function stopLateTunnel(tunnel) {
|
|
@@ -1193,7 +1368,16 @@ function stopLateTunnel(tunnel) {
|
|
|
1193
1368
|
}
|
|
1194
1369
|
}
|
|
1195
1370
|
function resolveDrainTransportBodyLimit(maxBodyBytes) {
|
|
1196
|
-
return maxBodyBytes > 0 ?
|
|
1371
|
+
return maxBodyBytes > 0 ? maxBodyBytes : 1;
|
|
1372
|
+
}
|
|
1373
|
+
function resolveDrainBatchSize(maxBodyBytes) {
|
|
1374
|
+
const perEventBytes = Math.max(1, maxBodyBytes * 2);
|
|
1375
|
+
return Math.max(1, Math.min(DRAIN_BATCH_SIZE, Math.floor(DRAIN_BODY_BUDGET_BYTES / perEventBytes)));
|
|
1376
|
+
}
|
|
1377
|
+
function resolveDrainEvaluateTimeout(maxBodyBytes, batchSize) {
|
|
1378
|
+
const estimatedBytes = Math.min(Number.MAX_SAFE_INTEGER, maxBodyBytes * 2 * batchSize);
|
|
1379
|
+
const extraMegabytes = Math.ceil(Math.max(0, estimatedBytes - 1e6) / 1e6);
|
|
1380
|
+
return Math.min(MAX_DRAIN_EVALUATE_TIMEOUT_MS, DRAIN_EVALUATE_TIMEOUT_MS + extraMegabytes * 5e3);
|
|
1197
1381
|
}
|
|
1198
1382
|
function isEvaluateTimeout(error) {
|
|
1199
1383
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1216,8 +1400,569 @@ function formatError(error) {
|
|
|
1216
1400
|
const message = error instanceof Error ? error.message : String(error);
|
|
1217
1401
|
return message.trim().length > 0 ? message.trim() : "Unknown error";
|
|
1218
1402
|
}
|
|
1403
|
+
function toSummaryEvent(event) {
|
|
1404
|
+
return {
|
|
1405
|
+
...event,
|
|
1406
|
+
requestHeaders: {},
|
|
1407
|
+
responseHeaders: {},
|
|
1408
|
+
requestBodyPreview: "",
|
|
1409
|
+
responseBodyPreview: ""
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// src/trace-compact.ts
|
|
1414
|
+
var COMPACT_BODY_PREVIEW_CHARS = 128;
|
|
1415
|
+
var SENSITIVE_QUERY_KEYS = new Set([
|
|
1416
|
+
"access_token",
|
|
1417
|
+
"api_key",
|
|
1418
|
+
"auth",
|
|
1419
|
+
"authorization",
|
|
1420
|
+
"client_secret",
|
|
1421
|
+
"oauth_code",
|
|
1422
|
+
"passwd",
|
|
1423
|
+
"password",
|
|
1424
|
+
"refresh_token",
|
|
1425
|
+
"sig",
|
|
1426
|
+
"signature",
|
|
1427
|
+
"token"
|
|
1428
|
+
].map(normalizeQueryKey));
|
|
1429
|
+
function detectBodyFormat(body, headers) {
|
|
1430
|
+
if (body.length === 0) {
|
|
1431
|
+
return "empty";
|
|
1432
|
+
}
|
|
1433
|
+
const contentType = headerValue(headers, "content-type").toLowerCase();
|
|
1434
|
+
if (contentType.includes("json")) {
|
|
1435
|
+
return "json";
|
|
1436
|
+
}
|
|
1437
|
+
if (contentType.includes("html")) {
|
|
1438
|
+
return "html";
|
|
1439
|
+
}
|
|
1440
|
+
if (contentType.includes("xml")) {
|
|
1441
|
+
return "xml";
|
|
1442
|
+
}
|
|
1443
|
+
if (contentType.includes("x-www-form-urlencoded")) {
|
|
1444
|
+
return "form";
|
|
1445
|
+
}
|
|
1446
|
+
if (contentType.includes("octet-stream")) {
|
|
1447
|
+
return "binary";
|
|
1448
|
+
}
|
|
1449
|
+
return detectBodyFormatFromText(body, contentType);
|
|
1450
|
+
}
|
|
1451
|
+
function compactTraceEvent(record) {
|
|
1452
|
+
const event = record.event;
|
|
1453
|
+
const requestBody = compactBody(event.requestBodyPreview);
|
|
1454
|
+
const responseBody = compactBody(event.responseBodyPreview);
|
|
1455
|
+
return {
|
|
1456
|
+
id: event.id,
|
|
1457
|
+
sessionId: record.sessionId,
|
|
1458
|
+
requestId: record.requestId,
|
|
1459
|
+
timestamp: event.timestamp,
|
|
1460
|
+
instance: event.instance,
|
|
1461
|
+
method: event.method,
|
|
1462
|
+
path: event.path,
|
|
1463
|
+
url: redactSensitiveQueryValues(event.url),
|
|
1464
|
+
normalizedUrl: redactSensitiveQueryValues(event.normalizedUrl),
|
|
1465
|
+
status: event.status,
|
|
1466
|
+
durationMs: event.durationMs,
|
|
1467
|
+
requestBytes: event.requestBytes,
|
|
1468
|
+
responseBytes: event.responseBytes,
|
|
1469
|
+
requestBodyFormat: record.requestBodyFormat,
|
|
1470
|
+
responseBodyFormat: record.responseBodyFormat,
|
|
1471
|
+
requestBodyPreview: requestBody.preview,
|
|
1472
|
+
requestBodyPreviewRemainingChars: requestBody.remainingChars,
|
|
1473
|
+
responseBodyPreview: responseBody.preview,
|
|
1474
|
+
responseBodyPreviewRemainingChars: responseBody.remainingChars,
|
|
1475
|
+
requestBodyTruncated: event.requestBodyTruncated,
|
|
1476
|
+
responseBodyTruncated: event.responseBodyTruncated,
|
|
1477
|
+
droppedBeforeEvent: event.droppedBeforeEvent,
|
|
1478
|
+
source: event.source,
|
|
1479
|
+
traceId: event.traceId,
|
|
1480
|
+
correlationId: event.correlationId
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
function headerValue(headers, name) {
|
|
1484
|
+
const lowerName = name.toLowerCase();
|
|
1485
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1486
|
+
if (key.toLowerCase() === lowerName) {
|
|
1487
|
+
return value;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return "";
|
|
1491
|
+
}
|
|
1492
|
+
function detectBodyFormatFromText(body, contentType) {
|
|
1493
|
+
const trimmed = body.trim();
|
|
1494
|
+
if (isJsonText(trimmed)) {
|
|
1495
|
+
return "json";
|
|
1496
|
+
}
|
|
1497
|
+
if (/^<!doctype\s+html/i.test(trimmed) || /^<html(?:\s|>)/i.test(trimmed)) {
|
|
1498
|
+
return "html";
|
|
1499
|
+
}
|
|
1500
|
+
if (/^<\?xml(?:\s|>)/i.test(trimmed) || /^<[A-Za-z_][\w:.-]*(?:\s|>|\/>)/.test(trimmed)) {
|
|
1501
|
+
return "xml";
|
|
1502
|
+
}
|
|
1503
|
+
if (contentType.startsWith("text/")) {
|
|
1504
|
+
return "text";
|
|
1505
|
+
}
|
|
1506
|
+
if (hasBinaryControlChars(body)) {
|
|
1507
|
+
return "binary";
|
|
1508
|
+
}
|
|
1509
|
+
return "text";
|
|
1510
|
+
}
|
|
1511
|
+
function isJsonText(text) {
|
|
1512
|
+
if (!text.startsWith("{") && !text.startsWith("[")) {
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
JSON.parse(text);
|
|
1517
|
+
return true;
|
|
1518
|
+
} catch {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
function hasBinaryControlChars(text) {
|
|
1523
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1524
|
+
const code = text.charCodeAt(index);
|
|
1525
|
+
if (code <= 8 || code === 11 || code === 12 || code >= 14 && code <= 31) {
|
|
1526
|
+
return true;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
function compactBody(body) {
|
|
1532
|
+
let preview = "";
|
|
1533
|
+
let length = 0;
|
|
1534
|
+
for (const character of body) {
|
|
1535
|
+
if (length < COMPACT_BODY_PREVIEW_CHARS) {
|
|
1536
|
+
preview += character;
|
|
1537
|
+
}
|
|
1538
|
+
length += 1;
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
preview,
|
|
1542
|
+
remainingChars: Math.max(0, length - COMPACT_BODY_PREVIEW_CHARS)
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
function redactSensitiveQueryValues(rawUrl) {
|
|
1546
|
+
const queryIndex = rawUrl.indexOf("?");
|
|
1547
|
+
if (queryIndex < 0) {
|
|
1548
|
+
return rawUrl;
|
|
1549
|
+
}
|
|
1550
|
+
const fragmentIndex = rawUrl.indexOf("#", queryIndex);
|
|
1551
|
+
const queryEnd = fragmentIndex < 0 ? rawUrl.length : fragmentIndex;
|
|
1552
|
+
const query = rawUrl.slice(queryIndex + 1, queryEnd);
|
|
1553
|
+
const redacted = query.split("&").map(redactQueryPart).join("&");
|
|
1554
|
+
return `${rawUrl.slice(0, queryIndex + 1)}${redacted}${rawUrl.slice(queryEnd)}`;
|
|
1555
|
+
}
|
|
1556
|
+
function redactQueryPart(part) {
|
|
1557
|
+
const separatorIndex = part.indexOf("=");
|
|
1558
|
+
const rawKey = separatorIndex < 0 ? part : part.slice(0, separatorIndex);
|
|
1559
|
+
if (!SENSITIVE_QUERY_KEYS.has(normalizeQueryKey(rawKey))) {
|
|
1560
|
+
return part;
|
|
1561
|
+
}
|
|
1562
|
+
return `${rawKey}=redacted`;
|
|
1563
|
+
}
|
|
1564
|
+
function normalizeQueryKey(rawKey) {
|
|
1565
|
+
try {
|
|
1566
|
+
return decodeURIComponent(rawKey).toLowerCase().replaceAll(/[^a-z0-9]/g, "");
|
|
1567
|
+
} catch {
|
|
1568
|
+
return rawKey.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// src/trace-store.ts
|
|
1573
|
+
import { createHash, randomBytes } from "crypto";
|
|
1574
|
+
import { access, mkdir, readFile, readdir, rename, rm as rm2, writeFile } from "fs/promises";
|
|
1575
|
+
import { homedir } from "os";
|
|
1576
|
+
import { join as join2 } from "path";
|
|
1577
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
1578
|
+
var CF_LIVE_TRACE_DIR_NAME = "cf-live-trace";
|
|
1579
|
+
var SESSIONS_DIR_NAME = "sessions";
|
|
1580
|
+
var EVENTS_DIR_NAME = "events";
|
|
1581
|
+
var MANIFEST_FILE_NAME = "manifest.json";
|
|
1582
|
+
var TRACE_TTL_MS = 2 * 60 * 60 * 1e3;
|
|
1583
|
+
var MAX_TARGET_SLUG_BYTES = 160;
|
|
1584
|
+
var SESSION_ID_PATTERN = /^s(?:[0-9a-f]{8}|[0-9a-f]{16})$/;
|
|
1585
|
+
var REQUEST_ID_PATTERN = /^r(?:[0-9a-f]{8}|[0-9a-f]{16})$/;
|
|
1586
|
+
function traceSessionsRoot(saptoolsRoot) {
|
|
1587
|
+
return join2(saptoolsRoot ?? join2(homedir(), SAPTOOLS_DIR_NAME), CF_LIVE_TRACE_DIR_NAME, SESSIONS_DIR_NAME);
|
|
1588
|
+
}
|
|
1589
|
+
async function createTraceSession(input, options = {}) {
|
|
1590
|
+
await pruneTraceSessions(options);
|
|
1591
|
+
const sessionId = resolveSessionId(options.sessionId);
|
|
1592
|
+
const createdAt = (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1593
|
+
const target = toTargetIdentity(input.target);
|
|
1594
|
+
const directory = sessionDirectory(sessionId, options.saptoolsRoot);
|
|
1595
|
+
const eventsDirectory2 = join2(directory, EVENTS_DIR_NAME);
|
|
1596
|
+
const manifestPath2 = join2(directory, MANIFEST_FILE_NAME);
|
|
1597
|
+
await mkdir(eventsDirectory2, { recursive: true, mode: 448 });
|
|
1598
|
+
await writeJsonFile(manifestPath2, createManifest(sessionId, createdAt, target));
|
|
1599
|
+
return { sessionId, createdAt, target, directory, eventsDirectory: eventsDirectory2, manifestPath: manifestPath2 };
|
|
1600
|
+
}
|
|
1601
|
+
async function writeTraceEvent(session, event, options = {}) {
|
|
1602
|
+
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
1603
|
+
const requestId = resolveRequestId(options.requestId?.());
|
|
1604
|
+
await ensureSessionManifest(session);
|
|
1605
|
+
const record = {
|
|
1606
|
+
version: 1,
|
|
1607
|
+
sessionId: session.sessionId,
|
|
1608
|
+
requestId,
|
|
1609
|
+
createdAt: now.toISOString(),
|
|
1610
|
+
expiresAt: new Date(now.getTime() + TRACE_TTL_MS).toISOString(),
|
|
1611
|
+
target: session.target,
|
|
1612
|
+
requestBodyFormat: detectBodyFormat(event.requestBodyPreview, event.requestHeaders),
|
|
1613
|
+
responseBodyFormat: detectBodyFormat(event.responseBodyPreview, event.responseHeaders),
|
|
1614
|
+
event
|
|
1615
|
+
};
|
|
1616
|
+
const backupPath = join2(session.eventsDirectory, eventFileName(record, now));
|
|
1617
|
+
await mkdir(session.eventsDirectory, { recursive: true, mode: 448 });
|
|
1618
|
+
await writeJsonFile(backupPath, record);
|
|
1619
|
+
return { ...record, backupPath };
|
|
1620
|
+
}
|
|
1621
|
+
async function readTraceEvent(sessionId, requestId, options = {}) {
|
|
1622
|
+
const resolvedSessionId = resolveSessionId(sessionId);
|
|
1623
|
+
const resolvedRequestId = resolveRequestId(requestId);
|
|
1624
|
+
const entries = await listEventFilePaths(resolvedSessionId, options.saptoolsRoot);
|
|
1625
|
+
const candidates = entries.filter((path) => path.includes(`-${resolvedRequestId}-`));
|
|
1626
|
+
for (const path of candidates) {
|
|
1627
|
+
const record = await readStoredTraceEvent(path, resolvedSessionId);
|
|
1628
|
+
if (record === void 0 || isExpired(record, options)) {
|
|
1629
|
+
await rm2(path, { force: true });
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
if (record.requestId === resolvedRequestId) {
|
|
1633
|
+
return record;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
throw new Error("Saved trace request not found or expired");
|
|
1637
|
+
}
|
|
1638
|
+
async function visitTraceEvents(sessionId, visitor, options = {}) {
|
|
1639
|
+
const resolvedSessionId = resolveSessionId(sessionId);
|
|
1640
|
+
const entries = await listEventFilePaths(resolvedSessionId, options.saptoolsRoot);
|
|
1641
|
+
for (const path of entries) {
|
|
1642
|
+
const record = await readStoredTraceEvent(path, resolvedSessionId);
|
|
1643
|
+
if (record === void 0 || isExpired(record, options)) {
|
|
1644
|
+
await rm2(path, { force: true });
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
if (!await visitor(record)) {
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
async function listTraceSessions(options = {}) {
|
|
1653
|
+
await pruneTraceSessions(options);
|
|
1654
|
+
const sessionIds = await listSessionIds(options.saptoolsRoot);
|
|
1655
|
+
const summaries = await Promise.all(sessionIds.map(async (sessionId) => await readSessionSummary(sessionId, options)));
|
|
1656
|
+
return summaries.filter((summary) => summary !== void 0).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
1657
|
+
}
|
|
1658
|
+
async function pruneTraceSessions(options = {}) {
|
|
1659
|
+
const sessionIds = await listSessionIds(options.saptoolsRoot);
|
|
1660
|
+
const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
1661
|
+
let removed = 0;
|
|
1662
|
+
for (const sessionId of sessionIds) {
|
|
1663
|
+
removed += await pruneSession(sessionId, now, options.saptoolsRoot);
|
|
1664
|
+
}
|
|
1665
|
+
return removed;
|
|
1666
|
+
}
|
|
1667
|
+
function toTargetIdentity(target) {
|
|
1668
|
+
return {
|
|
1669
|
+
...target.region === void 0 ? {} : { region: target.region },
|
|
1670
|
+
...target.apiEndpoint === void 0 ? {} : { apiEndpoint: target.apiEndpoint },
|
|
1671
|
+
org: target.org,
|
|
1672
|
+
space: target.space,
|
|
1673
|
+
app: target.app,
|
|
1674
|
+
instance: String(target.instanceIndex ?? 0)
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function createManifest(sessionId, createdAt, target) {
|
|
1678
|
+
return { version: 1, sessionId, createdAt, target };
|
|
1679
|
+
}
|
|
1680
|
+
async function ensureSessionManifest(session) {
|
|
1681
|
+
try {
|
|
1682
|
+
await access(session.manifestPath);
|
|
1683
|
+
return;
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
1686
|
+
throw error;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
await mkdir(session.eventsDirectory, { recursive: true, mode: 448 });
|
|
1690
|
+
await writeJsonFile(
|
|
1691
|
+
session.manifestPath,
|
|
1692
|
+
createManifest(session.sessionId, session.createdAt, session.target)
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
function isExpired(record, options) {
|
|
1696
|
+
const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
1697
|
+
return Date.parse(record.expiresAt) <= now;
|
|
1698
|
+
}
|
|
1699
|
+
async function readSessionSummary(sessionId, options) {
|
|
1700
|
+
const manifest = await readStoredManifest(manifestPath(sessionId, options.saptoolsRoot));
|
|
1701
|
+
if (manifest?.sessionId !== sessionId) {
|
|
1702
|
+
return void 0;
|
|
1703
|
+
}
|
|
1704
|
+
const eventCount = (await listEventFilePaths(sessionId, options.saptoolsRoot)).length;
|
|
1705
|
+
return {
|
|
1706
|
+
sessionId,
|
|
1707
|
+
createdAt: manifest.createdAt,
|
|
1708
|
+
target: manifest.target,
|
|
1709
|
+
eventCount,
|
|
1710
|
+
directory: sessionDirectory(sessionId, options.saptoolsRoot)
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
async function pruneSession(sessionId, now, saptoolsRoot) {
|
|
1714
|
+
const manifest = await readStoredManifest(manifestPath(sessionId, saptoolsRoot));
|
|
1715
|
+
const eventPaths = await listEventFilePaths(sessionId, saptoolsRoot);
|
|
1716
|
+
let removed = 0;
|
|
1717
|
+
let remaining = 0;
|
|
1718
|
+
for (const path of eventPaths) {
|
|
1719
|
+
const expiresAt = eventPathExpiresAt(path) ?? await storedEventExpiresAt(path);
|
|
1720
|
+
if (expiresAt === void 0 || expiresAt <= now) {
|
|
1721
|
+
await rm2(path, { force: true });
|
|
1722
|
+
removed += 1;
|
|
1723
|
+
} else {
|
|
1724
|
+
remaining += 1;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (remaining === 0 && isManifestExpiredOrMissing(manifest, now)) {
|
|
1728
|
+
await rm2(sessionDirectory(sessionId, saptoolsRoot), { recursive: true, force: true });
|
|
1729
|
+
}
|
|
1730
|
+
return removed;
|
|
1731
|
+
}
|
|
1732
|
+
async function storedEventExpiresAt(path) {
|
|
1733
|
+
const record = await readStoredTraceEvent(path);
|
|
1734
|
+
return record === void 0 ? void 0 : Date.parse(record.expiresAt);
|
|
1735
|
+
}
|
|
1736
|
+
function isManifestExpiredOrMissing(manifest, now) {
|
|
1737
|
+
if (manifest === void 0) {
|
|
1738
|
+
return true;
|
|
1739
|
+
}
|
|
1740
|
+
return Date.parse(manifest.createdAt) + TRACE_TTL_MS <= now;
|
|
1741
|
+
}
|
|
1742
|
+
async function listSessionIds(saptoolsRoot) {
|
|
1743
|
+
try {
|
|
1744
|
+
const entries = await readdir(traceSessionsRoot(saptoolsRoot), { withFileTypes: true });
|
|
1745
|
+
return entries.filter((entry) => entry.isDirectory() && SESSION_ID_PATTERN.test(entry.name)).map((entry) => entry.name);
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
if (isNodeError(error, "ENOENT")) {
|
|
1748
|
+
return [];
|
|
1749
|
+
}
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
async function listEventFilePaths(sessionId, saptoolsRoot) {
|
|
1754
|
+
try {
|
|
1755
|
+
const directory = eventsDirectory(sessionId, saptoolsRoot);
|
|
1756
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
1757
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join2(directory, entry.name)).sort(compareEventPaths);
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
if (isNodeError(error, "ENOENT")) {
|
|
1760
|
+
return [];
|
|
1761
|
+
}
|
|
1762
|
+
throw error;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
function compareEventPaths(left, right) {
|
|
1766
|
+
const leftTimestamp = eventPathTimestamp(left);
|
|
1767
|
+
const rightTimestamp = eventPathTimestamp(right);
|
|
1768
|
+
return leftTimestamp.localeCompare(rightTimestamp) || left.localeCompare(right);
|
|
1769
|
+
}
|
|
1770
|
+
function eventPathTimestamp(path) {
|
|
1771
|
+
return /-(\d{8}T\d{9}Z)\.json$/.exec(path)?.[1] ?? "";
|
|
1772
|
+
}
|
|
1773
|
+
function eventPathExpiresAt(path) {
|
|
1774
|
+
const compact = eventPathTimestamp(path);
|
|
1775
|
+
if (compact.length === 0) {
|
|
1776
|
+
return void 0;
|
|
1777
|
+
}
|
|
1778
|
+
const iso = compact.replace(
|
|
1779
|
+
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z$/,
|
|
1780
|
+
"$1-$2-$3T$4:$5:$6.$7Z"
|
|
1781
|
+
);
|
|
1782
|
+
const createdAt = Date.parse(iso);
|
|
1783
|
+
return Number.isNaN(createdAt) ? void 0 : createdAt + TRACE_TTL_MS;
|
|
1784
|
+
}
|
|
1785
|
+
async function readStoredManifest(path) {
|
|
1786
|
+
const parsed = await readJsonFile(path);
|
|
1787
|
+
return parsed !== void 0 && isStoredManifest(parsed) ? parsed : void 0;
|
|
1788
|
+
}
|
|
1789
|
+
async function readStoredTraceEvent(path, expectedSessionId) {
|
|
1790
|
+
const parsed = await readJsonFile(path);
|
|
1791
|
+
if (parsed === void 0 || !isStoredTraceEvent(parsed)) {
|
|
1792
|
+
return void 0;
|
|
1793
|
+
}
|
|
1794
|
+
if (expectedSessionId !== void 0 && parsed.sessionId !== expectedSessionId) {
|
|
1795
|
+
return void 0;
|
|
1796
|
+
}
|
|
1797
|
+
return { ...parsed, backupPath: path };
|
|
1798
|
+
}
|
|
1799
|
+
async function readJsonFile(path) {
|
|
1800
|
+
let raw;
|
|
1801
|
+
try {
|
|
1802
|
+
raw = await readFile(path, "utf8");
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
if (isNodeError(error, "ENOENT")) {
|
|
1805
|
+
return void 0;
|
|
1806
|
+
}
|
|
1807
|
+
throw error;
|
|
1808
|
+
}
|
|
1809
|
+
try {
|
|
1810
|
+
return JSON.parse(raw);
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
if (!(error instanceof SyntaxError)) {
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
return void 0;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
async function writeJsonFile(path, value) {
|
|
1819
|
+
const temporaryPath = `${path}.tmp-${process.pid.toString()}-${randomHex(4)}`;
|
|
1820
|
+
let renamed = false;
|
|
1821
|
+
try {
|
|
1822
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}
|
|
1823
|
+
`, {
|
|
1824
|
+
encoding: "utf8",
|
|
1825
|
+
mode: 384
|
|
1826
|
+
});
|
|
1827
|
+
await rename(temporaryPath, path);
|
|
1828
|
+
renamed = true;
|
|
1829
|
+
} finally {
|
|
1830
|
+
if (!renamed) {
|
|
1831
|
+
await rm2(temporaryPath, { force: true });
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function eventFileName(record, now) {
|
|
1836
|
+
const parts = [
|
|
1837
|
+
targetSlug(record.target),
|
|
1838
|
+
record.sessionId,
|
|
1839
|
+
record.requestId,
|
|
1840
|
+
fileTimestamp(now)
|
|
1841
|
+
].join("-");
|
|
1842
|
+
return `${parts}.json`;
|
|
1843
|
+
}
|
|
1844
|
+
function targetSlug(target) {
|
|
1845
|
+
const regionOrApi = target.region ?? target.apiEndpoint ?? "api";
|
|
1846
|
+
const fullSlug = [regionOrApi, target.org, target.space, target.app].map(sanitizePathPart).join("-");
|
|
1847
|
+
if (Buffer.byteLength(fullSlug) <= MAX_TARGET_SLUG_BYTES) {
|
|
1848
|
+
return fullSlug;
|
|
1849
|
+
}
|
|
1850
|
+
const hash = createHash("sha256").update(fullSlug).digest("hex").slice(0, 12);
|
|
1851
|
+
const prefix = fullSlug.slice(0, MAX_TARGET_SLUG_BYTES - hash.length - 1).replace(/-+$/, "");
|
|
1852
|
+
return `${prefix}-${hash}`;
|
|
1853
|
+
}
|
|
1854
|
+
function sanitizePathPart(value) {
|
|
1855
|
+
const sanitized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1856
|
+
return sanitized.length === 0 ? "unknown" : sanitized;
|
|
1857
|
+
}
|
|
1858
|
+
function fileTimestamp(now) {
|
|
1859
|
+
return now.toISOString().replaceAll("-", "").replaceAll(":", "").replace(".", "");
|
|
1860
|
+
}
|
|
1861
|
+
function sessionDirectory(sessionId, saptoolsRoot) {
|
|
1862
|
+
return join2(traceSessionsRoot(saptoolsRoot), sessionId);
|
|
1863
|
+
}
|
|
1864
|
+
function eventsDirectory(sessionId, saptoolsRoot) {
|
|
1865
|
+
return join2(sessionDirectory(sessionId, saptoolsRoot), EVENTS_DIR_NAME);
|
|
1866
|
+
}
|
|
1867
|
+
function manifestPath(sessionId, saptoolsRoot) {
|
|
1868
|
+
return join2(sessionDirectory(sessionId, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
1869
|
+
}
|
|
1870
|
+
function resolveSessionId(value) {
|
|
1871
|
+
const sessionId = value ?? `s${randomHex(8)}`;
|
|
1872
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
1873
|
+
throw new Error("Invalid trace session id");
|
|
1874
|
+
}
|
|
1875
|
+
return sessionId;
|
|
1876
|
+
}
|
|
1877
|
+
function resolveRequestId(value) {
|
|
1878
|
+
const requestId = value ?? `r${randomHex(8)}`;
|
|
1879
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) {
|
|
1880
|
+
throw new Error("Invalid trace request id");
|
|
1881
|
+
}
|
|
1882
|
+
return requestId;
|
|
1883
|
+
}
|
|
1884
|
+
function randomHex(bytes) {
|
|
1885
|
+
return randomBytes(bytes).toString("hex");
|
|
1886
|
+
}
|
|
1887
|
+
function isStoredManifest(value) {
|
|
1888
|
+
return isRecord3(value) && value["version"] === 1 && isSessionId(value["sessionId"]) && isIsoTimestamp(value["createdAt"]) && isTraceTargetIdentity(value["target"]);
|
|
1889
|
+
}
|
|
1890
|
+
function isStoredTraceEvent(value) {
|
|
1891
|
+
return isRecord3(value) && value["version"] === 1 && isStoredEventMetadata(value) && isTraceTargetIdentity(value["target"]) && isTraceBodyFormat(value["requestBodyFormat"]) && isTraceBodyFormat(value["responseBodyFormat"]) && isLiveTraceEvent(value["event"]);
|
|
1892
|
+
}
|
|
1893
|
+
function isStoredEventMetadata(value) {
|
|
1894
|
+
return isSessionId(value["sessionId"]) && isRequestId(value["requestId"]) && isIsoTimestamp(value["createdAt"]) && isIsoTimestamp(value["expiresAt"]);
|
|
1895
|
+
}
|
|
1896
|
+
function isTraceTargetIdentity(value) {
|
|
1897
|
+
return isRecord3(value) && optionalString(value["region"]) && optionalString(value["apiEndpoint"]) && typeof value["org"] === "string" && typeof value["space"] === "string" && typeof value["app"] === "string" && typeof value["instance"] === "string";
|
|
1898
|
+
}
|
|
1899
|
+
function isLiveTraceEvent(value) {
|
|
1900
|
+
return isRecord3(value) && hasLiveTraceStrings(value) && hasLiveTraceMetrics(value) && isStringRecord(value["requestHeaders"]) && isStringRecord(value["responseHeaders"]) && typeof value["requestBodyTruncated"] === "boolean" && typeof value["responseBodyTruncated"] === "boolean" && value["source"] === "runtime-http" && optionalNullableString(value["correlationId"]);
|
|
1901
|
+
}
|
|
1902
|
+
function hasLiveTraceStrings(value) {
|
|
1903
|
+
const fields = [
|
|
1904
|
+
"id",
|
|
1905
|
+
"appId",
|
|
1906
|
+
"instance",
|
|
1907
|
+
"method",
|
|
1908
|
+
"path",
|
|
1909
|
+
"url",
|
|
1910
|
+
"normalizedUrl",
|
|
1911
|
+
"requestBodyPreview",
|
|
1912
|
+
"responseBodyPreview",
|
|
1913
|
+
"traceId"
|
|
1914
|
+
];
|
|
1915
|
+
return isIsoTimestamp(value["timestamp"]) && fields.every((field) => typeof value[field] === "string");
|
|
1916
|
+
}
|
|
1917
|
+
function hasLiveTraceMetrics(value) {
|
|
1918
|
+
return isNullableFiniteNumber(value["status"]) && isNullableNonNegativeNumber(value["durationMs"]) && isNonNegativeFiniteNumber(value["requestBytes"]) && isNonNegativeFiniteNumber(value["responseBytes"]) && isNonNegativeFiniteNumber(value["droppedBeforeEvent"]);
|
|
1919
|
+
}
|
|
1920
|
+
function isTraceBodyFormat(value) {
|
|
1921
|
+
return value === "empty" || value === "json" || value === "xml" || value === "html" || value === "form" || value === "text" || value === "binary" || value === "unknown";
|
|
1922
|
+
}
|
|
1923
|
+
function optionalString(value) {
|
|
1924
|
+
return value === void 0 || typeof value === "string";
|
|
1925
|
+
}
|
|
1926
|
+
function optionalNullableString(value) {
|
|
1927
|
+
return value === null || typeof value === "string";
|
|
1928
|
+
}
|
|
1929
|
+
function isStringRecord(value) {
|
|
1930
|
+
return isRecord3(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
1931
|
+
}
|
|
1932
|
+
function isSessionId(value) {
|
|
1933
|
+
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
|
|
1934
|
+
}
|
|
1935
|
+
function isRequestId(value) {
|
|
1936
|
+
return typeof value === "string" && REQUEST_ID_PATTERN.test(value);
|
|
1937
|
+
}
|
|
1938
|
+
function isIsoTimestamp(value) {
|
|
1939
|
+
if (typeof value !== "string") {
|
|
1940
|
+
return false;
|
|
1941
|
+
}
|
|
1942
|
+
const parsed = new Date(value);
|
|
1943
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
|
|
1944
|
+
}
|
|
1945
|
+
function isNullableFiniteNumber(value) {
|
|
1946
|
+
return value === null || isFiniteNumber(value);
|
|
1947
|
+
}
|
|
1948
|
+
function isNullableNonNegativeNumber(value) {
|
|
1949
|
+
return value === null || isNonNegativeFiniteNumber(value);
|
|
1950
|
+
}
|
|
1951
|
+
function isNonNegativeFiniteNumber(value) {
|
|
1952
|
+
return isFiniteNumber(value) && value >= 0;
|
|
1953
|
+
}
|
|
1954
|
+
function isFiniteNumber(value) {
|
|
1955
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1956
|
+
}
|
|
1957
|
+
function isRecord3(value) {
|
|
1958
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1959
|
+
}
|
|
1960
|
+
function isNodeError(error, code) {
|
|
1961
|
+
return isRecord3(error) && error["code"] === code;
|
|
1962
|
+
}
|
|
1219
1963
|
|
|
1220
1964
|
// src/cli/options.ts
|
|
1965
|
+
var MAX_TIMER_SECONDS = 2147483;
|
|
1221
1966
|
function buildRunOptions2(flags, env) {
|
|
1222
1967
|
requireRegionOrApi(flags);
|
|
1223
1968
|
const target = buildTarget(flags, env);
|
|
@@ -1244,7 +1989,7 @@ function parsePositiveInteger(raw, label) {
|
|
|
1244
1989
|
return void 0;
|
|
1245
1990
|
}
|
|
1246
1991
|
const value = Number.parseInt(raw, 10);
|
|
1247
|
-
if (!Number.
|
|
1992
|
+
if (!Number.isSafeInteger(value) || value <= 0 || String(value) !== raw.trim()) {
|
|
1248
1993
|
throw new Error(`Invalid ${label}: "${raw}" \u2014 expected a positive integer.`);
|
|
1249
1994
|
}
|
|
1250
1995
|
return value;
|
|
@@ -1312,6 +2057,9 @@ function buildTarget(flags, env) {
|
|
|
1312
2057
|
function buildLimits(flags) {
|
|
1313
2058
|
const duration = parsePositiveInteger(flags.duration, "--duration");
|
|
1314
2059
|
const maxEvents = parsePositiveInteger(flags.maxEvents, "--max-events");
|
|
2060
|
+
if (duration !== void 0 && duration > MAX_TIMER_SECONDS) {
|
|
2061
|
+
throw new Error(`--duration is too large; maximum is ${String(MAX_TIMER_SECONDS)} seconds.`);
|
|
2062
|
+
}
|
|
1315
2063
|
return {
|
|
1316
2064
|
...duration === void 0 ? {} : { durationMs: duration * 1e3 },
|
|
1317
2065
|
...maxEvents === void 0 ? {} : { maxEvents }
|
|
@@ -1346,14 +2094,11 @@ function parseInstanceIndex(value) {
|
|
|
1346
2094
|
return parsed ?? 0;
|
|
1347
2095
|
}
|
|
1348
2096
|
function parseBodyLimit(value) {
|
|
1349
|
-
|
|
1350
|
-
return 4096;
|
|
1351
|
-
}
|
|
1352
|
-
return parseNonNegativeInteger(value, "--max-body-bytes");
|
|
2097
|
+
return parsePositiveInteger(value, "--max-body-bytes") ?? 4096;
|
|
1353
2098
|
}
|
|
1354
2099
|
function parseNonNegativeInteger(raw, label) {
|
|
1355
2100
|
const value = Number.parseInt(raw, 10);
|
|
1356
|
-
if (!Number.
|
|
2101
|
+
if (!Number.isSafeInteger(value) || value < 0 || String(value) !== raw.trim()) {
|
|
1357
2102
|
throw new Error(`Invalid ${label}: "${raw}" \u2014 expected a non-negative integer.`);
|
|
1358
2103
|
}
|
|
1359
2104
|
return value;
|
|
@@ -1389,67 +2134,462 @@ function writeLog(message) {
|
|
|
1389
2134
|
function writeSummaryLine(event) {
|
|
1390
2135
|
const status = event.status === null ? "-" : String(event.status);
|
|
1391
2136
|
const duration = event.durationMs === null ? "-" : `${event.durationMs.toString()}ms`;
|
|
1392
|
-
|
|
2137
|
+
const request = event.sessionId === void 0 || event.requestId === void 0 ? "" : ` ${event.sessionId}/${event.requestId}`;
|
|
2138
|
+
process2.stdout.write(`${event.timestamp} ${event.method} ${event.normalizedUrl} ${status} ${duration}${request}
|
|
1393
2139
|
`);
|
|
1394
2140
|
}
|
|
1395
2141
|
|
|
2142
|
+
// src/trace-inspect.ts
|
|
2143
|
+
var DEFAULT_BODY_LIMIT = 4e3;
|
|
2144
|
+
function inspectTraceBodyResult(record, options) {
|
|
2145
|
+
const limit = positive("limit", options.limit ?? DEFAULT_BODY_LIMIT);
|
|
2146
|
+
const maxRows = positive("max rows", options.maxRows ?? Number.MAX_SAFE_INTEGER);
|
|
2147
|
+
const pointer = options.path ?? "";
|
|
2148
|
+
const parsed = parseJsonBody(bodyText(record, options.body));
|
|
2149
|
+
const selected = resolvePointer(parsed, pointer);
|
|
2150
|
+
const rows = inspectionRows(selected, pointer, limit, maxRows);
|
|
2151
|
+
return {
|
|
2152
|
+
rows: rows.values,
|
|
2153
|
+
totalRows: rows.total,
|
|
2154
|
+
rowsTruncated: rows.total > rows.values.length
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
function inspectionRows(selected, pointer, valueLimit, maxRows) {
|
|
2158
|
+
if (Array.isArray(selected)) {
|
|
2159
|
+
const values = selected.slice(0, maxRows).map(
|
|
2160
|
+
(item, index) => inspectionRow(`${pointer}/${String(index)}`, item, valueLimit)
|
|
2161
|
+
);
|
|
2162
|
+
return { values, total: selected.length };
|
|
2163
|
+
}
|
|
2164
|
+
if (!isRecord4(selected)) {
|
|
2165
|
+
return { values: [inspectionRow(pointer, selected, valueLimit)], total: 1 };
|
|
2166
|
+
}
|
|
2167
|
+
return objectInspectionRows(selected, pointer, valueLimit, maxRows);
|
|
2168
|
+
}
|
|
2169
|
+
function objectInspectionRows(selected, pointer, valueLimit, maxRows) {
|
|
2170
|
+
const values = [];
|
|
2171
|
+
let total = 0;
|
|
2172
|
+
for (const key in selected) {
|
|
2173
|
+
if (!Object.hasOwn(selected, key)) {
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
if (values.length < maxRows) {
|
|
2177
|
+
values.push(inspectionRow(`${pointer}/${escapePointerToken(key)}`, selected[key], valueLimit));
|
|
2178
|
+
}
|
|
2179
|
+
total += 1;
|
|
2180
|
+
}
|
|
2181
|
+
return { values, total };
|
|
2182
|
+
}
|
|
2183
|
+
function searchTraceRecords(records, searchTerm, options) {
|
|
2184
|
+
const term = searchTerm.trim().toLowerCase();
|
|
2185
|
+
if (term.length === 0) {
|
|
2186
|
+
throw new Error("search text must not be empty");
|
|
2187
|
+
}
|
|
2188
|
+
const limit = positive("limit", options.limit);
|
|
2189
|
+
const previewLength = positive("preview length", options.previewLength ?? 128);
|
|
2190
|
+
const matches = [];
|
|
2191
|
+
for (const record of records) {
|
|
2192
|
+
for (const side of selectedSides(options.body)) {
|
|
2193
|
+
const remaining = limit - matches.length;
|
|
2194
|
+
matches.push(...searchBody(record, side, term, remaining, previewLength));
|
|
2195
|
+
if (matches.length >= limit) {
|
|
2196
|
+
return matches;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
return matches;
|
|
2201
|
+
}
|
|
2202
|
+
function searchBody(record, side, term, limit, previewLength) {
|
|
2203
|
+
if (limit <= 0) {
|
|
2204
|
+
return [];
|
|
2205
|
+
}
|
|
2206
|
+
const body = bodyText(record, side);
|
|
2207
|
+
const parsed = tryParseJson(body);
|
|
2208
|
+
return parsed === void 0 ? plainTextMatches(record, side, body, term, limit, previewLength) : jsonSearchMatches(record, side, parsed.value, term, limit, previewLength);
|
|
2209
|
+
}
|
|
2210
|
+
function plainTextMatches(record, side, text, term, limit, previewLength) {
|
|
2211
|
+
const matches = [];
|
|
2212
|
+
const lowerText = text.toLowerCase();
|
|
2213
|
+
let offset = lowerText.indexOf(term);
|
|
2214
|
+
while (offset >= 0 && matches.length < limit) {
|
|
2215
|
+
const start = Math.max(0, offset - 32);
|
|
2216
|
+
matches.push(toSearchMatch(record, side, "", text.slice(start, start + previewLength).replaceAll(/[\r\n\t]/g, " "), offset));
|
|
2217
|
+
offset = lowerText.indexOf(term, offset + Math.max(1, term.length));
|
|
2218
|
+
}
|
|
2219
|
+
return matches;
|
|
2220
|
+
}
|
|
2221
|
+
function jsonSearchMatches(record, side, root, term, limit, previewLength) {
|
|
2222
|
+
const matches = [];
|
|
2223
|
+
const stack = [{ path: "", value: root }];
|
|
2224
|
+
while (stack.length > 0 && matches.length < limit) {
|
|
2225
|
+
const entry = stack.pop();
|
|
2226
|
+
if (entry === void 0) {
|
|
2227
|
+
break;
|
|
2228
|
+
}
|
|
2229
|
+
inspectJsonSearchEntry(record, side, entry, term, limit, previewLength, matches, stack);
|
|
2230
|
+
}
|
|
2231
|
+
return matches.slice(0, limit);
|
|
2232
|
+
}
|
|
2233
|
+
function inspectJsonSearchEntry(record, side, entry, term, limit, previewLength, matches, stack) {
|
|
2234
|
+
if (typeof entry.value === "object" && entry.value !== null) {
|
|
2235
|
+
pushJsonChildren(record, side, entry, term, limit, previewLength, matches, stack);
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const text = jsonScalarSearchText(entry.value);
|
|
2239
|
+
if (text.toLowerCase().includes(term)) {
|
|
2240
|
+
matches.push(toSearchMatch(record, side, entry.path, text.slice(0, previewLength)));
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
function pushJsonChildren(record, side, entry, term, limit, previewLength, matches, stack) {
|
|
2244
|
+
if (!Array.isArray(entry.value) && !isRecord4(entry.value)) {
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const entries = Array.isArray(entry.value) ? entry.value.map((item, index) => [String(index), item]) : Object.entries(entry.value);
|
|
2248
|
+
for (const [key, value] of entries.reverse()) {
|
|
2249
|
+
if (matches.length >= limit) {
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
const path = `${entry.path}/${escapePointerToken(key)}`;
|
|
2253
|
+
if (key.toLowerCase().includes(term)) {
|
|
2254
|
+
matches.push(toSearchMatch(record, side, path, jsonValueText(value, previewLength)));
|
|
2255
|
+
}
|
|
2256
|
+
stack.push({ path, value });
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
function toSearchMatch(record, side, path, preview, offset) {
|
|
2260
|
+
return {
|
|
2261
|
+
sessionId: record.sessionId,
|
|
2262
|
+
requestId: record.requestId,
|
|
2263
|
+
timestamp: record.event.timestamp,
|
|
2264
|
+
method: record.event.method,
|
|
2265
|
+
normalizedUrl: record.event.normalizedUrl,
|
|
2266
|
+
status: record.event.status,
|
|
2267
|
+
body: side,
|
|
2268
|
+
path,
|
|
2269
|
+
...offset === void 0 ? {} : { offset },
|
|
2270
|
+
preview
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
function selectedSides(body) {
|
|
2274
|
+
return body === "both" ? ["request", "response"] : [body];
|
|
2275
|
+
}
|
|
2276
|
+
function bodyText(record, body) {
|
|
2277
|
+
return body === "request" ? record.event.requestBodyPreview : record.event.responseBodyPreview;
|
|
2278
|
+
}
|
|
2279
|
+
function parseJsonBody(body) {
|
|
2280
|
+
try {
|
|
2281
|
+
return JSON.parse(body);
|
|
2282
|
+
} catch (error) {
|
|
2283
|
+
throw new Error("Saved trace body does not contain valid JSON", { cause: error });
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
function tryParseJson(body) {
|
|
2287
|
+
try {
|
|
2288
|
+
return { value: JSON.parse(body) };
|
|
2289
|
+
} catch {
|
|
2290
|
+
return void 0;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
function decodePointerToken(token) {
|
|
2294
|
+
if (/~(?:[^01]|$)/.test(token)) {
|
|
2295
|
+
throw new Error("Invalid JSON Pointer escape");
|
|
2296
|
+
}
|
|
2297
|
+
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
2298
|
+
}
|
|
2299
|
+
function pointerTokens(pointer) {
|
|
2300
|
+
if (pointer === "") {
|
|
2301
|
+
return [];
|
|
2302
|
+
}
|
|
2303
|
+
if (!pointer.startsWith("/")) {
|
|
2304
|
+
throw new Error("JSON Pointer must be empty or start with /");
|
|
2305
|
+
}
|
|
2306
|
+
return pointer.slice(1).split("/").map(decodePointerToken);
|
|
2307
|
+
}
|
|
2308
|
+
function resolvePointer(root, pointer) {
|
|
2309
|
+
let current = root;
|
|
2310
|
+
for (const token of pointerTokens(pointer)) {
|
|
2311
|
+
current = resolvePointerToken(current, token, pointer);
|
|
2312
|
+
}
|
|
2313
|
+
return current;
|
|
2314
|
+
}
|
|
2315
|
+
function resolvePointerToken(current, token, pointer) {
|
|
2316
|
+
if (Array.isArray(current)) {
|
|
2317
|
+
const index = Number(token);
|
|
2318
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
|
|
2319
|
+
throw new Error(`JSON Pointer path "${pointer}" not found`);
|
|
2320
|
+
}
|
|
2321
|
+
return current[index];
|
|
2322
|
+
}
|
|
2323
|
+
if (isRecord4(current) && Object.hasOwn(current, token)) {
|
|
2324
|
+
return current[token];
|
|
2325
|
+
}
|
|
2326
|
+
throw new Error(`JSON Pointer path "${pointer}" not found`);
|
|
2327
|
+
}
|
|
2328
|
+
function escapePointerToken(token) {
|
|
2329
|
+
return token.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
2330
|
+
}
|
|
2331
|
+
function inspectionRow(path, value, limit) {
|
|
2332
|
+
return { path, type: jsonType(value), value: jsonValueText(value, limit) };
|
|
2333
|
+
}
|
|
2334
|
+
function jsonType(value) {
|
|
2335
|
+
if (value === null) {
|
|
2336
|
+
return "null";
|
|
2337
|
+
}
|
|
2338
|
+
if (Array.isArray(value)) {
|
|
2339
|
+
return "array";
|
|
2340
|
+
}
|
|
2341
|
+
return typeof value;
|
|
2342
|
+
}
|
|
2343
|
+
function jsonValueText(value, limit) {
|
|
2344
|
+
if (Array.isArray(value)) {
|
|
2345
|
+
return `items=${String(value.length)}`;
|
|
2346
|
+
}
|
|
2347
|
+
if (typeof value === "object" && value !== null) {
|
|
2348
|
+
return `keys=${String(Object.keys(value).length)}`;
|
|
2349
|
+
}
|
|
2350
|
+
if (typeof value === "string") {
|
|
2351
|
+
return value.slice(0, limit);
|
|
2352
|
+
}
|
|
2353
|
+
if (value === null) {
|
|
2354
|
+
return "null";
|
|
2355
|
+
}
|
|
2356
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
2357
|
+
return String(value);
|
|
2358
|
+
}
|
|
2359
|
+
return "";
|
|
2360
|
+
}
|
|
2361
|
+
function jsonScalarSearchText(value) {
|
|
2362
|
+
if (typeof value === "string") {
|
|
2363
|
+
return value;
|
|
2364
|
+
}
|
|
2365
|
+
if (value === null) {
|
|
2366
|
+
return "null";
|
|
2367
|
+
}
|
|
2368
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
2369
|
+
return String(value);
|
|
2370
|
+
}
|
|
2371
|
+
return "";
|
|
2372
|
+
}
|
|
2373
|
+
function positive(name, value) {
|
|
2374
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
2375
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
2376
|
+
}
|
|
2377
|
+
return value;
|
|
2378
|
+
}
|
|
2379
|
+
function isRecord4(value) {
|
|
2380
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// src/cli/session-commands.ts
|
|
2384
|
+
function registerSessionCommands(program) {
|
|
2385
|
+
const session = program.command("session").description("inspect saved live trace sessions");
|
|
2386
|
+
session.command("list").description("list active trace sessions").action(async () => {
|
|
2387
|
+
writeJson({ sessions: await listTraceSessions() });
|
|
2388
|
+
});
|
|
2389
|
+
session.command("events <sessionId>").description("list compact events saved for a trace session").option("--method <method>", "filter by HTTP method").option("--status <code>", "filter by HTTP status", parseIntOption).option("--path <text>", "filter by URL/path substring").option("--limit <count>", "maximum events to print", parseIntOption).action(async (sessionId, _options, command) => {
|
|
2390
|
+
await runEvents(sessionId, command.opts());
|
|
2391
|
+
});
|
|
2392
|
+
session.command("search <sessionId> <text>").description("search saved request and response bodies").option("--body <side>", "request, response, or both", parseSearchBodySide, "both").option("--limit <count>", "maximum matches to print", parseIntOption).option("--length <chars>", "maximum preview characters", parseIntOption).action(async (sessionId, text, _options, command) => {
|
|
2393
|
+
await runSearch(sessionId, text, command.opts());
|
|
2394
|
+
});
|
|
2395
|
+
session.command("body <sessionId> <requestId>").description("inspect a saved JSON request or response body").option("--body <side>", "request or response", parseBodySide, "response").option("--path <pointer>", "JSON Pointer inside the saved body").option("--limit <chars>", "maximum characters per value", parseIntOption).option("--rows <count>", "maximum structure rows to print", parseIntOption).action(async (sessionId, requestId, _options, command) => {
|
|
2396
|
+
await runBody(sessionId, requestId, command.opts());
|
|
2397
|
+
});
|
|
2398
|
+
session.command("prune").description("remove expired trace event files").action(async () => {
|
|
2399
|
+
writeJson({ removed: await pruneTraceSessions() });
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
async function runEvents(sessionId, options) {
|
|
2403
|
+
const limit = positive2("--limit", options.limit, 50);
|
|
2404
|
+
const status = optionalHttpStatus(options.status);
|
|
2405
|
+
const events = [];
|
|
2406
|
+
await visitTraceEvents(sessionId, (record) => {
|
|
2407
|
+
if (matchesEvent(record, options, status)) {
|
|
2408
|
+
events.push(compactTraceEvent(record));
|
|
2409
|
+
}
|
|
2410
|
+
return events.length < limit;
|
|
2411
|
+
});
|
|
2412
|
+
writeJson({ sessionId, events });
|
|
2413
|
+
}
|
|
2414
|
+
async function runSearch(sessionId, text, options) {
|
|
2415
|
+
const limit = positive2("--limit", options.limit, 20);
|
|
2416
|
+
const body = options.body ?? "both";
|
|
2417
|
+
const previewLength = positive2("--length", options.length, 128);
|
|
2418
|
+
const matches = [];
|
|
2419
|
+
await visitTraceEvents(sessionId, (record) => {
|
|
2420
|
+
const found = searchTraceRecords([record], text, {
|
|
2421
|
+
body,
|
|
2422
|
+
limit: limit - matches.length,
|
|
2423
|
+
previewLength
|
|
2424
|
+
});
|
|
2425
|
+
matches.push(...found);
|
|
2426
|
+
return matches.length < limit;
|
|
2427
|
+
});
|
|
2428
|
+
writeJson({ sessionId, matches });
|
|
2429
|
+
}
|
|
2430
|
+
async function runBody(sessionId, requestId, options) {
|
|
2431
|
+
const record = await readTraceEvent(sessionId, requestId);
|
|
2432
|
+
const body = options.body ?? "response";
|
|
2433
|
+
const inspection = inspectTraceBodyResult(record, {
|
|
2434
|
+
body,
|
|
2435
|
+
...options.path === void 0 ? {} : { path: options.path },
|
|
2436
|
+
limit: positive2("--limit", options.limit, 4e3),
|
|
2437
|
+
maxRows: positive2("--rows", options.rows, 100)
|
|
2438
|
+
});
|
|
2439
|
+
writeJson({
|
|
2440
|
+
sessionId,
|
|
2441
|
+
requestId,
|
|
2442
|
+
body,
|
|
2443
|
+
format: body === "request" ? record.requestBodyFormat : record.responseBodyFormat,
|
|
2444
|
+
...inspection
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
function matchesEvent(record, options, status) {
|
|
2448
|
+
const method = options.method?.trim().toUpperCase();
|
|
2449
|
+
const path = options.path?.trim().toLowerCase();
|
|
2450
|
+
if (method !== void 0 && record.event.method !== method) {
|
|
2451
|
+
return false;
|
|
2452
|
+
}
|
|
2453
|
+
if (status !== void 0 && record.event.status !== status) {
|
|
2454
|
+
return false;
|
|
2455
|
+
}
|
|
2456
|
+
return path === void 0 || record.event.normalizedUrl.toLowerCase().includes(path);
|
|
2457
|
+
}
|
|
2458
|
+
function parseIntOption(value) {
|
|
2459
|
+
const trimmed = value.trim();
|
|
2460
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
2461
|
+
throw new Error(`Expected an integer but received "${value}"`);
|
|
2462
|
+
}
|
|
2463
|
+
const parsed = Number(trimmed);
|
|
2464
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
2465
|
+
throw new Error(`Expected a safe integer but received "${value}"`);
|
|
2466
|
+
}
|
|
2467
|
+
return parsed;
|
|
2468
|
+
}
|
|
2469
|
+
function positive2(name, value, fallback) {
|
|
2470
|
+
const resolved = value ?? fallback;
|
|
2471
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
2472
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
2473
|
+
}
|
|
2474
|
+
return resolved;
|
|
2475
|
+
}
|
|
2476
|
+
function optionalHttpStatus(value) {
|
|
2477
|
+
if (value === void 0) {
|
|
2478
|
+
return void 0;
|
|
2479
|
+
}
|
|
2480
|
+
if (value < 100 || value > 599) {
|
|
2481
|
+
throw new Error("--status must be an integer from 100 through 599");
|
|
2482
|
+
}
|
|
2483
|
+
return value;
|
|
2484
|
+
}
|
|
2485
|
+
function parseSearchBodySide(value) {
|
|
2486
|
+
if (value === "request" || value === "response" || value === "both") {
|
|
2487
|
+
return value;
|
|
2488
|
+
}
|
|
2489
|
+
throw new Error(`Invalid --body: "${value}" \u2014 expected request, response, or both.`);
|
|
2490
|
+
}
|
|
2491
|
+
function parseBodySide(value) {
|
|
2492
|
+
if (value === "request" || value === "response") {
|
|
2493
|
+
return value;
|
|
2494
|
+
}
|
|
2495
|
+
throw new Error(`Invalid --body: "${value}" \u2014 expected request or response.`);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
1396
2498
|
// src/cli/program.ts
|
|
1397
2499
|
async function main(argv) {
|
|
1398
2500
|
const program = new Command();
|
|
1399
|
-
program.name("cf-live-trace").description("Inject a runtime HTTP trace hook into a Cloud Foundry Node.js app and stream request/response events").option("-r, --region <key>", "CF region key (default: current cf target)").option("--api-endpoint <url>", "Explicit CF API endpoint").option("-o, --org <name>", "CF org name (default: current cf target)").option("-s, --space <name>", "CF space name (default: current cf target)").
|
|
2501
|
+
program.name("cf-live-trace").description("Inject a runtime HTTP trace hook into a Cloud Foundry Node.js app and stream request/response events").option("-r, --region <key>", "CF region key (default: current cf target)").option("--api-endpoint <url>", "Explicit CF API endpoint").option("-o, --org <name>", "CF org name (default: current cf target)").option("-s, --space <name>", "CF space name (default: current cf target)").option("-a, --app <name>", "CF app name").option("--email <value>", "SAP email (default: SAP_EMAIL)").option("--password <value>", "SAP password (default: SAP_PASSWORD)").option("-i, --instance <index>", "CF app instance index (default: 0)").option("--cf-home <dir>", "Use an existing CF_HOME instead of a temporary one").option("--cf-command <path>", "CF CLI executable or test shim").option("--duration <seconds>", "Stop after N seconds").option("--max-events <count>", "Stop after N trace events").option("--max-body-bytes <bytes>", "Maximum request/response capture bytes; must be greater than 0", "4096").option("--no-capture-headers", "Do not capture request/response headers").option("--no-capture-request-body", "Do not capture request body previews").option("--no-capture-response-body", "Do not capture response body previews").option("--no-uninstall-on-exit", "Disable the runtime hook instead of uninstalling it on exit").option("--format <format>", "Output format: ndjson, summary, json", "ndjson").option("--quiet", "Suppress progress messages on stderr").action(async (flags) => {
|
|
1400
2502
|
await runTraceCommand(await buildRunOptionsWithCurrentTarget(flags, process3.env));
|
|
1401
2503
|
});
|
|
2504
|
+
registerSessionCommands(program);
|
|
1402
2505
|
await program.parseAsync([...argv]);
|
|
1403
2506
|
}
|
|
1404
2507
|
async function runTraceCommand(options) {
|
|
1405
2508
|
const cfHome = await resolveCfHome(options);
|
|
2509
|
+
let retention;
|
|
1406
2510
|
try {
|
|
1407
|
-
const
|
|
2511
|
+
const traceSession = await createTraceSession({ target: options.target });
|
|
2512
|
+
retention = startRetentionPruner(options);
|
|
2513
|
+
writeSessionHints(traceSession, options);
|
|
2514
|
+
const output = { count: 0, events: [] };
|
|
1408
2515
|
const eventLimit = createEventLimit(options);
|
|
2516
|
+
const runtimeError = createRuntimeErrorStopWaiter();
|
|
1409
2517
|
const session = new LiveTraceSession({
|
|
1410
2518
|
target: { ...options.target, cfHomeDir: cfHome.path },
|
|
1411
2519
|
onState: (event) => {
|
|
1412
2520
|
if (!options.quiet) {
|
|
1413
2521
|
writeProgress(event);
|
|
1414
2522
|
}
|
|
2523
|
+
runtimeError.report(event);
|
|
1415
2524
|
},
|
|
1416
2525
|
onLog: (message) => {
|
|
1417
2526
|
if (!options.quiet) {
|
|
1418
2527
|
writeLog(message);
|
|
1419
2528
|
}
|
|
1420
2529
|
},
|
|
1421
|
-
onEvents: (batch) => {
|
|
1422
|
-
handleEvents(batch, options,
|
|
1423
|
-
eventLimit.check(
|
|
2530
|
+
onEvents: async (batch) => {
|
|
2531
|
+
await handleEvents(batch, options, output, traceSession);
|
|
2532
|
+
eventLimit.check(output.count);
|
|
1424
2533
|
}
|
|
1425
2534
|
});
|
|
1426
|
-
await runUntilStopped(session, options, eventLimit);
|
|
2535
|
+
await runUntilStopped(session, options, eventLimit, runtimeError);
|
|
1427
2536
|
if (options.format === "json") {
|
|
1428
|
-
writeJson({ events });
|
|
2537
|
+
writeJson({ sessionId: traceSession.sessionId, events: output.events });
|
|
1429
2538
|
}
|
|
1430
2539
|
} finally {
|
|
2540
|
+
retention?.cleanup();
|
|
1431
2541
|
await cfHome.dispose();
|
|
1432
2542
|
}
|
|
1433
2543
|
}
|
|
1434
|
-
function
|
|
1435
|
-
|
|
1436
|
-
|
|
2544
|
+
function startRetentionPruner(options) {
|
|
2545
|
+
const timer = setInterval(() => {
|
|
2546
|
+
void pruneTraceSessions().catch((error) => {
|
|
2547
|
+
if (!options.quiet) {
|
|
2548
|
+
writeLog(`session: retention cleanup failed: ${formatError2(error)}`);
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
}, 6e4);
|
|
2552
|
+
timer.unref();
|
|
2553
|
+
return {
|
|
2554
|
+
cleanup() {
|
|
2555
|
+
clearInterval(timer);
|
|
2556
|
+
}
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
async function handleEvents(batch, options, output, traceSession) {
|
|
2560
|
+
const remaining = options.limits.maxEvents === void 0 ? batch.length : Math.max(0, options.limits.maxEvents - output.count);
|
|
2561
|
+
for (const event of batch.slice(0, remaining)) {
|
|
2562
|
+
const record = await writeTraceEvent(traceSession, event);
|
|
2563
|
+
const compact = compactTraceEvent(record);
|
|
2564
|
+
output.count += 1;
|
|
2565
|
+
if (options.format === "json") {
|
|
2566
|
+
output.events.push(compact);
|
|
2567
|
+
}
|
|
1437
2568
|
if (options.format === "ndjson") {
|
|
1438
|
-
writeJsonLine(
|
|
2569
|
+
writeJsonLine(compact);
|
|
1439
2570
|
}
|
|
1440
2571
|
if (options.format === "summary") {
|
|
1441
|
-
writeSummaryLine(
|
|
2572
|
+
writeSummaryLine(compact);
|
|
1442
2573
|
}
|
|
1443
2574
|
}
|
|
1444
2575
|
}
|
|
1445
|
-
|
|
2576
|
+
function writeSessionHints(traceSession, options) {
|
|
2577
|
+
if (options.quiet) {
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
writeLog(`session: id=${traceSession.sessionId} backups=${traceSession.directory} ttl=2h`);
|
|
2581
|
+
writeLog(`session: list events with \`cf-live-trace session events ${traceSession.sessionId}\``);
|
|
2582
|
+
writeLog(`session: search bodies with \`cf-live-trace session search ${traceSession.sessionId} <text>\``);
|
|
2583
|
+
writeLog(`session: inspect JSON with \`cf-live-trace session body ${traceSession.sessionId} <requestId> --body response --path / --limit 4000\``);
|
|
2584
|
+
}
|
|
2585
|
+
async function runUntilStopped(session, options, eventLimit, runtimeError) {
|
|
1446
2586
|
const abort = createAbortPromise();
|
|
1447
2587
|
const duration = createDurationStopWaiter(options.limits.durationMs);
|
|
1448
2588
|
let stopReason = "user";
|
|
1449
2589
|
let failed = false;
|
|
1450
2590
|
try {
|
|
1451
2591
|
await session.start(options.trace);
|
|
1452
|
-
stopReason = await waitForStop([abort, eventLimit, duration]);
|
|
2592
|
+
stopReason = await waitForStop([abort, eventLimit, duration, runtimeError]);
|
|
1453
2593
|
} catch (error) {
|
|
1454
2594
|
failed = true;
|
|
1455
2595
|
throw error;
|
|
@@ -1457,6 +2597,7 @@ async function runUntilStopped(session, options, eventLimit) {
|
|
|
1457
2597
|
abort.cleanup();
|
|
1458
2598
|
duration.cleanup();
|
|
1459
2599
|
eventLimit.cleanup();
|
|
2600
|
+
runtimeError.cleanup();
|
|
1460
2601
|
await session.stop({ uninstallRuntimeHook: options.uninstallOnExit, reason: failed ? "error" : stopReason });
|
|
1461
2602
|
}
|
|
1462
2603
|
}
|
|
@@ -1512,6 +2653,36 @@ function createNeverStopWaiter() {
|
|
|
1512
2653
|
}
|
|
1513
2654
|
};
|
|
1514
2655
|
}
|
|
2656
|
+
function createRuntimeErrorStopWaiter() {
|
|
2657
|
+
let hasStreamed = false;
|
|
2658
|
+
let settled = false;
|
|
2659
|
+
let rejectStop = () => {
|
|
2660
|
+
return;
|
|
2661
|
+
};
|
|
2662
|
+
const promise = new Promise((_resolve, reject) => {
|
|
2663
|
+
rejectStop = reject;
|
|
2664
|
+
});
|
|
2665
|
+
promise.catch(() => {
|
|
2666
|
+
return;
|
|
2667
|
+
});
|
|
2668
|
+
return {
|
|
2669
|
+
promise,
|
|
2670
|
+
report: (event) => {
|
|
2671
|
+
if (event.state === "streaming") {
|
|
2672
|
+
hasStreamed = true;
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
if (event.state !== "error" || !hasStreamed || settled) {
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
settled = true;
|
|
2679
|
+
rejectStop(new Error(event.message));
|
|
2680
|
+
},
|
|
2681
|
+
cleanup: () => {
|
|
2682
|
+
settled = true;
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
1515
2686
|
function createEventLimit(options) {
|
|
1516
2687
|
if (options.limits.maxEvents === void 0) {
|
|
1517
2688
|
return {
|
|
@@ -1554,6 +2725,10 @@ async function resolveCfHome(options) {
|
|
|
1554
2725
|
}
|
|
1555
2726
|
};
|
|
1556
2727
|
}
|
|
2728
|
+
function formatError2(error) {
|
|
2729
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2730
|
+
return message.trim().length > 0 ? message.trim() : "Unknown error";
|
|
2731
|
+
}
|
|
1557
2732
|
|
|
1558
2733
|
// src/cli.ts
|
|
1559
2734
|
try {
|