@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/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
- return error["message"] instanceof Error ? error["message"].message : "";
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
- function truncatePreview(preview, maxChars) {
517
- if (maxChars <= 0) {
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
- if (preview.length <= maxChars) {
527
+ const encoded = Buffer2.from(preview);
528
+ if (encoded.length <= maxBytes) {
521
529
  return { preview, truncated: false };
522
530
  }
523
- return { preview: preview.slice(0, maxChars), truncated: true };
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 = 1;
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 byteLength = (text) => BufferCtor ? BufferCtor.byteLength(text) : text.length;
688
- const appendPreview = (current, chunk, enabled) => {
689
- if (!enabled) return current;
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
- if (state.options.maxBodyBytes <= 0) return current + text;
692
- if (current.length >= state.options.maxBodyBytes) return current;
693
- return (current + text).slice(0, state.options.maxBodyBytes);
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
- let requestPreview = '';
711
- let responsePreview = '';
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
- const text = chunkText(args[0]);
719
- requestBytes += byteLength(text);
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
- const text = chunkText(chunk);
726
- responseBytes += byteLength(text);
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
- const text = chunkText(chunk);
733
- responseBytes += byteLength(text);
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 && requestPreview.length >= state.options.maxBodyBytes,
762
- responseBodyTruncated: state.options.maxBodyBytes > 0 && responsePreview.length >= state.options.maxBodyBytes,
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, maxChars) => {
867
+ const limitPreview = (event, previewKey, truncatedKey, maxBytes) => {
787
868
  const preview = event[previewKey];
788
- if (maxChars <= 0 || typeof preview !== 'string' || preview.length <= maxChars) return event;
789
- return { ...event, [previewKey]: preview.slice(0, maxChars), [truncatedKey]: true };
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
- state.options = { ...state.options, ...options };
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
- const events = state.queue.splice(0, count).map((event) => eventForDrain(event, transportLimit));
824
- return { events, droppedCount: state.droppedCount, queueSize: state.queue.length };
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 requireFn = loadRequire();
832
- if (requireFn) {
833
- const http = requireFn('http');
834
- const https = requireFn('https');
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
- return `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.drainEvents(${String(maxCount)}, ${String(maxTransportBodyBytes)}) ?? { events: [], droppedCount: 0, queueSize: 0 }`;
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 DRAIN_TRANSPORT_BODY_LIMIT = 2e4;
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
- events = [];
1083
+ summaryEvents = [];
960
1084
  consecutiveDrainTimeouts = 0;
961
- drainInFlight = false;
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
- void this.drainTraceEvents(maxBodyBytes);
1184
+ this.startDrain(maxBodyBytes);
1055
1185
  }, DRAIN_INTERVAL_MS);
1056
1186
  }
1057
- async drainTraceEvents(maxBodyBytes) {
1058
- if (this.drainInFlight || this.inspectorClient === void 0 || this.state !== "streaming") {
1187
+ startDrain(maxBodyBytes) {
1188
+ if (this.drainTask !== void 0 || this.inspectorClient === void 0 || this.state !== "streaming") {
1059
1189
  return;
1060
1190
  }
1061
- this.drainInFlight = true;
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.inspectorClient.evaluate(
1064
- buildDrainExpression(DRAIN_BATCH_SIZE, resolveDrainTransportBodyLimit(maxBodyBytes)),
1065
- DRAIN_EVALUATE_TIMEOUT_MS
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
- this.publishDrainedEvents(payload, maxBodyBytes);
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 === 0) {
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.events.push(...drained.events);
1081
- this.events.splice(0, Math.max(0, this.events.length - RUNTIME_QUEUE_SIZE));
1082
- this.options.onEvents?.(drained.events);
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 stopRuntimeTrace(uninstallRuntimeHook) {
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: options.maxBodyBytes ?? 4096,
1187
- runtimeQueueSize: options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE
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 ? Math.min(maxBodyBytes, DRAIN_TRANSPORT_BODY_LIMIT) : DRAIN_TRANSPORT_BODY_LIMIT;
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.isInteger(value) || value <= 0 || String(value) !== raw.trim()) {
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
- if (value === void 0) {
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.isInteger(value) || value < 0 || String(value) !== raw.trim()) {
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
- process2.stdout.write(`${event.timestamp} ${event.method} ${event.normalizedUrl} ${status} ${duration}
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)").requiredOption("-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 preview bytes; 0 keeps unlimited previews locally", "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) => {
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 events = [];
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, events);
1423
- eventLimit.check(events.length);
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 handleEvents(batch, options, events) {
1435
- for (const event of batch) {
1436
- events.push(event);
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(event);
2569
+ writeJsonLine(compact);
1439
2570
  }
1440
2571
  if (options.format === "summary") {
1441
- writeSummaryLine(event);
2572
+ writeSummaryLine(compact);
1442
2573
  }
1443
2574
  }
1444
2575
  }
1445
- async function runUntilStopped(session, options, eventLimit) {
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 {