@saptools/cf-live-trace 0.1.6 → 0.1.9

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/index.js CHANGED
@@ -1,15 +1,19 @@
1
1
  // src/runtime-source.ts
2
2
  var CF_LIVE_TRACE_GLOBAL_NAME = "__SAPTOOLS_CF_LIVE_TRACE__";
3
- var CF_LIVE_TRACE_RUNTIME_VERSION = 2;
3
+ var CF_LIVE_TRACE_RUNTIME_VERSION = 3;
4
4
  var CF_LIVE_TRACE_RUNTIME_SOURCE = `
5
5
  (() => {
6
6
  const name = '${CF_LIVE_TRACE_GLOBAL_NAME}';
7
7
  const runtimeVersion = ${String(CF_LIVE_TRACE_RUNTIME_VERSION)};
8
8
  const existing = globalThis[name];
9
9
  if (existing && typeof existing.version === 'number' && existing.version >= runtimeVersion) return existing;
10
+ let staleCleanup = null;
10
11
  if (existing && typeof existing.uninstall === 'function') {
11
12
  try {
12
- existing.uninstall();
13
+ const cleanup = existing.uninstall();
14
+ if (cleanup && typeof cleanup.then === 'function') {
15
+ staleCleanup = Promise.resolve(cleanup).catch(() => undefined);
16
+ }
13
17
  } catch {}
14
18
  }
15
19
  let BufferCtor = globalThis.Buffer;
@@ -22,7 +26,9 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
22
26
  droppedCount: 0,
23
27
  originals: {},
24
28
  seen: new WeakSet(),
25
- nextId: 1
29
+ nextId: 1,
30
+ nextDrainId: 1,
31
+ pendingDrain: null
26
32
  };
27
33
  const loadRequire = () => {
28
34
  if (typeof require === 'function') return require;
@@ -55,13 +61,62 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
55
61
  if (chunk instanceof Uint8Array && BufferCtor) return BufferCtor.from(chunk).toString('utf8');
56
62
  return '';
57
63
  };
58
- const byteLength = (text) => BufferCtor ? BufferCtor.byteLength(text) : text.length;
59
- const appendPreview = (current, chunk, enabled) => {
60
- if (!enabled) return current;
64
+ const chunkBuffer = (chunk) => {
65
+ if (!BufferCtor || chunk === undefined || chunk === null) return null;
66
+ if (BufferCtor.isBuffer(chunk)) return chunk;
67
+ if (typeof chunk === 'string' || chunk instanceof Uint8Array) return BufferCtor.from(chunk);
68
+ return null;
69
+ };
70
+ const textByteLength = (text) => BufferCtor ? BufferCtor.byteLength(text) : text.length;
71
+ const chunkByteLength = (chunk) => {
72
+ if (chunk === undefined || chunk === null) return 0;
73
+ if (BufferCtor && BufferCtor.isBuffer(chunk)) return chunk.length;
74
+ if (chunk instanceof Uint8Array) return chunk.byteLength;
75
+ return textByteLength(typeof chunk === 'string' ? chunk : '');
76
+ };
77
+ const truncateTextToBytes = (text, maxBytes) => {
78
+ if (!BufferCtor) return text.slice(0, maxBytes);
79
+ const encoded = BufferCtor.from(text);
80
+ if (encoded.length <= maxBytes) return text;
81
+ let boundary = Math.min(maxBytes, encoded.length);
82
+ while (boundary > 0 && encoded[boundary] !== undefined && (encoded[boundary] & 0xc0) === 0x80) {
83
+ boundary -= 1;
84
+ }
85
+ return encoded.subarray(0, boundary).toString('utf8');
86
+ };
87
+ const appendPreview = (chunks, currentBytes, chunk, enabled) => {
88
+ if (!enabled) return currentBytes;
89
+ const maxBytes = Math.floor(Number(state.options.maxBodyBytes) || 0);
90
+ if (maxBytes <= 0) return currentBytes;
91
+ const remaining = maxBytes - currentBytes;
92
+ if (remaining <= 0) return currentBytes;
93
+ const buffer = chunkBuffer(chunk);
94
+ if (buffer) {
95
+ const addition = buffer.subarray(0, remaining);
96
+ chunks.push(addition);
97
+ return currentBytes + addition.length;
98
+ }
61
99
  const text = chunkText(chunk);
62
- if (state.options.maxBodyBytes <= 0) return current + text;
63
- if (current.length >= state.options.maxBodyBytes) return current;
64
- return (current + text).slice(0, state.options.maxBodyBytes);
100
+ const addition = truncateTextToBytes(text, remaining);
101
+ chunks.push(addition);
102
+ return currentBytes + textByteLength(addition);
103
+ };
104
+ const completeUtf8Length = (buffer) => {
105
+ if (!buffer || buffer.length === 0) return 0;
106
+ let start = buffer.length - 1;
107
+ while (start > 0 && (buffer[start] & 0xc0) === 0x80) start -= 1;
108
+ const lead = buffer[start];
109
+ const expected = lead <= 0x7f ? 1 : lead <= 0xdf ? 2 : lead <= 0xef ? 3 : lead <= 0xf7 ? 4 : 1;
110
+ return buffer.length - start < expected ? start : buffer.length;
111
+ };
112
+ const previewText = (chunks, retainedBytes) => {
113
+ if (!BufferCtor) {
114
+ const text = chunks.join('');
115
+ return { text, bytes: textByteLength(text) };
116
+ }
117
+ const buffer = BufferCtor.concat(chunks, retainedBytes);
118
+ const completeBytes = completeUtf8Length(buffer);
119
+ return { text: buffer.subarray(0, completeBytes).toString('utf8'), bytes: completeBytes };
65
120
  };
66
121
  const enqueue = (event) => {
67
122
  if (state.queue.length >= state.options.maxEvents) {
@@ -78,31 +133,30 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
78
133
  const traceId = String(state.nextId++);
79
134
  let requestBytes = 0;
80
135
  let responseBytes = 0;
81
- let requestPreview = '';
82
- let responsePreview = '';
136
+ const requestPreviewChunks = [];
137
+ const responsePreviewChunks = [];
138
+ let requestPreviewBytes = 0;
139
+ let responsePreviewBytes = 0;
83
140
  let finished = false;
84
141
  const originalReqEmit = req.emit;
85
142
  const originalWrite = res.write;
86
143
  const originalEnd = res.end;
87
144
  req.emit = function patchedReqEmit(eventName, ...args) {
88
145
  if (eventName === 'data' && args[0] !== undefined) {
89
- const text = chunkText(args[0]);
90
- requestBytes += byteLength(text);
91
- requestPreview = appendPreview(requestPreview, args[0], state.options.captureRequestBody);
146
+ requestBytes += chunkByteLength(args[0]);
147
+ requestPreviewBytes = appendPreview(requestPreviewChunks, requestPreviewBytes, args[0], state.options.captureRequestBody);
92
148
  }
93
149
  return originalReqEmit.apply(this, [eventName, ...args]);
94
150
  };
95
151
  res.write = function patchedWrite(chunk, ...args) {
96
- const text = chunkText(chunk);
97
- responseBytes += byteLength(text);
98
- responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
152
+ responseBytes += chunkByteLength(chunk);
153
+ responsePreviewBytes = appendPreview(responsePreviewChunks, responsePreviewBytes, chunk, state.options.captureResponseBody);
99
154
  return originalWrite.apply(this, [chunk, ...args]);
100
155
  };
101
156
  res.end = function patchedEnd(chunk, ...args) {
102
157
  if (chunk !== undefined) {
103
- const text = chunkText(chunk);
104
- responseBytes += byteLength(text);
105
- responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
158
+ responseBytes += chunkByteLength(chunk);
159
+ responsePreviewBytes = appendPreview(responsePreviewChunks, responsePreviewBytes, chunk, state.options.captureResponseBody);
106
160
  }
107
161
  return originalEnd.apply(this, [chunk, ...args]);
108
162
  };
@@ -113,6 +167,8 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
113
167
  res.write = originalWrite;
114
168
  res.end = originalEnd;
115
169
  const rawUrl = initialUrl || String(req.url || '');
170
+ const requestPreview = previewText(requestPreviewChunks, requestPreviewBytes);
171
+ const responsePreview = previewText(responsePreviewChunks, responsePreviewBytes);
116
172
  enqueue({
117
173
  id: traceId,
118
174
  timestamp: new Date().toISOString(),
@@ -127,10 +183,10 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
127
183
  responseBytes,
128
184
  requestHeaders: toHeaderRecord(req.headers),
129
185
  responseHeaders: toHeaderRecord(typeof res.getHeaders === 'function' ? res.getHeaders() : {}),
130
- requestBodyPreview: requestPreview,
131
- responseBodyPreview: responsePreview,
132
- requestBodyTruncated: state.options.maxBodyBytes > 0 && requestPreview.length >= state.options.maxBodyBytes,
133
- responseBodyTruncated: state.options.maxBodyBytes > 0 && responsePreview.length >= state.options.maxBodyBytes,
186
+ requestBodyPreview: requestPreview.text,
187
+ responseBodyPreview: responsePreview.text,
188
+ requestBodyTruncated: state.options.captureRequestBody && state.options.maxBodyBytes > 0 && requestBytes > requestPreview.bytes,
189
+ responseBodyTruncated: state.options.captureResponseBody && state.options.maxBodyBytes > 0 && responseBytes > responsePreview.bytes,
134
190
  droppedBeforeEvent: state.droppedCount,
135
191
  traceId,
136
192
  correlationId: req.headers && typeof req.headers['x-saptools-trace-id'] === 'string' ? req.headers['x-saptools-trace-id'] : null
@@ -154,10 +210,10 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
154
210
  const numeric = Number(value);
155
211
  return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
156
212
  };
157
- const limitPreview = (event, previewKey, truncatedKey, maxChars) => {
213
+ const limitPreview = (event, previewKey, truncatedKey, maxBytes) => {
158
214
  const preview = event[previewKey];
159
- if (maxChars <= 0 || typeof preview !== 'string' || preview.length <= maxChars) return event;
160
- return { ...event, [previewKey]: preview.slice(0, maxChars), [truncatedKey]: true };
215
+ if (maxBytes <= 0 || typeof preview !== 'string' || textByteLength(preview) <= maxBytes) return event;
216
+ return { ...event, [previewKey]: truncateTextToBytes(preview, maxBytes), [truncatedKey]: true };
161
217
  };
162
218
  const eventForDrain = (event, maxChars) => {
163
219
  if (!event || typeof event !== 'object') return event;
@@ -166,10 +222,44 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
166
222
  output = limitPreview(output, 'responseBodyPreview', 'responseBodyTruncated', maxChars);
167
223
  return output;
168
224
  };
225
+ const acknowledgeDrain = (drainId) => {
226
+ const pending = state.pendingDrain;
227
+ if (!pending || drainId !== pending.id) return;
228
+ const drainedEvents = new Set(pending.sourceEvents);
229
+ state.queue = state.queue.filter((event) => !drainedEvents.has(event));
230
+ state.pendingDrain = null;
231
+ };
232
+ const drainResult = () => {
233
+ const pending = state.pendingDrain;
234
+ if (!pending) {
235
+ return { drainId: null, events: [], droppedCount: state.droppedCount, queueSize: state.queue.length };
236
+ }
237
+ return {
238
+ drainId: pending.id,
239
+ events: pending.events,
240
+ droppedCount: state.droppedCount,
241
+ queueSize: state.queue.length
242
+ };
243
+ };
169
244
  const api = {
170
245
  version: runtimeVersion,
171
246
  async install(options) {
172
- state.options = { ...state.options, ...options };
247
+ if (staleCleanup) {
248
+ await staleCleanup;
249
+ staleCleanup = null;
250
+ }
251
+ state.options = {
252
+ ...state.options,
253
+ ...options,
254
+ maxBodyBytes: toTransportLimit(options && options.maxBodyBytes),
255
+ maxEvents: Math.max(1, Math.floor(Number(options && options.maxEvents) || 1))
256
+ };
257
+ state.queue = [];
258
+ state.pendingDrain = null;
259
+ state.droppedCount = 0;
260
+ state.seen = new WeakSet();
261
+ state.nextId = 1;
262
+ state.nextDrainId = 1;
173
263
  if (!state.installed) {
174
264
  if (!BufferCtor) {
175
265
  const bufferModule = await loadModule('buffer');
@@ -188,11 +278,19 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
188
278
  state.enabled = false;
189
279
  return api.status();
190
280
  },
191
- drainEvents(maxCount, maxTransportBodyBytes) {
281
+ drainEvents(maxCount, maxTransportBodyBytes, acknowledgedDrainId) {
282
+ acknowledgeDrain(acknowledgedDrainId);
283
+ if (state.pendingDrain) return drainResult();
192
284
  const count = Math.max(0, Math.min(Number(maxCount) || 0, state.queue.length));
193
285
  const transportLimit = toTransportLimit(maxTransportBodyBytes);
194
- const events = state.queue.splice(0, count).map((event) => eventForDrain(event, transportLimit));
195
- return { events, droppedCount: state.droppedCount, queueSize: state.queue.length };
286
+ if (count === 0) return drainResult();
287
+ const sourceEvents = state.queue.slice(0, count);
288
+ state.pendingDrain = {
289
+ id: 'd' + String(state.nextDrainId++),
290
+ sourceEvents,
291
+ events: sourceEvents.map((event) => eventForDrain(event, transportLimit))
292
+ };
293
+ return drainResult();
196
294
  },
197
295
  status() {
198
296
  return { installed: state.installed, enabled: state.enabled, queueSize: state.queue.length, droppedCount: state.droppedCount, maxEvents: state.options.maxEvents };
@@ -204,6 +302,8 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
204
302
  if (state.originals.httpServerEmit && http && http.Server) http.Server.prototype.emit = state.originals.httpServerEmit;
205
303
  if (state.originals.httpsServerEmit && https && https.Server) https.Server.prototype.emit = state.originals.httpsServerEmit;
206
304
  state.installed = false;
305
+ state.queue = [];
306
+ state.pendingDrain = null;
207
307
  return api.status();
208
308
  }
209
309
  };
@@ -214,37 +314,57 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
214
314
  function buildInstallExpression(options) {
215
315
  return `${CF_LIVE_TRACE_RUNTIME_SOURCE}.install(${JSON.stringify(options)})`;
216
316
  }
217
- function buildDrainExpression(maxCount, maxTransportBodyBytes) {
218
- return `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.drainEvents(${String(maxCount)}, ${String(maxTransportBodyBytes)}) ?? { events: [], droppedCount: 0, queueSize: 0 }`;
317
+ function buildDrainExpression(maxCount, maxTransportBodyBytes, acknowledgedDrainId = null) {
318
+ const acknowledgement = JSON.stringify(acknowledgedDrainId);
319
+ return `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.drainEvents(${String(maxCount)}, ${String(maxTransportBodyBytes)}, ${acknowledgement}) ?? { drainId: null, events: [], droppedCount: 0, queueSize: 0 }`;
219
320
  }
220
321
  function buildStopExpression(options) {
221
322
  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 }`;
