@saptools/cf-live-trace 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 = 1;
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,25 +278,32 @@ 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 };
199
297
  },
200
- uninstall() {
298
+ async uninstall() {
201
299
  state.enabled = false;
202
- const requireFn = loadRequire();
203
- if (requireFn) {
204
- const http = requireFn('http');
205
- const https = requireFn('https');
206
- if (state.originals.httpServerEmit && http && http.Server) http.Server.prototype.emit = state.originals.httpServerEmit;
207
- if (state.originals.httpsServerEmit && https && https.Server) https.Server.prototype.emit = state.originals.httpsServerEmit;
208
- }
300
+ const http = await loadModule('http');
301
+ const https = await loadModule('https');
302
+ if (state.originals.httpServerEmit && http && http.Server) http.Server.prototype.emit = state.originals.httpServerEmit;
303
+ if (state.originals.httpsServerEmit && https && https.Server) https.Server.prototype.emit = state.originals.httpsServerEmit;
209
304
  state.installed = false;
305
+ state.queue = [];
306
+ state.pendingDrain = null;
210
307
  return api.status();
211
308
  }
212
309
  };
@@ -217,37 +314,57 @@ var CF_LIVE_TRACE_RUNTIME_SOURCE = `
217
314
  function buildInstallExpression(options) {
218
315
  return `${CF_LIVE_TRACE_RUNTIME_SOURCE}.install(${JSON.stringify(options)})`;
219
316
  }
220
- function buildDrainExpression(maxCount, maxTransportBodyBytes) {
221
- 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 }`;
222
320
  }
