@saptools/cf-live-trace 0.1.6 → 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
@@ -519,29 +519,48 @@ function extractEvaluateValue(result) {
519
519
  }
520
520
 
521
521
  // src/preview.ts
522
- function truncatePreview(preview, maxChars) {
523
- if (maxChars <= 0) {
522
+ import { Buffer as Buffer2 } from "buffer";
523
+ function truncatePreview(preview, maxBytes) {
524
+ if (maxBytes <= 0) {
524
525
  return { preview, truncated: false };
525
526
  }
526
- if (preview.length <= maxChars) {
527
+ const encoded = Buffer2.from(preview);
528
+ if (encoded.length <= maxBytes) {
527
529
  return { preview, truncated: false };
528
530
  }
529
- 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;
530
545
  }
531
546
 
532
547
  // src/payload.ts
533
548
  var fallbackEventId = 0;
534
549
  function parseDrainResult(payload, options) {
535
550
  if (!isRecord2(payload)) {
536
- return { events: [], droppedCount: 0, queueSize: 0 };
551
+ return { drainId: null, events: [], droppedCount: 0, queueSize: 0 };
537
552
  }
538
553
  const rawEvents = Array.isArray(payload["events"]) ? payload["events"] : [];
539
554
  return {
555
+ drainId: readDrainId(payload["drainId"]),
540
556
  events: rawEvents.map((event) => parseRuntimeEvent(event, options)).filter((event) => event !== null),
541
557
  droppedCount: readNonNegativeNumber(payload["droppedCount"]),
542
558
  queueSize: readNonNegativeNumber(payload["queueSize"])
543
559
  };
544
560
  }
561
+ function readDrainId(value) {
562
+ return typeof value === "string" && /^d\d+$/.test(value) ? value : null;
563
+ }
545
564
  function parseRuntimeEvent(payload, options) {
546
565
  if (!isRecord2(payload)) {
547
566
  return null;
@@ -635,16 +654,20 @@ function isRecord2(value) {
635
654
 
636
655
  // src/runtime-source.ts
637
656
  var CF_LIVE_TRACE_GLOBAL_NAME = "__SAPTOOLS_CF_LIVE_TRACE__";
638
- var CF_LIVE_TRACE_RUNTIME_VERSION = 2;
657
+ var CF_LIVE_TRACE_RUNTIME_VERSION = 3;
639
658
  var CF_LIVE_TRACE_RUNTIME_SOURCE = `
640
659
  (() => {
641
660
  const name = '${CF_LIVE_TRACE_GLOBAL_NAME}';
642
661
  const runtimeVersion = ${String(CF_LIVE_TRACE_RUNTIME_VERSION)};
643
662
  const existing = globalThis[name];
644
663
  if (existing && typeof existing.version === 'number' && existing.version >= runtimeVersion) return existing;
664
+ let staleCleanup = null;
645
665
  if (existing && typeof existing.uninstall === 'function') {
646
666
  try {
647
- existing.uninstall();
667
+ const cleanup = existing.uninstall();
668
+ if (cleanup && typeof cleanup.then === 'function') {
669
+ staleCleanup = Promise.resolve(cleanup).catch(() => undefined);
670
+ }
648
671
  } catch {}
649
672
  }
650
673
  let BufferCtor = globalThis.Buffer;
@@ -657,7 +680,9 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
657
680
  droppedCount: 0,
658
681
  originals: {},
659
682
  seen: new WeakSet(),
660
- nextId: 1
683
+ nextId: 1,
684
+ nextDrainId: 1,
685
+ pendingDrain: null
661
686
  };
662
687
  const loadRequire = () => {
663
688
  if (typeof require === 'function') return require;
@@ -690,13 +715,62 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
690
715
  if (chunk instanceof Uint8Array && BufferCtor) return BufferCtor.from(chunk).toString('utf8');
691
716
  return '';
692
717
  };
693
- const byteLength = (text) => BufferCtor ? BufferCtor.byteLength(text) : text.length;
694
- const appendPreview = (current, chunk, enabled) => {
695
- 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
+ }
696
753
  const text = chunkText(chunk);
697
- if (state.options.maxBodyBytes <= 0) return current + text;
698
- if (current.length >= state.options.maxBodyBytes) return current;
699
- 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 };
700
774
  };
701
775
  const enqueue = (event) => {
702
776
  if (state.queue.length >= state.options.maxEvents) {
@@ -713,31 +787,30 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
713
787
  const traceId = String(state.nextId++);
714
788
  let requestBytes = 0;
715
789
  let responseBytes = 0;
716
- let requestPreview = '';
717
- let responsePreview = '';
790
+ const requestPreviewChunks = [];
791
+ const responsePreviewChunks = [];
792
+ let requestPreviewBytes = 0;
793
+ let responsePreviewBytes = 0;
718
794
  let finished = false;
719
795
  const originalReqEmit = req.emit;
720
796
  const originalWrite = res.write;
721
797
  const originalEnd = res.end;
722
798
  req.emit = function patchedReqEmit(eventName, ...args) {
723
799
  if (eventName === 'data' && args[0] !== undefined) {
724
- const text = chunkText(args[0]);
725
- requestBytes += byteLength(text);
726
- requestPreview = appendPreview(requestPreview, args[0], state.options.captureRequestBody);
800
+ requestBytes += chunkByteLength(args[0]);
801
+ requestPreviewBytes = appendPreview(requestPreviewChunks, requestPreviewBytes, args[0], state.options.captureRequestBody);
727
802
  }
728
803
  return originalReqEmit.apply(this, [eventName, ...args]);
729
804
  };
730
805
  res.write = function patchedWrite(chunk, ...args) {
731
- const text = chunkText(chunk);
732
- responseBytes += byteLength(text);
733
- responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
806
+ responseBytes += chunkByteLength(chunk);
807
+ responsePreviewBytes = appendPreview(responsePreviewChunks, responsePreviewBytes, chunk, state.options.captureResponseBody);
734
808
  return originalWrite.apply(this, [chunk, ...args]);
735
809
  };
736
810
  res.end = function patchedEnd(chunk, ...args) {
737
811
  if (chunk !== undefined) {
738
- const text = chunkText(chunk);
739
- responseBytes += byteLength(text);
740
- responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
812
+ responseBytes += chunkByteLength(chunk);
813
+ responsePreviewBytes = appendPreview(responsePreviewChunks, responsePreviewBytes, chunk, state.options.captureResponseBody);
741
814
  }
742
815
  return originalEnd.apply(this, [chunk, ...args]);
743
816
  };
@@ -748,6 +821,8 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
748
821
  res.write = originalWrite;
749
822
  res.end = originalEnd;
750
823
  const rawUrl = initialUrl || String(req.url || '');
824
+ const requestPreview = previewText(requestPreviewChunks, requestPreviewBytes);
825
+ const responsePreview = previewText(responsePreviewChunks, responsePreviewBytes);
751
826
  enqueue({
752
827
  id: traceId,
753
828
  timestamp: new Date().toISOString(),
@@ -762,10 +837,10 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
762
837
  responseBytes,
763
838
  requestHeaders: toHeaderRecord(req.headers),
764
839
  responseHeaders: toHeaderRecord(typeof res.getHeaders === 'function' ? res.getHeaders() : {}),
765
- requestBodyPreview: requestPreview,
766
- responseBodyPreview: responsePreview,
767
- requestBodyTruncated: state.options.maxBodyBytes > 0 && requestPreview.length >= state.options.maxBodyBytes,
768
- 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,
769
844
  droppedBeforeEvent: state.droppedCount,
770
845
  traceId,
771
846
  correlationId: req.headers && typeof req.headers['x-saptools-trace-id'] === 'string' ? req.headers['x-saptools-trace-id'] : null
@@ -789,10 +864,10 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
789
864
  const numeric = Number(value);
790
865
  return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
791
866
  };
792
- const limitPreview = (event, previewKey, truncatedKey, maxChars) => {
867
+ const limitPreview = (event, previewKey, truncatedKey, maxBytes) => {
793
868
  const preview = event[previewKey];
794
- if (maxChars <= 0 || typeof preview !== 'string' || preview.length <= maxChars) return event;
795
- 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 };
796
871
  };
797
872
  const eventForDrain = (event, maxChars) => {
798
873
  if (!event || typeof event !== 'object') return event;
@@ -801,10 +876,44 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
801
876
  output = limitPreview(output, 'responseBodyPreview', 'responseBodyTruncated', maxChars);
802
877
  return output;
803
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
+ };
804
898
  const api = {
805
899
  version: runtimeVersion,
806
900
  async install(options) {
807
- 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;
808
917
  if (!state.installed) {
809
918
  if (!BufferCtor) {
810
919
  const bufferModule = await loadModule('buffer');
@@ -823,11 +932,19 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
823
932
  state.enabled = false;
824
933
  return api.status();
825
934
  },
826
- drainEvents(maxCount, maxTransportBodyBytes) {
935
+ drainEvents(maxCount, maxTransportBodyBytes, acknowledgedDrainId) {
936
+ acknowledgeDrain(acknowledgedDrainId);
937
+ if (state.pendingDrain) return drainResult();
827
938
  const count = Math.max(0, Math.min(Number(maxCount) || 0, state.queue.length));
828
939
  const transportLimit = toTransportLimit(maxTransportBodyBytes);
829
- const events = state.queue.splice(0, count).map((event) => eventForDrain(event, transportLimit));
830
- 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();
831
948
  },
832
949
  status() {
833
950
  return { installed: state.installed, enabled: state.enabled, queueSize: state.queue.length, droppedCount: state.droppedCount, maxEvents: state.options.maxEvents };
@@ -839,6 +956,8 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
839
956
  if (state.originals.httpServerEmit && http && http.Server) http.Server.prototype.emit = state.originals.httpServerEmit;
840
957
  if (state.originals.httpsServerEmit && https && https.Server) https.Server.prototype.emit = state.originals.httpsServerEmit;
841
958
  state.installed = false;
959
+ state.queue = [];
960
+ state.pendingDrain = null;
842
961
  return api.status();
843
962
  }
844
963
  };
@@ -849,8 +968,9 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
849
968
  function buildInstallExpression(options) {
850
969
  return `${CF_LIVE_TRACE_RUNTIME_SOURCE}.install(${JSON.stringify(options)})`;
851
970
  }
852
- function buildDrainExpression(maxCount, maxTransportBodyBytes) {
853
- 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 }`;
854
974
  }
855
975
  function buildStopExpression(options) {
856
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 }`;
@@ -942,7 +1062,8 @@ var RUNTIME_QUEUE_SIZE = 1e3;
942
1062
  var CONTROL_EVALUATE_TIMEOUT_MS = 5e3;
943
1063
  var DRAIN_EVALUATE_TIMEOUT_MS = 1e4;
944
1064
  var DRAIN_TIMEOUT_RETRY_LIMIT = 3;
945
- var DRAIN_TRANSPORT_BODY_LIMIT = 2e4;
1065
+ var MAX_DRAIN_EVALUATE_TIMEOUT_MS = 6e4;
1066
+ var DRAIN_BODY_BUDGET_BYTES = 8 * 1024 * 1024;
946
1067
  var defaultDependencies = {
947
1068
  prepareCfSession,
948
1069
  ensureSshEnabled,
@@ -959,11 +1080,12 @@ var LiveTraceSession = class {
959
1080
  }
960
1081
  options;
961
1082
  dependencies;
962
- events = [];
1083
+ summaryEvents = [];
963
1084
  consecutiveDrainTimeouts = 0;
964
- drainInFlight = false;
1085
+ drainTask;
965
1086
  inspectorClient;
966
1087
  pollTimer;
1088
+ pendingDrainAcknowledgement = null;
967
1089
  state = "idle";
968
1090
  stopRequested = false;
969
1091
  tunnelHandle;
@@ -972,6 +1094,8 @@ var LiveTraceSession = class {
972
1094
  return;
973
1095
  }
974
1096
  this.stopRequested = false;
1097
+ this.pendingDrainAcknowledgement = null;
1098
+ this.summaryEvents.splice(0);
975
1099
  await this.startRuntimeTrace(resolveStartOptions(options));
976
1100
  }
977
1101
  async stop(options) {
@@ -1057,36 +1181,65 @@ var LiveTraceSession = class {
1057
1181
  this.stopPolling();
1058
1182
  this.consecutiveDrainTimeouts = 0;
1059
1183
  this.pollTimer = this.dependencies.setInterval(() => {
1060
- void this.drainTraceEvents(maxBodyBytes);
1184
+ this.startDrain(maxBodyBytes);
1061
1185
  }, DRAIN_INTERVAL_MS);
1062
1186
  }
1063
- async drainTraceEvents(maxBodyBytes) {
1064
- 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") {
1065
1189
  return;
1066
1190
  }
1067
- 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);
1068
1209
  try {
1069
- const payload = await this.inspectorClient.evaluate(
1070
- buildDrainExpression(DRAIN_BATCH_SIZE, resolveDrainTransportBodyLimit(maxBodyBytes)),
1071
- 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)
1072
1217
  );
1073
1218
  this.consecutiveDrainTimeouts = 0;
1074
- this.publishDrainedEvents(payload, maxBodyBytes);
1219
+ try {
1220
+ await this.publishDrainedEvents(payload, maxBodyBytes);
1221
+ } catch (error) {
1222
+ await this.handleEventHandlerFailure(error);
1223
+ }
1075
1224
  } catch (error) {
1076
1225
  await this.handleDrainFailure(error);
1077
- } finally {
1078
- this.drainInFlight = false;
1079
1226
  }
1080
1227
  }
1081
- publishDrainedEvents(payload, maxBodyBytes) {
1228
+ async publishDrainedEvents(payload, maxBodyBytes) {
1082
1229
  const drained = parseDrainResult(payload, { appId: this.options.target.app, maxBodyBytes });
1083
- 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) {
1084
1238
  return;
1085
1239
  }
1086
- this.events.push(...drained.events);
1087
- this.events.splice(0, Math.max(0, this.events.length - RUNTIME_QUEUE_SIZE));
1088
- this.options.onEvents?.(drained.events);
1089
- 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));
1090
1243
  }
1091
1244
  async handleDrainFailure(error) {
1092
1245
  if (isEvaluateTimeout(error)) {
@@ -1097,11 +1250,19 @@ var LiveTraceSession = class {
1097
1250
  }
1098
1251
  }
1099
1252
  this.log(`Live Trace stream failed for ${this.options.target.app}: ${formatError(error)}`);
1100
- await this.stopRuntimeTrace(false);
1253
+ await this.stopRuntimeTrace(false, false);
1101
1254
  this.postState("error", "Runtime HTTP trace connection was lost.", false, true);
1102
1255
  }
1103
- 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) {
1104
1262
  this.stopPolling();
1263
+ if (waitForDrain) {
1264
+ await this.drainTask;
1265
+ }
1105
1266
  this.consecutiveDrainTimeouts = 0;
1106
1267
  const uninstalled = await this.stopInspectorHook(uninstallRuntimeHook);
1107
1268
  await this.closeInspectorClient();
@@ -1185,12 +1346,20 @@ var LiveTraceStartupError = class extends Error {
1185
1346
  stateMessage;
1186
1347
  };
1187
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
+ }
1188
1357
  return {
1189
1358
  captureHeaders: options.captureHeaders ?? true,
1190
1359
  captureRequestBody: options.captureRequestBody ?? true,
1191
1360
  captureResponseBody: options.captureResponseBody ?? true,
1192
- maxBodyBytes: options.maxBodyBytes ?? 4096,
1193
- runtimeQueueSize: options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE
1361
+ maxBodyBytes,
1362
+ runtimeQueueSize
1194
1363
  };
1195
1364
  }
1196
1365
  function stopLateTunnel(tunnel) {
@@ -1199,7 +1368,16 @@ function stopLateTunnel(tunnel) {
1199
1368
  }
1200
1369
  }
1201
1370
  function resolveDrainTransportBodyLimit(maxBodyBytes) {
1202
- 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);
1203
1381
  }
1204
1382
  function isEvaluateTimeout(error) {
1205
1383
  const message = error instanceof Error ? error.message : String(error);
@@ -1222,8 +1400,569 @@ function formatError(error) {
1222
1400
  const message = error instanceof Error ? error.message : String(error);
1223
1401
  return message.trim().length > 0 ? message.trim() : "Unknown error";
1224
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
+ }
1225
1963
 
1226
1964
  // src/cli/options.ts
1965
+ var MAX_TIMER_SECONDS = 2147483;
1227
1966
  function buildRunOptions2(flags, env) {
1228
1967
  requireRegionOrApi(flags);
1229
1968
  const target = buildTarget(flags, env);
@@ -1250,7 +1989,7 @@ function parsePositiveInteger(raw, label) {
1250
1989
  return void 0;
1251
1990
  }
1252
1991
  const value = Number.parseInt(raw, 10);
1253
- if (!Number.isInteger(value) || value <= 0 || String(value) !== raw.trim()) {
1992
+ if (!Number.isSafeInteger(value) || value <= 0 || String(value) !== raw.trim()) {
1254
1993
  throw new Error(`Invalid ${label}: "${raw}" \u2014 expected a positive integer.`);
1255
1994
  }
1256
1995
  return value;
@@ -1318,6 +2057,9 @@ function buildTarget(flags, env) {
1318
2057
  function buildLimits(flags) {
1319
2058
  const duration = parsePositiveInteger(flags.duration, "--duration");
1320
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
+ }
1321
2063
  return {
1322
2064
  ...duration === void 0 ? {} : { durationMs: duration * 1e3 },
1323
2065
  ...maxEvents === void 0 ? {} : { maxEvents }
@@ -1352,14 +2094,11 @@ function parseInstanceIndex(value) {
1352
2094
  return parsed ?? 0;
1353
2095
  }
1354
2096
  function parseBodyLimit(value) {
1355
- if (value === void 0) {
1356
- return 4096;
1357
- }
1358
- return parseNonNegativeInteger(value, "--max-body-bytes");
2097
+ return parsePositiveInteger(value, "--max-body-bytes") ?? 4096;
1359
2098
  }
1360
2099
  function parseNonNegativeInteger(raw, label) {
1361
2100
  const value = Number.parseInt(raw, 10);
1362
- if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) {
2101
+ if (!Number.isSafeInteger(value) || value < 0 || String(value) !== raw.trim()) {
1363
2102
  throw new Error(`Invalid ${label}: "${raw}" \u2014 expected a non-negative integer.`);
1364
2103
  }
1365
2104
  return value;
@@ -1395,22 +2134,384 @@ function writeLog(message) {
1395
2134
  function writeSummaryLine(event) {
1396
2135
  const status = event.status === null ? "-" : String(event.status);
1397
2136
  const duration = event.durationMs === null ? "-" : `${event.durationMs.toString()}ms`;
1398
- 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}
1399
2139
  `);
1400
2140
  }
1401
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
+
1402
2498
  // src/cli/program.ts
1403
2499
  async function main(argv) {
1404
2500
  const program = new Command();
1405
- 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) => {
1406
2502
  await runTraceCommand(await buildRunOptionsWithCurrentTarget(flags, process3.env));
1407
2503
  });
2504
+ registerSessionCommands(program);
1408
2505
  await program.parseAsync([...argv]);
1409
2506
  }
1410
2507
  async function runTraceCommand(options) {
1411
2508
  const cfHome = await resolveCfHome(options);
2509
+ let retention;
1412
2510
  try {
1413
- const events = [];
2511
+ const traceSession = await createTraceSession({ target: options.target });
2512
+ retention = startRetentionPruner(options);
2513
+ writeSessionHints(traceSession, options);
2514
+ const output = { count: 0, events: [] };
1414
2515
  const eventLimit = createEventLimit(options);
1415
2516
  const runtimeError = createRuntimeErrorStopWaiter();
1416
2517
  const session = new LiveTraceSession({
@@ -1426,30 +2527,61 @@ async function runTraceCommand(options) {
1426
2527
  writeLog(message);
1427
2528
  }
1428
2529
  },
1429
- onEvents: (batch) => {
1430
- handleEvents(batch, options, events);
1431
- eventLimit.check(events.length);
2530
+ onEvents: async (batch) => {
2531
+ await handleEvents(batch, options, output, traceSession);
2532
+ eventLimit.check(output.count);
1432
2533
  }
1433
2534
  });
1434
2535
  await runUntilStopped(session, options, eventLimit, runtimeError);
1435
2536
  if (options.format === "json") {
1436
- writeJson({ events });
2537
+ writeJson({ sessionId: traceSession.sessionId, events: output.events });
1437
2538
  }
1438
2539
  } finally {
2540
+ retention?.cleanup();
1439
2541
  await cfHome.dispose();
1440
2542
  }
1441
2543
  }
1442
- function handleEvents(batch, options, events) {
1443
- for (const event of batch) {
1444
- 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
+ }
1445
2568
  if (options.format === "ndjson") {
1446
- writeJsonLine(event);
2569
+ writeJsonLine(compact);
1447
2570
  }
1448
2571
  if (options.format === "summary") {
1449
- writeSummaryLine(event);
2572
+ writeSummaryLine(compact);
1450
2573
  }
1451
2574
  }
1452
2575
  }
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
+ }
1453
2585
  async function runUntilStopped(session, options, eventLimit, runtimeError) {
1454
2586
  const abort = createAbortPromise();
1455
2587
  const duration = createDurationStopWaiter(options.limits.durationMs);
@@ -1593,6 +2725,10 @@ async function resolveCfHome(options) {
1593
2725
  }
1594
2726
  };
1595
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
+ }
1596
2732
 
1597
2733
  // src/cli.ts
1598
2734
  try {