222
323
  }
223
324
 
224
325
  // src/preview.ts
225
- function truncatePreview(preview, maxChars) {
226
- if (maxChars <= 0) {
326
+ import { Buffer as Buffer2 } from "buffer";
327
+ function truncatePreview(preview, maxBytes) {
328
+ if (maxBytes <= 0) {
227
329
  return { preview, truncated: false };
228
330
  }
229
- if (preview.length <= maxChars) {
331
+ const encoded = Buffer2.from(preview);
332
+ if (encoded.length <= maxBytes) {
230
333
  return { preview, truncated: false };
231
334
  }
232
- return { preview: preview.slice(0, maxChars), truncated: true };
335
+ return {
336
+ preview: encoded.subarray(0, utf8Boundary(encoded, maxBytes)).toString("utf8"),
337
+ truncated: true
338
+ };
339
+ }
340
+ function utf8Boundary(encoded, maxBytes) {
341
+ let boundary = Math.min(maxBytes, encoded.length);
342
+ while (boundary > 0 && isContinuationByte(encoded[boundary])) {
343
+ boundary -= 1;
344
+ }
345
+ return boundary;
346
+ }
347
+ function isContinuationByte(value) {
348
+ return value !== void 0 && (value & 192) === 128;
233
349
  }
234
350
 
235
351
  // src/payload.ts
236
352
  var fallbackEventId = 0;
237
353
  function parseDrainResult(payload, options) {
238
354
  if (!isRecord(payload)) {
239
- return { events: [], droppedCount: 0, queueSize: 0 };
355
+ return { drainId: null, events: [], droppedCount: 0, queueSize: 0 };
240
356
  }
241
357
  const rawEvents = Array.isArray(payload["events"]) ? payload["events"] : [];
242
358
  return {
359
+ drainId: readDrainId(payload["drainId"]),
243
360
  events: rawEvents.map((event) => parseRuntimeEvent(event, options)).filter((event) => event !== null),
244
361
  droppedCount: readNonNegativeNumber(payload["droppedCount"]),
245
362
  queueSize: readNonNegativeNumber(payload["queueSize"])
246
363
  };
247
364
  }
365
+ function readDrainId(value) {
366
+ return typeof value === "string" && /^d\d+$/.test(value) ? value : null;
367
+ }
248
368
  function parseRuntimeEvent(payload, options) {
249
369
  if (!isRecord(payload)) {
250
370
  return null;
@@ -415,12 +535,815 @@ function toStatusBucket(status) {
415
535
  return "unknown";
416
536
  }
417
537
 
538
+ // src/trace-compact.ts
539
+ var COMPACT_BODY_PREVIEW_CHARS = 128;
540
+ var SENSITIVE_QUERY_KEYS = new Set([
541
+ "access_token",
542
+ "api_key",
543
+ "auth",
544
+ "authorization",
545
+ "client_secret",
546
+ "oauth_code",
547
+ "passwd",
548
+ "password",
549
+ "refresh_token",
550
+ "sig",
551
+ "signature",
552
+ "token"
553
+ ].map(normalizeQueryKey));
554
+ function detectBodyFormat(body, headers) {
555
+ if (body.length === 0) {
556
+ return "empty";
557
+ }
558
+ const contentType = headerValue(headers, "content-type").toLowerCase();
559
+ if (contentType.includes("json")) {
560
+ return "json";
561
+ }
562
+ if (contentType.includes("html")) {
563
+ return "html";
564
+ }
565
+ if (contentType.includes("xml")) {
566
+ return "xml";
567
+ }
568
+ if (contentType.includes("x-www-form-urlencoded")) {
569
+ return "form";
570
+ }
571
+ if (contentType.includes("octet-stream")) {
572
+ return "binary";
573
+ }
574
+ return detectBodyFormatFromText(body, contentType);
575
+ }
576
+ function compactTraceEvent(record) {
577
+ const event = record.event;
578
+ const requestBody = compactBody(event.requestBodyPreview);
579
+ const responseBody = compactBody(event.responseBodyPreview);
580
+ return {
581
+ id: event.id,
582
+ sessionId: record.sessionId,
583
+ requestId: record.requestId,
584
+ timestamp: event.timestamp,
585
+ instance: event.instance,
586
+ method: event.method,
587
+ path: event.path,
588
+ url: redactSensitiveQueryValues(event.url),
589
+ normalizedUrl: redactSensitiveQueryValues(event.normalizedUrl),
590
+ status: event.status,
591
+ durationMs: event.durationMs,
592
+ requestBytes: event.requestBytes,
593
+ responseBytes: event.responseBytes,
594
+ requestBodyFormat: record.requestBodyFormat,
595
+ responseBodyFormat: record.responseBodyFormat,
596
+ requestBodyPreview: requestBody.preview,
597
+ requestBodyPreviewRemainingChars: requestBody.remainingChars,
598
+ responseBodyPreview: responseBody.preview,
599
+ responseBodyPreviewRemainingChars: responseBody.remainingChars,
600
+ requestBodyTruncated: event.requestBodyTruncated,
601
+ responseBodyTruncated: event.responseBodyTruncated,
602
+ droppedBeforeEvent: event.droppedBeforeEvent,
603
+ source: event.source,
604
+ traceId: event.traceId,
605
+ correlationId: event.correlationId
606
+ };
607
+ }
608
+ function headerValue(headers, name) {
609
+ const lowerName = name.toLowerCase();
610
+ for (const [key, value] of Object.entries(headers)) {
611
+ if (key.toLowerCase() === lowerName) {
612
+ return value;
613
+ }
614
+ }
615
+ return "";
616
+ }
617
+ function detectBodyFormatFromText(body, contentType) {
618
+ const trimmed = body.trim();
619
+ if (isJsonText(trimmed)) {
620
+ return "json";
621
+ }
622
+ if (/^<!doctype\s+html/i.test(trimmed) || /^<html(?:\s|>)/i.test(trimmed)) {
623
+ return "html";
624
+ }
625
+ if (/^<\?xml(?:\s|>)/i.test(trimmed) || /^<[A-Za-z_][\w:.-]*(?:\s|>|\/>)/.test(trimmed)) {
626
+ return "xml";
627
+ }
628
+ if (contentType.startsWith("text/")) {
629
+ return "text";
630
+ }
631
+ if (hasBinaryControlChars(body)) {
632
+ return "binary";
633
+ }
634
+ return "text";
635
+ }
636
+ function isJsonText(text) {
637
+ if (!text.startsWith("{") && !text.startsWith("[")) {
638
+ return false;
639
+ }
640
+ try {
641
+ JSON.parse(text);
642
+ return true;
643
+ } catch {
644
+ return false;
645
+ }
646
+ }
647
+ function hasBinaryControlChars(text) {
648
+ for (let index = 0; index < text.length; index += 1) {
649
+ const code = text.charCodeAt(index);
650
+ if (code <= 8 || code === 11 || code === 12 || code >= 14 && code <= 31) {
651
+ return true;
652
+ }
653
+ }
654
+ return false;
655
+ }
656
+ function compactBody(body) {
657
+ let preview = "";
658
+ let length = 0;
659
+ for (const character of body) {
660
+ if (length < COMPACT_BODY_PREVIEW_CHARS) {
661
+ preview += character;
662
+ }
663
+ length += 1;
664
+ }
665
+ return {
666
+ preview,
667
+ remainingChars: Math.max(0, length - COMPACT_BODY_PREVIEW_CHARS)
668
+ };
669
+ }
670
+ function redactSensitiveQueryValues(rawUrl) {
671
+ const queryIndex = rawUrl.indexOf("?");
672
+ if (queryIndex < 0) {
673
+ return rawUrl;
674
+ }
675
+ const fragmentIndex = rawUrl.indexOf("#", queryIndex);
676
+ const queryEnd = fragmentIndex < 0 ? rawUrl.length : fragmentIndex;
677
+ const query = rawUrl.slice(queryIndex + 1, queryEnd);
678
+ const redacted = query.split("&").map(redactQueryPart).join("&");
679
+ return `${rawUrl.slice(0, queryIndex + 1)}${redacted}${rawUrl.slice(queryEnd)}`;
680
+ }
681
+ function redactQueryPart(part) {
682
+ const separatorIndex = part.indexOf("=");
683
+ const rawKey = separatorIndex < 0 ? part : part.slice(0, separatorIndex);
684
+ if (!SENSITIVE_QUERY_KEYS.has(normalizeQueryKey(rawKey))) {
685
+ return part;
686
+ }
687
+ return `${rawKey}=redacted`;
688
+ }
689
+ function normalizeQueryKey(rawKey) {
690
+ try {
691
+ return decodeURIComponent(rawKey).toLowerCase().replaceAll(/[^a-z0-9]/g, "");
692
+ } catch {
693
+ return rawKey.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
694
+ }
695
+ }
696
+
697
+ // src/trace-inspect.ts
698
+ var DEFAULT_BODY_LIMIT = 4e3;
699
+ function inspectTraceBody(record, options) {
700
+ return inspectTraceBodyResult(record, options).rows;
701
+ }
702
+ function inspectTraceBodyResult(record, options) {
703
+ const limit = positive("limit", options.limit ?? DEFAULT_BODY_LIMIT);
704
+ const maxRows = positive("max rows", options.maxRows ?? Number.MAX_SAFE_INTEGER);
705
+ const pointer = options.path ?? "";
706
+ const parsed = parseJsonBody(bodyText(record, options.body));
707
+ const selected = resolvePointer(parsed, pointer);
708
+ const rows = inspectionRows(selected, pointer, limit, maxRows);
709
+ return {
710
+ rows: rows.values,
711
+ totalRows: rows.total,
712
+ rowsTruncated: rows.total > rows.values.length
713
+ };
714
+ }
715
+ function inspectionRows(selected, pointer, valueLimit, maxRows) {
716
+ if (Array.isArray(selected)) {
717
+ const values = selected.slice(0, maxRows).map(
718
+ (item, index) => inspectionRow(`${pointer}/${String(index)}`, item, valueLimit)
719
+ );
720
+ return { values, total: selected.length };
721
+ }
722
+ if (!isRecord2(selected)) {
723
+ return { values: [inspectionRow(pointer, selected, valueLimit)], total: 1 };
724
+ }
725
+ return objectInspectionRows(selected, pointer, valueLimit, maxRows);
726
+ }
727
+ function objectInspectionRows(selected, pointer, valueLimit, maxRows) {
728
+ const values = [];
729
+ let total = 0;
730
+ for (const key in selected) {
731
+ if (!Object.hasOwn(selected, key)) {
732
+ continue;
733
+ }
734
+ if (values.length < maxRows) {
735
+ values.push(inspectionRow(`${pointer}/${escapePointerToken(key)}`, selected[key], valueLimit));
736
+ }
737
+ total += 1;
738
+ }
739
+ return { values, total };
740
+ }
741
+ function searchTraceRecords(records, searchTerm, options) {
742
+ const term = searchTerm.trim().toLowerCase();
743
+ if (term.length === 0) {
744
+ throw new Error("search text must not be empty");
745
+ }
746
+ const limit = positive("limit", options.limit);
747
+ const previewLength = positive("preview length", options.previewLength ?? 128);
748
+ const matches = [];
749
+ for (const record of records) {
750
+ for (const side of selectedSides(options.body)) {
751
+ const remaining = limit - matches.length;
752
+ matches.push(...searchBody(record, side, term, remaining, previewLength));
753
+ if (matches.length >= limit) {
754
+ return matches;
755
+ }
756
+ }
757
+ }
758
+ return matches;
759
+ }
760
+ function searchBody(record, side, term, limit, previewLength) {
761
+ if (limit <= 0) {
762
+ return [];
763
+ }
764
+ const body = bodyText(record, side);
765
+ const parsed = tryParseJson(body);
766
+ return parsed === void 0 ? plainTextMatches(record, side, body, term, limit, previewLength) : jsonSearchMatches(record, side, parsed.value, term, limit, previewLength);
767
+ }
768
+ function plainTextMatches(record, side, text, term, limit, previewLength) {
769
+ const matches = [];
770
+ const lowerText = text.toLowerCase();
771
+ let offset = lowerText.indexOf(term);
772
+ while (offset >= 0 && matches.length < limit) {
773
+ const start = Math.max(0, offset - 32);
774
+ matches.push(toSearchMatch(record, side, "", text.slice(start, start + previewLength).replaceAll(/[\r\n\t]/g, " "), offset));
775
+ offset = lowerText.indexOf(term, offset + Math.max(1, term.length));
776
+ }
777
+ return matches;
778
+ }
779
+ function jsonSearchMatches(record, side, root, term, limit, previewLength) {
780
+ const matches = [];
781
+ const stack = [{ path: "", value: root }];
782
+ while (stack.length > 0 && matches.length < limit) {
783
+ const entry = stack.pop();
784
+ if (entry === void 0) {
785
+ break;
786
+ }
787
+ inspectJsonSearchEntry(record, side, entry, term, limit, previewLength, matches, stack);
788
+ }
789
+ return matches.slice(0, limit);
790
+ }
791
+ function inspectJsonSearchEntry(record, side, entry, term, limit, previewLength, matches, stack) {
792
+ if (typeof entry.value === "object" && entry.value !== null) {
793
+ pushJsonChildren(record, side, entry, term, limit, previewLength, matches, stack);
794
+ return;
795
+ }
796
+ const text = jsonScalarSearchText(entry.value);
797
+ if (text.toLowerCase().includes(term)) {
798
+ matches.push(toSearchMatch(record, side, entry.path, text.slice(0, previewLength)));
799
+ }
800
+ }
801
+ function pushJsonChildren(record, side, entry, term, limit, previewLength, matches, stack) {
802
+ if (!Array.isArray(entry.value) && !isRecord2(entry.value)) {
803
+ return;
804
+ }
805
+ const entries = Array.isArray(entry.value) ? entry.value.map((item, index) => [String(index), item]) : Object.entries(entry.value);
806
+ for (const [key, value] of entries.reverse()) {
807
+ if (matches.length >= limit) {
808
+ return;
809
+ }
810
+ const path = `${entry.path}/${escapePointerToken(key)}`;
811
+ if (key.toLowerCase().includes(term)) {
812
+ matches.push(toSearchMatch(record, side, path, jsonValueText(value, previewLength)));
813
+ }
814
+ stack.push({ path, value });
815
+ }
816
+ }
817
+ function toSearchMatch(record, side, path, preview, offset) {
818
+ return {
819
+ sessionId: record.sessionId,
820
+ requestId: record.requestId,
821
+ timestamp: record.event.timestamp,
822
+ method: record.event.method,
823
+ normalizedUrl: record.event.normalizedUrl,
824
+ status: record.event.status,
825
+ body: side,
826
+ path,
827
+ ...offset === void 0 ? {} : { offset },
828
+ preview
829
+ };
830
+ }
831
+ function selectedSides(body) {
832
+ return body === "both" ? ["request", "response"] : [body];
833
+ }
834
+ function bodyText(record, body) {
835
+ return body === "request" ? record.event.requestBodyPreview : record.event.responseBodyPreview;
836
+ }
837
+ function parseJsonBody(body) {
838
+ try {
839
+ return JSON.parse(body);
840
+ } catch (error) {
841
+ throw new Error("Saved trace body does not contain valid JSON", { cause: error });
842
+ }
843
+ }
844
+ function tryParseJson(body) {
845
+ try {
846
+ return { value: JSON.parse(body) };
847
+ } catch {
848
+ return void 0;
849
+ }
850
+ }
851
+ function decodePointerToken(token) {
852
+ if (/~(?:[^01]|$)/.test(token)) {
853
+ throw new Error("Invalid JSON Pointer escape");
854
+ }
855
+ return token.replaceAll("~1", "/").replaceAll("~0", "~");
856
+ }
857
+ function pointerTokens(pointer) {
858
+ if (pointer === "") {
859
+ return [];
860
+ }
861
+ if (!pointer.startsWith("/")) {
862
+ throw new Error("JSON Pointer must be empty or start with /");
863
+ }
864
+ return pointer.slice(1).split("/").map(decodePointerToken);
865
+ }
866
+ function resolvePointer(root, pointer) {
867
+ let current = root;
868
+ for (const token of pointerTokens(pointer)) {
869
+ current = resolvePointerToken(current, token, pointer);
870
+ }
871
+ return current;
872
+ }
873
+ function resolvePointerToken(current, token, pointer) {
874
+ if (Array.isArray(current)) {
875
+ const index = Number(token);
876
+ if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
877
+ throw new Error(`JSON Pointer path "${pointer}" not found`);
878
+ }
879
+ return current[index];
880
+ }
881
+ if (isRecord2(current) && Object.hasOwn(current, token)) {
882
+ return current[token];
883
+ }
884
+ throw new Error(`JSON Pointer path "${pointer}" not found`);
885
+ }
886
+ function escapePointerToken(token) {
887
+ return token.replaceAll("~", "~0").replaceAll("/", "~1");
888
+ }
889
+ function inspectionRow(path, value, limit) {
890
+ return { path, type: jsonType(value), value: jsonValueText(value, limit) };
891
+ }
892
+ function jsonType(value) {
893
+ if (value === null) {
894
+ return "null";
895
+ }
896
+ if (Array.isArray(value)) {
897
+ return "array";
898
+ }
899
+ return typeof value;
900
+ }
901
+ function jsonValueText(value, limit) {
902
+ if (Array.isArray(value)) {
903
+ return `items=${String(value.length)}`;
904
+ }
905
+ if (typeof value === "object" && value !== null) {
906
+ return `keys=${String(Object.keys(value).length)}`;
907
+ }
908
+ if (typeof value === "string") {
909
+ return value.slice(0, limit);
910
+ }
911
+ if (value === null) {
912
+ return "null";
913
+ }
914
+ if (typeof value === "number" || typeof value === "boolean") {
915
+ return String(value);
916
+ }
917
+ return "";
918
+ }
919
+ function jsonScalarSearchText(value) {
920
+ if (typeof value === "string") {
921
+ return value;
922
+ }
923
+ if (value === null) {
924
+ return "null";
925
+ }
926
+ if (typeof value === "number" || typeof value === "boolean") {
927
+ return String(value);
928
+ }
929
+ return "";
930
+ }
931
+ function positive(name, value) {
932
+ if (!Number.isSafeInteger(value) || value <= 0) {
933
+ throw new Error(`${name} must be a positive safe integer`);
934
+ }
935
+ return value;
936
+ }
937
+ function isRecord2(value) {
938
+ return typeof value === "object" && value !== null && !Array.isArray(value);
939
+ }
940
+
941
+ // src/trace-store.ts
942
+ import { createHash, randomBytes } from "crypto";
943
+ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
944
+ import { homedir } from "os";
945
+ import { join } from "path";
946
+ var SAPTOOLS_DIR_NAME = ".saptools";
947
+ var CF_LIVE_TRACE_DIR_NAME = "cf-live-trace";
948
+ var SESSIONS_DIR_NAME = "sessions";
949
+ var EVENTS_DIR_NAME = "events";
950
+ var MANIFEST_FILE_NAME = "manifest.json";
951
+ var TRACE_TTL_MS = 2 * 60 * 60 * 1e3;
952
+ var MAX_TARGET_SLUG_BYTES = 160;
953
+ var SESSION_ID_PATTERN = /^s(?:[0-9a-f]{8}|[0-9a-f]{16})$/;
954
+ var REQUEST_ID_PATTERN = /^r(?:[0-9a-f]{8}|[0-9a-f]{16})$/;
955
+ function traceSessionsRoot(saptoolsRoot) {
956
+ return join(saptoolsRoot ?? join(homedir(), SAPTOOLS_DIR_NAME), CF_LIVE_TRACE_DIR_NAME, SESSIONS_DIR_NAME);
957
+ }
958
+ async function createTraceSession(input, options = {}) {
959
+ await pruneTraceSessions(options);
960
+ const sessionId = resolveSessionId(options.sessionId);
961
+ const createdAt = (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
962
+ const target = toTargetIdentity(input.target);
963
+ const directory = sessionDirectory(sessionId, options.saptoolsRoot);
964
+ const eventsDirectory2 = join(directory, EVENTS_DIR_NAME);
965
+ const manifestPath2 = join(directory, MANIFEST_FILE_NAME);
966
+ await mkdir(eventsDirectory2, { recursive: true, mode: 448 });
967
+ await writeJsonFile(manifestPath2, createManifest(sessionId, createdAt, target));
968
+ return { sessionId, createdAt, target, directory, eventsDirectory: eventsDirectory2, manifestPath: manifestPath2 };
969
+ }
970
+ async function writeTraceEvent(session, event, options = {}) {
971
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
972
+ const requestId = resolveRequestId(options.requestId?.());
973
+ await ensureSessionManifest(session);
974
+ const record = {
975
+ version: 1,
976
+ sessionId: session.sessionId,
977
+ requestId,
978
+ createdAt: now.toISOString(),
979
+ expiresAt: new Date(now.getTime() + TRACE_TTL_MS).toISOString(),
980
+ target: session.target,
981
+ requestBodyFormat: detectBodyFormat(event.requestBodyPreview, event.requestHeaders),
982
+ responseBodyFormat: detectBodyFormat(event.responseBodyPreview, event.responseHeaders),
983
+ event
984
+ };
985
+ const backupPath = join(session.eventsDirectory, eventFileName(record, now));
986
+ await mkdir(session.eventsDirectory, { recursive: true, mode: 448 });
987
+ await writeJsonFile(backupPath, record);
988
+ return { ...record, backupPath };
989
+ }
990
+ async function readTraceEvent(sessionId, requestId, options = {}) {
991
+ const resolvedSessionId = resolveSessionId(sessionId);
992
+ const resolvedRequestId = resolveRequestId(requestId);
993
+ const entries = await listEventFilePaths(resolvedSessionId, options.saptoolsRoot);
994
+ const candidates = entries.filter((path) => path.includes(`-${resolvedRequestId}-`));
995
+ for (const path of candidates) {
996
+ const record = await readStoredTraceEvent(path, resolvedSessionId);
997
+ if (record === void 0 || isExpired(record, options)) {
998
+ await rm(path, { force: true });
999
+ continue;
1000
+ }
1001
+ if (record.requestId === resolvedRequestId) {
1002
+ return record;
1003
+ }
1004
+ }
1005
+ throw new Error("Saved trace request not found or expired");
1006
+ }
1007
+ async function listTraceEvents(sessionId, options = {}) {
1008
+ const records = [];
1009
+ await visitTraceEvents(sessionId, (record) => {
1010
+ records.push(record);
1011
+ return true;
1012
+ }, options);
1013
+ return records;
1014
+ }
1015
+ async function visitTraceEvents(sessionId, visitor, options = {}) {
1016
+ const resolvedSessionId = resolveSessionId(sessionId);
1017
+ const entries = await listEventFilePaths(resolvedSessionId, options.saptoolsRoot);
1018
+ for (const path of entries) {
1019
+ const record = await readStoredTraceEvent(path, resolvedSessionId);
1020
+ if (record === void 0 || isExpired(record, options)) {
1021
+ await rm(path, { force: true });
1022
+ continue;
1023
+ }
1024
+ if (!await visitor(record)) {
1025
+ return;
1026
+ }
1027
+ }
1028
+ }
1029
+ async function listTraceSessions(options = {}) {
1030
+ await pruneTraceSessions(options);
1031
+ const sessionIds = await listSessionIds(options.saptoolsRoot);
1032
+ const summaries = await Promise.all(sessionIds.map(async (sessionId) => await readSessionSummary(sessionId, options)));
1033
+ return summaries.filter((summary) => summary !== void 0).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
1034
+ }
1035
+ async function pruneTraceSessions(options = {}) {
1036
+ const sessionIds = await listSessionIds(options.saptoolsRoot);
1037
+ const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
1038
+ let removed = 0;
1039
+ for (const sessionId of sessionIds) {
1040
+ removed += await pruneSession(sessionId, now, options.saptoolsRoot);
1041
+ }
1042
+ return removed;
1043
+ }
1044
+ function toTargetIdentity(target) {
1045
+ return {
1046
+ ...target.region === void 0 ? {} : { region: target.region },
1047
+ ...target.apiEndpoint === void 0 ? {} : { apiEndpoint: target.apiEndpoint },
1048
+ org: target.org,
1049
+ space: target.space,
1050
+ app: target.app,
1051
+ instance: String(target.instanceIndex ?? 0)
1052
+ };
1053
+ }
1054
+ function createManifest(sessionId, createdAt, target) {
1055
+ return { version: 1, sessionId, createdAt, target };
1056
+ }
1057
+ async function ensureSessionManifest(session) {
1058
+ try {
1059
+ await access(session.manifestPath);
1060
+ return;
1061
+ } catch (error) {
1062
+ if (!isNodeError(error, "ENOENT")) {
1063
+ throw error;
1064
+ }
1065
+ }
1066
+ await mkdir(session.eventsDirectory, { recursive: true, mode: 448 });
1067
+ await writeJsonFile(
1068
+ session.manifestPath,
1069
+ createManifest(session.sessionId, session.createdAt, session.target)
1070
+ );
1071
+ }
1072
+ function isExpired(record, options) {
1073
+ const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
1074
+ return Date.parse(record.expiresAt) <= now;
1075
+ }
1076
+ async function readSessionSummary(sessionId, options) {
1077
+ const manifest = await readStoredManifest(manifestPath(sessionId, options.saptoolsRoot));
1078
+ if (manifest?.sessionId !== sessionId) {
1079
+ return void 0;
1080
+ }
1081
+ const eventCount = (await listEventFilePaths(sessionId, options.saptoolsRoot)).length;
1082
+ return {
1083
+ sessionId,
1084
+ createdAt: manifest.createdAt,
1085
+ target: manifest.target,
1086
+ eventCount,
1087
+ directory: sessionDirectory(sessionId, options.saptoolsRoot)
1088
+ };
1089
+ }
1090
+ async function pruneSession(sessionId, now, saptoolsRoot) {
1091
+ const manifest = await readStoredManifest(manifestPath(sessionId, saptoolsRoot));
1092
+ const eventPaths = await listEventFilePaths(sessionId, saptoolsRoot);
1093
+ let removed = 0;
1094
+ let remaining = 0;
1095
+ for (const path of eventPaths) {
1096
+ const expiresAt = eventPathExpiresAt(path) ?? await storedEventExpiresAt(path);
1097
+ if (expiresAt === void 0 || expiresAt <= now) {
1098
+ await rm(path, { force: true });
1099
+ removed += 1;
1100
+ } else {
1101
+ remaining += 1;
1102
+ }
1103
+ }
1104
+ if (remaining === 0 && isManifestExpiredOrMissing(manifest, now)) {
1105
+ await rm(sessionDirectory(sessionId, saptoolsRoot), { recursive: true, force: true });
1106
+ }
1107
+ return removed;
1108
+ }
1109
+ async function storedEventExpiresAt(path) {
1110
+ const record = await readStoredTraceEvent(path);
1111
+ return record === void 0 ? void 0 : Date.parse(record.expiresAt);
1112
+ }
1113
+ function isManifestExpiredOrMissing(manifest, now) {
1114
+ if (manifest === void 0) {
1115
+ return true;
1116
+ }
1117
+ return Date.parse(manifest.createdAt) + TRACE_TTL_MS <= now;
1118
+ }
1119
+ async function listSessionIds(saptoolsRoot) {
1120
+ try {
1121
+ const entries = await readdir(traceSessionsRoot(saptoolsRoot), { withFileTypes: true });
1122
+ return entries.filter((entry) => entry.isDirectory() && SESSION_ID_PATTERN.test(entry.name)).map((entry) => entry.name);
1123
+ } catch (error) {
1124
+ if (isNodeError(error, "ENOENT")) {
1125
+ return [];
1126
+ }
1127
+ throw error;
1128
+ }
1129
+ }
1130
+ async function listEventFilePaths(sessionId, saptoolsRoot) {
1131
+ try {
1132
+ const directory = eventsDirectory(sessionId, saptoolsRoot);
1133
+ const entries = await readdir(directory, { withFileTypes: true });
1134
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name)).sort(compareEventPaths);
1135
+ } catch (error) {
1136
+ if (isNodeError(error, "ENOENT")) {
1137
+ return [];
1138
+ }
1139
+ throw error;
1140
+ }
1141
+ }
1142
+ function compareEventPaths(left, right) {
1143
+ const leftTimestamp = eventPathTimestamp(left);
1144
+ const rightTimestamp = eventPathTimestamp(right);
1145
+ return leftTimestamp.localeCompare(rightTimestamp) || left.localeCompare(right);
1146
+ }
1147
+ function eventPathTimestamp(path) {
1148
+ return /-(\d{8}T\d{9}Z)\.json$/.exec(path)?.[1] ?? "";
1149
+ }
1150
+ function eventPathExpiresAt(path) {
1151
+ const compact = eventPathTimestamp(path);
1152
+ if (compact.length === 0) {
1153
+ return void 0;
1154
+ }
1155
+ const iso = compact.replace(
1156
+ /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z$/,
1157
+ "$1-$2-$3T$4:$5:$6.$7Z"
1158
+ );
1159
+ const createdAt = Date.parse(iso);
1160
+ return Number.isNaN(createdAt) ? void 0 : createdAt + TRACE_TTL_MS;
1161
+ }
1162
+ async function readStoredManifest(path) {
1163
+ const parsed = await readJsonFile(path);
1164
+ return parsed !== void 0 && isStoredManifest(parsed) ? parsed : void 0;
1165
+ }
1166
+ async function readStoredTraceEvent(path, expectedSessionId) {
1167
+ const parsed = await readJsonFile(path);
1168
+ if (parsed === void 0 || !isStoredTraceEvent(parsed)) {
1169
+ return void 0;
1170
+ }
1171
+ if (expectedSessionId !== void 0 && parsed.sessionId !== expectedSessionId) {
1172
+ return void 0;
1173
+ }
1174
+ return { ...parsed, backupPath: path };
1175
+ }
1176
+ async function readJsonFile(path) {
1177
+ let raw;
1178
+ try {
1179
+ raw = await readFile(path, "utf8");
1180
+ } catch (error) {
1181
+ if (isNodeError(error, "ENOENT")) {
1182
+ return void 0;
1183
+ }
1184
+ throw error;
1185
+ }
1186
+ try {
1187
+ return JSON.parse(raw);
1188
+ } catch (error) {
1189
+ if (!(error instanceof SyntaxError)) {
1190
+ throw error;
1191
+ }
1192
+ return void 0;
1193
+ }
1194
+ }
1195
+ async function writeJsonFile(path, value) {
1196
+ const temporaryPath = `${path}.tmp-${process.pid.toString()}-${randomHex(4)}`;
1197
+ let renamed = false;
1198
+ try {
1199
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}
1200
+ `, {
1201
+ encoding: "utf8",
1202
+ mode: 384
1203
+ });
1204
+ await rename(temporaryPath, path);
1205
+ renamed = true;
1206
+ } finally {
1207
+ if (!renamed) {
1208
+ await rm(temporaryPath, { force: true });
1209
+ }
1210
+ }
1211
+ }
1212
+ function eventFileName(record, now) {
1213
+ const parts = [
1214
+ targetSlug(record.target),
1215
+ record.sessionId,
1216
+ record.requestId,
1217
+ fileTimestamp(now)
1218
+ ].join("-");
1219
+ return `${parts}.json`;
1220
+ }
1221
+ function targetSlug(target) {
1222
+ const regionOrApi = target.region ?? target.apiEndpoint ?? "api";
1223
+ const fullSlug = [regionOrApi, target.org, target.space, target.app].map(sanitizePathPart).join("-");
1224
+ if (Buffer.byteLength(fullSlug) <= MAX_TARGET_SLUG_BYTES) {
1225
+ return fullSlug;
1226
+ }
1227
+ const hash = createHash("sha256").update(fullSlug).digest("hex").slice(0, 12);
1228
+ const prefix = fullSlug.slice(0, MAX_TARGET_SLUG_BYTES - hash.length - 1).replace(/-+$/, "");
1229
+ return `${prefix}-${hash}`;
1230
+ }
1231
+ function sanitizePathPart(value) {
1232
+ const sanitized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
1233
+ return sanitized.length === 0 ? "unknown" : sanitized;
1234
+ }
1235
+ function fileTimestamp(now) {
1236
+ return now.toISOString().replaceAll("-", "").replaceAll(":", "").replace(".", "");
1237
+ }
1238
+ function sessionDirectory(sessionId, saptoolsRoot) {
1239
+ return join(traceSessionsRoot(saptoolsRoot), sessionId);
1240
+ }
1241
+ function eventsDirectory(sessionId, saptoolsRoot) {
1242
+ return join(sessionDirectory(sessionId, saptoolsRoot), EVENTS_DIR_NAME);
1243
+ }
1244
+ function manifestPath(sessionId, saptoolsRoot) {
1245
+ return join(sessionDirectory(sessionId, saptoolsRoot), MANIFEST_FILE_NAME);
1246
+ }
1247
+ function resolveSessionId(value) {
1248
+ const sessionId = value ?? `s${randomHex(8)}`;
1249
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
1250
+ throw new Error("Invalid trace session id");
1251
+ }
1252
+ return sessionId;
1253
+ }
1254
+ function resolveRequestId(value) {
1255
+ const requestId = value ?? `r${randomHex(8)}`;
1256
+ if (!REQUEST_ID_PATTERN.test(requestId)) {
1257
+ throw new Error("Invalid trace request id");
1258
+ }
1259
+ return requestId;
1260
+ }
1261
+ function randomHex(bytes) {
1262
+ return randomBytes(bytes).toString("hex");
1263
+ }
1264
+ function isStoredManifest(value) {
1265
+ return isRecord3(value) && value["version"] === 1 && isSessionId(value["sessionId"]) && isIsoTimestamp(value["createdAt"]) && isTraceTargetIdentity(value["target"]);
1266
+ }
1267
+ function isStoredTraceEvent(value) {
1268
+ return isRecord3(value) && value["version"] === 1 && isStoredEventMetadata(value) && isTraceTargetIdentity(value["target"]) && isTraceBodyFormat(value["requestBodyFormat"]) && isTraceBodyFormat(value["responseBodyFormat"]) && isLiveTraceEvent(value["event"]);
1269
+ }
1270
+ function isStoredEventMetadata(value) {
1271
+ return isSessionId(value["sessionId"]) && isRequestId(value["requestId"]) && isIsoTimestamp(value["createdAt"]) && isIsoTimestamp(value["expiresAt"]);
1272
+ }
1273
+ function isTraceTargetIdentity(value) {
1274
+ 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";
1275
+ }
1276
+ function isLiveTraceEvent(value) {
1277
+ 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"]);
1278
+ }
1279
+ function hasLiveTraceStrings(value) {
1280
+ const fields = [
1281
+ "id",
1282
+ "appId",
1283
+ "instance",
1284
+ "method",
1285
+ "path",
1286
+ "url",
1287
+ "normalizedUrl",
1288
+ "requestBodyPreview",
1289
+ "responseBodyPreview",
1290
+ "traceId"
1291
+ ];
1292
+ return isIsoTimestamp(value["timestamp"]) && fields.every((field) => typeof value[field] === "string");
1293
+ }
1294
+ function hasLiveTraceMetrics(value) {
1295
+ return isNullableFiniteNumber(value["status"]) && isNullableNonNegativeNumber(value["durationMs"]) && isNonNegativeFiniteNumber(value["requestBytes"]) && isNonNegativeFiniteNumber(value["responseBytes"]) && isNonNegativeFiniteNumber(value["droppedBeforeEvent"]);
1296
+ }
1297
+ function isTraceBodyFormat(value) {
1298
+ return value === "empty" || value === "json" || value === "xml" || value === "html" || value === "form" || value === "text" || value === "binary" || value === "unknown";
1299
+ }
1300
+ function optionalString(value) {
1301
+ return value === void 0 || typeof value === "string";
1302
+ }
1303
+ function optionalNullableString(value) {
1304
+ return value === null || typeof value === "string";
1305
+ }
1306
+ function isStringRecord(value) {
1307
+ return isRecord3(value) && Object.values(value).every((entry) => typeof entry === "string");
1308
+ }
1309
+ function isSessionId(value) {
1310
+ return typeof value === "string" && SESSION_ID_PATTERN.test(value);
1311
+ }
1312
+ function isRequestId(value) {
1313
+ return typeof value === "string" && REQUEST_ID_PATTERN.test(value);
1314
+ }
1315
+ function isIsoTimestamp(value) {
1316
+ if (typeof value !== "string") {
1317
+ return false;
1318
+ }
1319
+ const parsed = new Date(value);
1320
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
1321
+ }
1322
+ function isNullableFiniteNumber(value) {
1323
+ return value === null || isFiniteNumber(value);
1324
+ }
1325
+ function isNullableNonNegativeNumber(value) {
1326
+ return value === null || isNonNegativeFiniteNumber(value);
1327
+ }
1328
+ function isNonNegativeFiniteNumber(value) {
1329
+ return isFiniteNumber(value) && value >= 0;
1330
+ }
1331
+ function isFiniteNumber(value) {
1332
+ return typeof value === "number" && Number.isFinite(value);
1333
+ }
1334
+ function isRecord3(value) {
1335
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1336
+ }
1337
+ function isNodeError(error, code) {
1338
+ return isRecord3(error) && error["code"] === code;
1339
+ }
1340
+
418
1341
  // src/cf.ts
419
1342
  import { execFile, spawn } from "child_process";
420
- import { mkdtemp, rm } from "fs/promises";
1343
+ import { mkdtemp, rm as rm2 } from "fs/promises";
421
1344
  import { connect as netConnect, createServer } from "net";
422
1345
  import { tmpdir } from "os";
423
- import { join } from "path";
1346
+ import { join as join2 } from "path";
424
1347
  import { promisify } from "util";
425
1348
  var execFileAsync = promisify(execFile);
426
1349
  var CF_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@@ -654,7 +1577,7 @@ function formatCfError(args, error, redactor) {
654
1577
  return redactor?.(message) ?? message;
655
1578
  }
656
1579
  function extractErrorDetail(error) {
657
- if (!isRecord2(error)) {
1580
+ if (!isRecord4(error)) {
658
1581
  return "";
659
1582
  }
660
1583
  const stderr = typeof error["stderr"] === "string" ? error["stderr"].trim() : "";
@@ -757,7 +1680,7 @@ function retryPortProbe(socket, deadline, attempt, finish, setRetryTimer) {
757
1680
  }
758
1681
  setRetryTimer(setTimeout(attempt, TUNNEL_READY_POLL_MS));
759
1682
  }
760
- function isRecord2(value) {
1683
+ function isRecord4(value) {
761
1684
  return typeof value === "object" && value !== null && !Array.isArray(value);
762
1685
  }
763
1686
  var INSPECTOR_SIGNAL_COMMAND = [
@@ -866,7 +1789,8 @@ var RUNTIME_QUEUE_SIZE = 1e3;
866
1789
  var CONTROL_EVALUATE_TIMEOUT_MS = 5e3;
867
1790
  var DRAIN_EVALUATE_TIMEOUT_MS = 1e4;
868
1791
  var DRAIN_TIMEOUT_RETRY_LIMIT = 3;
869
- var DRAIN_TRANSPORT_BODY_LIMIT = 2e4;
1792
+ var MAX_DRAIN_EVALUATE_TIMEOUT_MS = 6e4;
1793
+ var DRAIN_BODY_BUDGET_BYTES = 8 * 1024 * 1024;
870
1794
  var defaultDependencies = {
871
1795
  prepareCfSession,
872
1796
  ensureSshEnabled,
@@ -883,11 +1807,12 @@ var LiveTraceSession = class {
883
1807
  }
884
1808
  options;
885
1809
  dependencies;
886
- events = [];
1810
+ summaryEvents = [];
887
1811
  consecutiveDrainTimeouts = 0;
888
- drainInFlight = false;
1812
+ drainTask;
889
1813
  inspectorClient;
890
1814
  pollTimer;
1815
+ pendingDrainAcknowledgement = null;
891
1816
  state = "idle";
892
1817
  stopRequested = false;
893
1818
  tunnelHandle;
@@ -896,6 +1821,8 @@ var LiveTraceSession = class {
896
1821
  return;
897
1822
  }
898
1823
  this.stopRequested = false;
1824
+ this.pendingDrainAcknowledgement = null;
1825
+ this.summaryEvents.splice(0);
899
1826
  await this.startRuntimeTrace(resolveStartOptions(options));
900
1827
  }
901
1828
  async stop(options) {
@@ -981,36 +1908,65 @@ var LiveTraceSession = class {
981
1908
  this.stopPolling();
982
1909
  this.consecutiveDrainTimeouts = 0;
983
1910
  this.pollTimer = this.dependencies.setInterval(() => {
984
- void this.drainTraceEvents(maxBodyBytes);
1911
+ this.startDrain(maxBodyBytes);
985
1912
  }, DRAIN_INTERVAL_MS);
986
1913
  }
987
- async drainTraceEvents(maxBodyBytes) {
988
- if (this.drainInFlight || this.inspectorClient === void 0 || this.state !== "streaming") {
1914
+ startDrain(maxBodyBytes) {
1915
+ if (this.drainTask !== void 0 || this.inspectorClient === void 0 || this.state !== "streaming") {
989
1916
  return;
990
1917
  }
991
- this.drainInFlight = true;
1918
+ const task = this.drainTraceEvents(maxBodyBytes);
1919
+ this.drainTask = task;
1920
+ void task.then(
1921
+ () => {
1922
+ this.clearDrainTask(task);
1923
+ },
1924
+ () => {
1925
+ this.clearDrainTask(task);
1926
+ }
1927
+ );
1928
+ }
1929
+ clearDrainTask(task) {
1930
+ if (this.drainTask === task) {
1931
+ this.drainTask = void 0;
1932
+ }
1933
+ }
1934
+ async drainTraceEvents(maxBodyBytes) {
1935
+ const batchSize = resolveDrainBatchSize(maxBodyBytes);
992
1936
  try {
993
- const payload = await this.inspectorClient.evaluate(
994
- buildDrainExpression(DRAIN_BATCH_SIZE, resolveDrainTransportBodyLimit(maxBodyBytes)),
995
- DRAIN_EVALUATE_TIMEOUT_MS
1937
+ const payload = await this.requireInspector().evaluate(
1938
+ buildDrainExpression(
1939
+ batchSize,
1940
+ resolveDrainTransportBodyLimit(maxBodyBytes),
1941
+ this.pendingDrainAcknowledgement
1942
+ ),
1943
+ resolveDrainEvaluateTimeout(maxBodyBytes, batchSize)
996
1944
  );
997
1945
  this.consecutiveDrainTimeouts = 0;
998
- this.publishDrainedEvents(payload, maxBodyBytes);
1946
+ try {
1947
+ await this.publishDrainedEvents(payload, maxBodyBytes);
1948
+ } catch (error) {
1949
+ await this.handleEventHandlerFailure(error);
1950
+ }
999
1951
  } catch (error) {
1000
1952
  await this.handleDrainFailure(error);
1001
- } finally {
1002
- this.drainInFlight = false;
1003
1953
  }
1004
1954
  }
1005
- publishDrainedEvents(payload, maxBodyBytes) {
1955
+ async publishDrainedEvents(payload, maxBodyBytes) {
1006
1956
  const drained = parseDrainResult(payload, { appId: this.options.target.app, maxBodyBytes });
1007
- if (drained.events.length === 0) {
1957
+ if (drained.events.length > 0) {
1958
+ await this.options.onEvents?.(drained.events);
1959
+ this.publishSummary(drained.events);
1960
+ }
1961
+ this.pendingDrainAcknowledgement = drained.drainId;
1962
+ }
1963
+ publishSummary(events) {
1964
+ if (this.options.onSummary === void 0) {
1008
1965
  return;
1009
1966
  }
1010
- this.events.push(...drained.events);
1011
- this.events.splice(0, Math.max(0, this.events.length - RUNTIME_QUEUE_SIZE));
1012
- this.options.onEvents?.(drained.events);
1013
- this.options.onSummary?.(buildUrlSummaries(this.events));
1967
+ this.summaryEvents.push(...events.map(toSummaryEvent));
1968
+ this.summaryEvents.splice(0, Math.max(0, this.summaryEvents.length - RUNTIME_QUEUE_SIZE));
1969
+ this.options.onSummary(buildUrlSummaries(this.summaryEvents));
1014
1970
  }
1015
1971
  async handleDrainFailure(error) {
1016
1972
  if (isEvaluateTimeout(error)) {
@@ -1021,11 +1977,19 @@ var LiveTraceSession = class {
1021
1977
  }
1022
1978
  }
1023
1979
  this.log(`Live Trace stream failed for ${this.options.target.app}: ${formatError(error)}`);
1024
- await this.stopRuntimeTrace(false);
1980
+ await this.stopRuntimeTrace(false, false);
1025
1981
  this.postState("error", "Runtime HTTP trace connection was lost.", false, true);
1026
1982
  }
1027
- async stopRuntimeTrace(uninstallRuntimeHook) {
1983
+ async handleEventHandlerFailure(error) {
1984
+ this.log(`Live Trace event handler failed for ${this.options.target.app}: ${formatError(error)}`);
1985
+ await this.stopRuntimeTrace(false, false);
1986
+ this.postState("error", "Trace event handler failed.", false, true);
1987
+ }
1988
+ async stopRuntimeTrace(uninstallRuntimeHook, waitForDrain = true) {
1028
1989
  this.stopPolling();
1990
+ if (waitForDrain) {
1991
+ await this.drainTask;
1992
+ }
1029
1993
  this.consecutiveDrainTimeouts = 0;
1030
1994
  const uninstalled = await this.stopInspectorHook(uninstallRuntimeHook);
1031
1995
  await this.closeInspectorClient();
@@ -1109,12 +2073,20 @@ var LiveTraceStartupError = class extends Error {
1109
2073
  stateMessage;
1110
2074
  };
1111
2075
  function resolveStartOptions(options) {
2076
+ const maxBodyBytes = options.maxBodyBytes ?? 4096;
2077
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
2078
+ throw new Error("maxBodyBytes must be a positive safe integer.");
2079
+ }
2080
+ const runtimeQueueSize = options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE;
2081
+ if (!Number.isSafeInteger(runtimeQueueSize) || runtimeQueueSize <= 0) {
2082
+ throw new Error("runtimeQueueSize must be a positive safe integer.");
2083
+ }
1112
2084
  return {
1113
2085
  captureHeaders: options.captureHeaders ?? true,
1114
2086
  captureRequestBody: options.captureRequestBody ?? true,
1115
2087
  captureResponseBody: options.captureResponseBody ?? true,
1116
- maxBodyBytes: options.maxBodyBytes ?? 4096,
1117
- runtimeQueueSize: options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE
2088
+ maxBodyBytes,
2089
+ runtimeQueueSize
1118
2090
  };
1119
2091
  }
1120
2092
  function stopLateTunnel(tunnel) {
@@ -1123,7 +2095,16 @@ function stopLateTunnel(tunnel) {
1123
2095
  }
1124
2096
  }
1125
2097
  function resolveDrainTransportBodyLimit(maxBodyBytes) {
1126
- return maxBodyBytes > 0 ? Math.min(maxBodyBytes, DRAIN_TRANSPORT_BODY_LIMIT) : DRAIN_TRANSPORT_BODY_LIMIT;
2098
+ return maxBodyBytes > 0 ? maxBodyBytes : 1;
2099
+ }
2100
+ function resolveDrainBatchSize(maxBodyBytes) {
2101
+ const perEventBytes = Math.max(1, maxBodyBytes * 2);
2102
+ return Math.max(1, Math.min(DRAIN_BATCH_SIZE, Math.floor(DRAIN_BODY_BUDGET_BYTES / perEventBytes)));
2103
+ }
2104
+ function resolveDrainEvaluateTimeout(maxBodyBytes, batchSize) {
2105
+ const estimatedBytes = Math.min(Number.MAX_SAFE_INTEGER, maxBodyBytes * 2 * batchSize);
2106
+ const extraMegabytes = Math.ceil(Math.max(0, estimatedBytes - 1e6) / 1e6);
2107
+ return Math.min(MAX_DRAIN_EVALUATE_TIMEOUT_MS, DRAIN_EVALUATE_TIMEOUT_MS + extraMegabytes * 5e3);
1127
2108
  }
1128
2109
  function isEvaluateTimeout(error) {
1129
2110
  const message = error instanceof Error ? error.message : String(error);
@@ -1146,6 +2127,15 @@ function formatError(error) {
1146
2127
  const message = error instanceof Error ? error.message : String(error);
1147
2128
  return message.trim().length > 0 ? message.trim() : "Unknown error";
1148
2129
  }
2130
+ function toSummaryEvent(event) {
2131
+ return {
2132
+ ...event,
2133
+ requestHeaders: {},
2134
+ responseHeaders: {},
2135
+ requestBodyPreview: "",
2136
+ responseBodyPreview: ""
2137
+ };
2138
+ }
1149
2139
  export {
1150
2140
  CF_LIVE_TRACE_GLOBAL_NAME,
1151
2141
  CF_LIVE_TRACE_RUNTIME_SOURCE,
@@ -1157,13 +2147,26 @@ export {
1157
2147
  buildInstallExpression,
1158
2148
  buildStopExpression,
1159
2149
  buildUrlSummaries,
2150
+ compactTraceEvent,
1160
2151
  createSecretRedactor,
2152
+ createTraceSession,
2153
+ detectBodyFormat,
2154
+ inspectTraceBody,
2155
+ inspectTraceBodyResult,
2156
+ listTraceEvents,
2157
+ listTraceSessions,
1161
2158
  normalizeEventUrl,
1162
2159
  openInspectorTunnel,
1163
2160
  parseDrainResult,
1164
2161
  prepareCfSession,
2162
+ pruneTraceSessions,
2163
+ readTraceEvent,
2164
+ searchTraceRecords,
1165
2165
  startNodeInspector,
2166
+ traceSessionsRoot,
1166
2167
  truncatePreview,
1167
- tryStartNodeInspector
2168
+ tryStartNodeInspector,
2169
+ visitTraceEvents,
2170
+ writeTraceEvent
1168
2171
  };
1169
2172
  //# sourceMappingURL=index.js.map