223
321
  function buildStopExpression(options) {
224
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 }`;
225
323
  }
226
324
 
227
325
  // src/preview.ts
228
- function truncatePreview(preview, maxChars) {
229
- if (maxChars <= 0) {
326
+ import { Buffer as Buffer2 } from "buffer";
327
+ function truncatePreview(preview, maxBytes) {
328
+ if (maxBytes <= 0) {
230
329
  return { preview, truncated: false };
231
330
  }
232
- if (preview.length <= maxChars) {
331
+ const encoded = Buffer2.from(preview);
332
+ if (encoded.length <= maxBytes) {
233
333
  return { preview, truncated: false };
234
334
  }
235
- 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;
236
349
  }
237
350
 
238
351
  // src/payload.ts
239
352
  var fallbackEventId = 0;
240
353
  function parseDrainResult(payload, options) {
241
354
  if (!isRecord(payload)) {
242
- return { events: [], droppedCount: 0, queueSize: 0 };
355
+ return { drainId: null, events: [], droppedCount: 0, queueSize: 0 };
243
356
  }
244
357
  const rawEvents = Array.isArray(payload["events"]) ? payload["events"] : [];
245
358
  return {
359
+ drainId: readDrainId(payload["drainId"]),
246
360
  events: rawEvents.map((event) => parseRuntimeEvent(event, options)).filter((event) => event !== null),
247
361
  droppedCount: readNonNegativeNumber(payload["droppedCount"]),
248
362
  queueSize: readNonNegativeNumber(payload["queueSize"])
249
363
  };
250
364
  }
365
+ function readDrainId(value) {
366
+ return typeof value === "string" && /^d\d+$/.test(value) ? value : null;
367
+ }
251
368
  function parseRuntimeEvent(payload, options) {
252
369
  if (!isRecord(payload)) {
253
370
  return null;
@@ -418,12 +535,815 @@ function toStatusBucket(status) {
418
535
  return "unknown";
419
536
  }
420
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
+
421
1341
  // src/cf.ts
422
1342
  import { execFile, spawn } from "child_process";
423
- import { mkdtemp, rm } from "fs/promises";
1343
+ import { mkdtemp, rm as rm2 } from "fs/promises";
424
1344
  import { connect as netConnect, createServer } from "net";
425
1345
  import { tmpdir } from "os";
426
- import { join } from "path";
1346
+ import { join as join2 } from "path";
427
1347
  import { promisify } from "util";
428
1348
  var execFileAsync = promisify(execFile);
429
1349
  var CF_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@@ -521,6 +1441,8 @@ function spawnPortForward(params) {
521
1441
  env: buildCfEnv(params.cfHomeDir),
522
1442
  stdio: ["ignore", "pipe", "pipe"]
523
1443
  });
1444
+ child.stdout.resume();
1445
+ child.stderr.resume();
524
1446
  return {
525
1447
  process: child,
526
1448
  localPort: params.localPort,
@@ -655,14 +1577,18 @@ function formatCfError(args, error, redactor) {
655
1577
  return redactor?.(message) ?? message;
656
1578
  }
657
1579
  function extractErrorDetail(error) {
658
- if (!isRecord2(error)) {
1580
+ if (!isRecord4(error)) {
659
1581
  return "";
660
1582
  }
661
1583
  const stderr = typeof error["stderr"] === "string" ? error["stderr"].trim() : "";
662
1584
  if (stderr.length > 0) {
663
1585
  return stderr;
664
1586
  }
665
- return error["message"] instanceof Error ? error["message"].message : "";
1587
+ const message = error["message"];
1588
+ if (typeof message === "string") {
1589
+ return message.trim();
1590
+ }
1591
+ return message instanceof Error ? message.message.trim() : "";
666
1592
  }
667
1593
  function formatErrorMessage(error) {
668
1594
  const message = error instanceof Error ? error.message : String(error);
@@ -754,7 +1680,7 @@ function retryPortProbe(socket, deadline, attempt, finish, setRetryTimer) {
754
1680
  }
755
1681
  setRetryTimer(setTimeout(attempt, TUNNEL_READY_POLL_MS));
756
1682
  }
757
- function isRecord2(value) {
1683
+ function isRecord4(value) {
758
1684
  return typeof value === "object" && value !== null && !Array.isArray(value);
759
1685
  }
760
1686
  var INSPECTOR_SIGNAL_COMMAND = [
@@ -863,7 +1789,8 @@ var RUNTIME_QUEUE_SIZE = 1e3;
863
1789
  var CONTROL_EVALUATE_TIMEOUT_MS = 5e3;
864
1790
  var DRAIN_EVALUATE_TIMEOUT_MS = 1e4;
865
1791
  var DRAIN_TIMEOUT_RETRY_LIMIT = 3;
866
- var DRAIN_TRANSPORT_BODY_LIMIT = 2e4;
1792
+ var MAX_DRAIN_EVALUATE_TIMEOUT_MS = 6e4;
1793
+ var DRAIN_BODY_BUDGET_BYTES = 8 * 1024 * 1024;
867
1794
  var defaultDependencies = {
868
1795
  prepareCfSession,
869
1796
  ensureSshEnabled,
@@ -880,11 +1807,12 @@ var LiveTraceSession = class {
880
1807
  }
881
1808
  options;
882
1809
  dependencies;
883
- events = [];
1810
+ summaryEvents = [];
884
1811
  consecutiveDrainTimeouts = 0;
885
- drainInFlight = false;
1812
+ drainTask;
886
1813
  inspectorClient;
887
1814
  pollTimer;
1815
+ pendingDrainAcknowledgement = null;
888
1816
  state = "idle";
889
1817
  stopRequested = false;
890
1818
  tunnelHandle;
@@ -893,11 +1821,16 @@ var LiveTraceSession = class {
893
1821
  return;
894
1822
  }
895
1823
  this.stopRequested = false;
1824
+ this.pendingDrainAcknowledgement = null;
1825
+ this.summaryEvents.splice(0);
896
1826
  await this.startRuntimeTrace(resolveStartOptions(options));
897
1827
  }
898
1828
  async stop(options) {
899
1829
  this.stopRequested = true;
900
1830
  if (!this.isRunning()) {
1831
+ if (this.state === "error" && options.reason === "error") {
1832
+ return;
1833
+ }
901
1834
  this.postState("stopped", `Trace stopped (${options.reason}).`, false, false);
902
1835
  return;
903
1836
  }
@@ -975,36 +1908,65 @@ var LiveTraceSession = class {
975
1908
  this.stopPolling();
976
1909
  this.consecutiveDrainTimeouts = 0;
977
1910
  this.pollTimer = this.dependencies.setInterval(() => {
978
- void this.drainTraceEvents(maxBodyBytes);
1911
+ this.startDrain(maxBodyBytes);
979
1912
  }, DRAIN_INTERVAL_MS);
980
1913
  }
981
- async drainTraceEvents(maxBodyBytes) {
982
- 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") {
983
1916
  return;
984
1917
  }
985
- 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);
986
1936
  try {
987
- const payload = await this.inspectorClient.evaluate(
988
- buildDrainExpression(DRAIN_BATCH_SIZE, resolveDrainTransportBodyLimit(maxBodyBytes)),
989
- 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)
990
1944
  );
991
1945
  this.consecutiveDrainTimeouts = 0;
992
- this.publishDrainedEvents(payload, maxBodyBytes);
1946
+ try {
1947
+ await this.publishDrainedEvents(payload, maxBodyBytes);
1948
+ } catch (error) {
1949
+ await this.handleEventHandlerFailure(error);
1950
+ }
993
1951
  } catch (error) {
994
1952
  await this.handleDrainFailure(error);
995
- } finally {
996
- this.drainInFlight = false;
997
1953
  }
998
1954
  }
999
- publishDrainedEvents(payload, maxBodyBytes) {
1955
+ async publishDrainedEvents(payload, maxBodyBytes) {
1000
1956
  const drained = parseDrainResult(payload, { appId: this.options.target.app, maxBodyBytes });
1001
- 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) {
1002
1965
  return;
1003
1966
  }
1004
- this.events.push(...drained.events);
1005
- this.events.splice(0, Math.max(0, this.events.length - RUNTIME_QUEUE_SIZE));
1006
- this.options.onEvents?.(drained.events);
1007
- 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));
1008
1970
  }
1009
1971
  async handleDrainFailure(error) {
1010
1972
  if (isEvaluateTimeout(error)) {
@@ -1015,11 +1977,19 @@ var LiveTraceSession = class {
1015
1977
  }
1016
1978
  }
1017
1979
  this.log(`Live Trace stream failed for ${this.options.target.app}: ${formatError(error)}`);
1018
- await this.stopRuntimeTrace(false);
1980
+ await this.stopRuntimeTrace(false, false);
1019
1981
  this.postState("error", "Runtime HTTP trace connection was lost.", false, true);
1020
1982
  }
1021
- 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) {
1022
1989
  this.stopPolling();
1990
+ if (waitForDrain) {
1991
+ await this.drainTask;
1992
+ }
1023
1993
  this.consecutiveDrainTimeouts = 0;
1024
1994
  const uninstalled = await this.stopInspectorHook(uninstallRuntimeHook);
1025
1995
  await this.closeInspectorClient();
@@ -1103,12 +2073,20 @@ var LiveTraceStartupError = class extends Error {
1103
2073
  stateMessage;
1104
2074
  };
1105
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
+ }
1106
2084
  return {
1107
2085
  captureHeaders: options.captureHeaders ?? true,
1108
2086
  captureRequestBody: options.captureRequestBody ?? true,
1109
2087
  captureResponseBody: options.captureResponseBody ?? true,
1110
- maxBodyBytes: options.maxBodyBytes ?? 4096,
1111
- runtimeQueueSize: options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE
2088
+ maxBodyBytes,
2089
+ runtimeQueueSize
1112
2090
  };
1113
2091
  }
1114
2092
  function stopLateTunnel(tunnel) {
@@ -1117,7 +2095,16 @@ function stopLateTunnel(tunnel) {
1117
2095
  }
1118
2096
  }
1119
2097
  function resolveDrainTransportBodyLimit(maxBodyBytes) {
1120
- 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);
1121
2108
  }
1122
2109
  function isEvaluateTimeout(error) {
1123
2110
  const message = error instanceof Error ? error.message : String(error);
@@ -1140,6 +2127,15 @@ function formatError(error) {
1140
2127
  const message = error instanceof Error ? error.message : String(error);
1141
2128
  return message.trim().length > 0 ? message.trim() : "Unknown error";
1142
2129
  }
2130
+ function toSummaryEvent(event) {
2131
+ return {
2132
+ ...event,
2133
+ requestHeaders: {},
2134
+ responseHeaders: {},
2135
+ requestBodyPreview: "",
2136
+ responseBodyPreview: ""
2137
+ };
2138
+ }
1143
2139
  export {
1144
2140
  CF_LIVE_TRACE_GLOBAL_NAME,
1145
2141
  CF_LIVE_TRACE_RUNTIME_SOURCE,
@@ -1151,13 +2147,26 @@ export {
1151
2147
  buildInstallExpression,
1152
2148
  buildStopExpression,
1153
2149
  buildUrlSummaries,
2150
+ compactTraceEvent,
1154
2151
  createSecretRedactor,
2152
+ createTraceSession,
2153
+ detectBodyFormat,
2154
+ inspectTraceBody,
2155
+ inspectTraceBodyResult,
2156
+ listTraceEvents,
2157
+ listTraceSessions,
1155
2158
  normalizeEventUrl,
1156
2159
  openInspectorTunnel,
1157
2160
  parseDrainResult,
1158
2161
  prepareCfSession,
2162
+ pruneTraceSessions,
2163
+ readTraceEvent,
2164
+ searchTraceRecords,
1159
2165
  startNodeInspector,
2166
+ traceSessionsRoot,
1160
2167
  truncatePreview,
1161
- tryStartNodeInspector
2168
+ tryStartNodeInspector,
2169
+ visitTraceEvents,
2170
+ writeTraceEvent
1162
2171
  };
1163
2172
  //# sourceMappingURL=index.js.map