@saptools/cf-live-trace 0.1.0
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/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/cli.js +1283 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +221 -0
- package/dist/index.js +1027 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
// src/runtime-source.ts
|
|
2
|
+
var CF_LIVE_TRACE_GLOBAL_NAME = "__SAPTOOLS_CF_LIVE_TRACE__";
|
|
3
|
+
var CF_LIVE_TRACE_RUNTIME_VERSION = 1;
|
|
4
|
+
var CF_LIVE_TRACE_RUNTIME_SOURCE = `
|
|
5
|
+
(() => {
|
|
6
|
+
const name = '${CF_LIVE_TRACE_GLOBAL_NAME}';
|
|
7
|
+
const runtimeVersion = ${String(CF_LIVE_TRACE_RUNTIME_VERSION)};
|
|
8
|
+
const existing = globalThis[name];
|
|
9
|
+
if (existing && typeof existing.version === 'number' && existing.version >= runtimeVersion) return existing;
|
|
10
|
+
if (existing && typeof existing.uninstall === 'function') {
|
|
11
|
+
try {
|
|
12
|
+
existing.uninstall();
|
|
13
|
+
} catch {}
|
|
14
|
+
}
|
|
15
|
+
let BufferCtor = globalThis.Buffer;
|
|
16
|
+
const state = {
|
|
17
|
+
version: runtimeVersion,
|
|
18
|
+
installed: false,
|
|
19
|
+
enabled: false,
|
|
20
|
+
options: { appId: '', instance: '0', captureHeaders: true, captureRequestBody: true, captureResponseBody: true, maxBodyBytes: 4096, maxEvents: 1000 },
|
|
21
|
+
queue: [],
|
|
22
|
+
droppedCount: 0,
|
|
23
|
+
originals: {},
|
|
24
|
+
seen: new WeakSet(),
|
|
25
|
+
nextId: 1
|
|
26
|
+
};
|
|
27
|
+
const loadRequire = () => {
|
|
28
|
+
if (typeof require === 'function') return require;
|
|
29
|
+
if (globalThis.process && globalThis.process.mainModule && typeof globalThis.process.mainModule.require === 'function') {
|
|
30
|
+
return globalThis.process.mainModule.require.bind(globalThis.process.mainModule);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const loadModule = async (moduleName) => {
|
|
35
|
+
const requireFn = loadRequire();
|
|
36
|
+
if (requireFn) return requireFn(moduleName);
|
|
37
|
+
if (globalThis.process && typeof globalThis.process.getBuiltinModule === 'function') {
|
|
38
|
+
return globalThis.process.getBuiltinModule(moduleName);
|
|
39
|
+
}
|
|
40
|
+
return await import('node:' + moduleName);
|
|
41
|
+
};
|
|
42
|
+
const toHeaderRecord = (headers) => {
|
|
43
|
+
const output = {};
|
|
44
|
+
if (!headers || !state.options.captureHeaders) return output;
|
|
45
|
+
for (const key of Object.keys(headers)) {
|
|
46
|
+
const value = headers[key];
|
|
47
|
+
output[key] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
48
|
+
}
|
|
49
|
+
return output;
|
|
50
|
+
};
|
|
51
|
+
const chunkText = (chunk) => {
|
|
52
|
+
if (chunk === undefined || chunk === null) return '';
|
|
53
|
+
if (BufferCtor && BufferCtor.isBuffer(chunk)) return chunk.toString('utf8');
|
|
54
|
+
if (typeof chunk === 'string') return chunk;
|
|
55
|
+
if (chunk instanceof Uint8Array && BufferCtor) return BufferCtor.from(chunk).toString('utf8');
|
|
56
|
+
return '';
|
|
57
|
+
};
|
|
58
|
+
const byteLength = (text) => BufferCtor ? BufferCtor.byteLength(text) : text.length;
|
|
59
|
+
const appendPreview = (current, chunk, enabled) => {
|
|
60
|
+
if (!enabled) return current;
|
|
61
|
+
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);
|
|
65
|
+
};
|
|
66
|
+
const enqueue = (event) => {
|
|
67
|
+
if (state.queue.length >= state.options.maxEvents) {
|
|
68
|
+
state.queue.shift();
|
|
69
|
+
state.droppedCount += 1;
|
|
70
|
+
}
|
|
71
|
+
state.queue.push(event);
|
|
72
|
+
};
|
|
73
|
+
const observe = (req, res) => {
|
|
74
|
+
if (!state.enabled || !req || !res || state.seen.has(req)) return;
|
|
75
|
+
state.seen.add(req);
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
const initialUrl = String(req.url || '');
|
|
78
|
+
const traceId = String(state.nextId++);
|
|
79
|
+
let requestBytes = 0;
|
|
80
|
+
let responseBytes = 0;
|
|
81
|
+
let requestPreview = '';
|
|
82
|
+
let responsePreview = '';
|
|
83
|
+
let finished = false;
|
|
84
|
+
const originalReqEmit = req.emit;
|
|
85
|
+
const originalWrite = res.write;
|
|
86
|
+
const originalEnd = res.end;
|
|
87
|
+
req.emit = function patchedReqEmit(eventName, ...args) {
|
|
88
|
+
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);
|
|
92
|
+
}
|
|
93
|
+
return originalReqEmit.apply(this, [eventName, ...args]);
|
|
94
|
+
};
|
|
95
|
+
res.write = function patchedWrite(chunk, ...args) {
|
|
96
|
+
const text = chunkText(chunk);
|
|
97
|
+
responseBytes += byteLength(text);
|
|
98
|
+
responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
|
|
99
|
+
return originalWrite.apply(this, [chunk, ...args]);
|
|
100
|
+
};
|
|
101
|
+
res.end = function patchedEnd(chunk, ...args) {
|
|
102
|
+
if (chunk !== undefined) {
|
|
103
|
+
const text = chunkText(chunk);
|
|
104
|
+
responseBytes += byteLength(text);
|
|
105
|
+
responsePreview = appendPreview(responsePreview, chunk, state.options.captureResponseBody);
|
|
106
|
+
}
|
|
107
|
+
return originalEnd.apply(this, [chunk, ...args]);
|
|
108
|
+
};
|
|
109
|
+
const finish = () => {
|
|
110
|
+
if (finished) return;
|
|
111
|
+
finished = true;
|
|
112
|
+
req.emit = originalReqEmit;
|
|
113
|
+
res.write = originalWrite;
|
|
114
|
+
res.end = originalEnd;
|
|
115
|
+
const rawUrl = initialUrl || String(req.url || '');
|
|
116
|
+
enqueue({
|
|
117
|
+
id: traceId,
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
instance: state.options.instance,
|
|
120
|
+
method: String(req.method || 'GET').toUpperCase(),
|
|
121
|
+
path: rawUrl.split('?')[0] || rawUrl,
|
|
122
|
+
url: rawUrl,
|
|
123
|
+
normalizedUrl: rawUrl,
|
|
124
|
+
status: typeof res.statusCode === 'number' ? res.statusCode : null,
|
|
125
|
+
durationMs: Date.now() - started,
|
|
126
|
+
requestBytes,
|
|
127
|
+
responseBytes,
|
|
128
|
+
requestHeaders: toHeaderRecord(req.headers),
|
|
129
|
+
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,
|
|
134
|
+
droppedBeforeEvent: state.droppedCount,
|
|
135
|
+
traceId,
|
|
136
|
+
correlationId: req.headers && typeof req.headers['x-saptools-trace-id'] === 'string' ? req.headers['x-saptools-trace-id'] : null
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
res.once('finish', finish);
|
|
140
|
+
res.once('close', finish);
|
|
141
|
+
};
|
|
142
|
+
const patchEmit = (serverPrototype) => {
|
|
143
|
+
if (!serverPrototype || serverPrototype.emit.__saptoolsCfLiveTracePatched) return undefined;
|
|
144
|
+
const original = serverPrototype.emit;
|
|
145
|
+
const patched = function patchedServerEmit(eventName, ...args) {
|
|
146
|
+
if (eventName === 'request') observe(args[0], args[1]);
|
|
147
|
+
return original.apply(this, [eventName, ...args]);
|
|
148
|
+
};
|
|
149
|
+
patched.__saptoolsCfLiveTracePatched = true;
|
|
150
|
+
serverPrototype.emit = patched;
|
|
151
|
+
return original;
|
|
152
|
+
};
|
|
153
|
+
const toTransportLimit = (value) => {
|
|
154
|
+
const numeric = Number(value);
|
|
155
|
+
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
|
|
156
|
+
};
|
|
157
|
+
const limitPreview = (event, previewKey, truncatedKey, maxChars) => {
|
|
158
|
+
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 };
|
|
161
|
+
};
|
|
162
|
+
const eventForDrain = (event, maxChars) => {
|
|
163
|
+
if (!event || typeof event !== 'object') return event;
|
|
164
|
+
let output = { ...event };
|
|
165
|
+
output = limitPreview(output, 'requestBodyPreview', 'requestBodyTruncated', maxChars);
|
|
166
|
+
output = limitPreview(output, 'responseBodyPreview', 'responseBodyTruncated', maxChars);
|
|
167
|
+
return output;
|
|
168
|
+
};
|
|
169
|
+
const api = {
|
|
170
|
+
version: runtimeVersion,
|
|
171
|
+
async install(options) {
|
|
172
|
+
state.options = { ...state.options, ...options };
|
|
173
|
+
if (!state.installed) {
|
|
174
|
+
if (!BufferCtor) {
|
|
175
|
+
const bufferModule = await loadModule('buffer');
|
|
176
|
+
BufferCtor = bufferModule && bufferModule.Buffer ? bufferModule.Buffer : BufferCtor;
|
|
177
|
+
}
|
|
178
|
+
const http = await loadModule('http');
|
|
179
|
+
const https = await loadModule('https');
|
|
180
|
+
state.originals.httpServerEmit = patchEmit(http && http.Server && http.Server.prototype);
|
|
181
|
+
state.originals.httpsServerEmit = patchEmit(https && https.Server && https.Server.prototype);
|
|
182
|
+
state.installed = true;
|
|
183
|
+
}
|
|
184
|
+
state.enabled = true;
|
|
185
|
+
return api.status();
|
|
186
|
+
},
|
|
187
|
+
disable() {
|
|
188
|
+
state.enabled = false;
|
|
189
|
+
return api.status();
|
|
190
|
+
},
|
|
191
|
+
drainEvents(maxCount, maxTransportBodyBytes) {
|
|
192
|
+
const count = Math.max(0, Math.min(Number(maxCount) || 0, state.queue.length));
|
|
193
|
+
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 };
|
|
196
|
+
},
|
|
197
|
+
status() {
|
|
198
|
+
return { installed: state.installed, enabled: state.enabled, queueSize: state.queue.length, droppedCount: state.droppedCount, maxEvents: state.options.maxEvents };
|
|
199
|
+
},
|
|
200
|
+
uninstall() {
|
|
201
|
+
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
|
+
}
|
|
209
|
+
state.installed = false;
|
|
210
|
+
return api.status();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
globalThis[name] = api;
|
|
214
|
+
return api;
|
|
215
|
+
})()
|
|
216
|
+
`;
|
|
217
|
+
function buildInstallExpression(options) {
|
|
218
|
+
return `${CF_LIVE_TRACE_RUNTIME_SOURCE}.install(${JSON.stringify(options)})`;
|
|
219
|
+
}
|
|
220
|
+
function buildDrainExpression(maxCount, maxTransportBodyBytes) {
|
|
221
|
+
return `globalThis.${CF_LIVE_TRACE_GLOBAL_NAME}?.drainEvents(${String(maxCount)}, ${String(maxTransportBodyBytes)}) ?? { events: [], droppedCount: 0, queueSize: 0 }`;
|
|
222
|
+
}
|
|
223
|
+
function buildStopExpression(options) {
|
|
224
|
+
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
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/preview.ts
|
|
228
|
+
function truncatePreview(preview, maxChars) {
|
|
229
|
+
if (maxChars <= 0) {
|
|
230
|
+
return { preview, truncated: false };
|
|
231
|
+
}
|
|
232
|
+
if (preview.length <= maxChars) {
|
|
233
|
+
return { preview, truncated: false };
|
|
234
|
+
}
|
|
235
|
+
return { preview: preview.slice(0, maxChars), truncated: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/payload.ts
|
|
239
|
+
var fallbackEventId = 0;
|
|
240
|
+
function parseDrainResult(payload, options) {
|
|
241
|
+
if (!isRecord(payload)) {
|
|
242
|
+
return { events: [], droppedCount: 0, queueSize: 0 };
|
|
243
|
+
}
|
|
244
|
+
const rawEvents = Array.isArray(payload["events"]) ? payload["events"] : [];
|
|
245
|
+
return {
|
|
246
|
+
events: rawEvents.map((event) => parseRuntimeEvent(event, options)).filter((event) => event !== null),
|
|
247
|
+
droppedCount: readNonNegativeNumber(payload["droppedCount"]),
|
|
248
|
+
queueSize: readNonNegativeNumber(payload["queueSize"])
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function parseRuntimeEvent(payload, options) {
|
|
252
|
+
if (!isRecord(payload)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const rawUrl = readString(payload["url"]) ?? readString(payload["normalizedUrl"]) ?? readString(payload["path"]);
|
|
256
|
+
if (rawUrl === null) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const requestBody = limitBodyPreview(readString(payload["requestBodyPreview"]) ?? "", options.maxBodyBytes);
|
|
260
|
+
const responseBody = limitBodyPreview(readString(payload["responseBodyPreview"]) ?? "", options.maxBodyBytes);
|
|
261
|
+
return buildEvent(payload, rawUrl, requestBody, responseBody, options);
|
|
262
|
+
}
|
|
263
|
+
function buildEvent(payload, rawUrl, requestBody, responseBody, options) {
|
|
264
|
+
return {
|
|
265
|
+
id: readString(payload["id"]) ?? nextFallbackEventId(),
|
|
266
|
+
timestamp: readString(payload["timestamp"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
267
|
+
appId: options.appId,
|
|
268
|
+
instance: readString(payload["instance"]) ?? "0",
|
|
269
|
+
method: (readString(payload["method"]) ?? "GET").toUpperCase(),
|
|
270
|
+
path: readString(payload["path"]) ?? normalizePath(rawUrl),
|
|
271
|
+
url: rawUrl,
|
|
272
|
+
normalizedUrl: readString(payload["normalizedUrl"]) ?? normalizePath(rawUrl),
|
|
273
|
+
status: readNullableNumber(payload["status"]),
|
|
274
|
+
durationMs: readNullableNumber(payload["durationMs"]),
|
|
275
|
+
requestBytes: readNonNegativeNumber(payload["requestBytes"]),
|
|
276
|
+
responseBytes: readNonNegativeNumber(payload["responseBytes"]),
|
|
277
|
+
requestHeaders: readHeaders(payload["requestHeaders"]),
|
|
278
|
+
responseHeaders: readHeaders(payload["responseHeaders"]),
|
|
279
|
+
requestBodyPreview: requestBody.preview,
|
|
280
|
+
responseBodyPreview: responseBody.preview,
|
|
281
|
+
requestBodyTruncated: payload["requestBodyTruncated"] === true || requestBody.truncated,
|
|
282
|
+
responseBodyTruncated: payload["responseBodyTruncated"] === true || responseBody.truncated,
|
|
283
|
+
droppedBeforeEvent: readNonNegativeNumber(payload["droppedBeforeEvent"]),
|
|
284
|
+
source: "runtime-http",
|
|
285
|
+
traceId: readString(payload["traceId"]) ?? readString(payload["id"]) ?? "",
|
|
286
|
+
correlationId: readString(payload["correlationId"])
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function readHeaders(value) {
|
|
290
|
+
if (!isRecord(value)) {
|
|
291
|
+
return {};
|
|
292
|
+
}
|
|
293
|
+
const headers = {};
|
|
294
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
295
|
+
const header = readHeaderValue(rawValue);
|
|
296
|
+
if (header !== null) {
|
|
297
|
+
headers[key] = header;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return headers;
|
|
301
|
+
}
|
|
302
|
+
function readHeaderValue(value) {
|
|
303
|
+
if (typeof value === "string") {
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
307
|
+
return String(value);
|
|
308
|
+
}
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
return value.map((item) => String(item)).join(", ");
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
function limitBodyPreview(preview, maxChars) {
|
|
315
|
+
return truncatePreview(preview, maxChars);
|
|
316
|
+
}
|
|
317
|
+
function normalizePath(rawUrl) {
|
|
318
|
+
try {
|
|
319
|
+
const parsed = new URL(rawUrl, "https://saptools.local");
|
|
320
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
321
|
+
} catch {
|
|
322
|
+
return rawUrl;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function readString(value) {
|
|
326
|
+
return typeof value === "string" ? value : null;
|
|
327
|
+
}
|
|
328
|
+
function readNullableNumber(value) {
|
|
329
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
330
|
+
}
|
|
331
|
+
function readNonNegativeNumber(value) {
|
|
332
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
333
|
+
}
|
|
334
|
+
function nextFallbackEventId() {
|
|
335
|
+
fallbackEventId += 1;
|
|
336
|
+
return `runtime-${String(fallbackEventId)}`;
|
|
337
|
+
}
|
|
338
|
+
function isRecord(value) {
|
|
339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/summary.ts
|
|
343
|
+
function buildUrlSummaries(events) {
|
|
344
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
345
|
+
for (const event of events) {
|
|
346
|
+
const normalizedUrl = normalizeEventUrl(event);
|
|
347
|
+
const current = summaries.get(normalizedUrl) ?? createSummary(normalizedUrl);
|
|
348
|
+
updateSummary(current, event);
|
|
349
|
+
summaries.set(normalizedUrl, current);
|
|
350
|
+
}
|
|
351
|
+
return [...summaries.values()].map(toImmutableSummary).sort((left, right) => right.latestSeenAt.localeCompare(left.latestSeenAt));
|
|
352
|
+
}
|
|
353
|
+
function normalizeEventUrl(event) {
|
|
354
|
+
const candidate = event.normalizedUrl.length > 0 ? event.normalizedUrl : event.url.length > 0 ? event.url : event.path;
|
|
355
|
+
return normalizeUrl(candidate);
|
|
356
|
+
}
|
|
357
|
+
function updateSummary(summary, event) {
|
|
358
|
+
summary.methods.add(event.method);
|
|
359
|
+
summary.totalCount += 1;
|
|
360
|
+
summary.statusCounts[toStatusBucket(event.status)] += 1;
|
|
361
|
+
if (event.timestamp >= summary.latestSeenAt) {
|
|
362
|
+
summary.latestStatus = event.status;
|
|
363
|
+
summary.latestDurationMs = event.durationMs;
|
|
364
|
+
summary.latestSeenAt = event.timestamp;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function normalizeUrl(rawUrl) {
|
|
368
|
+
if (rawUrl.trim().length === 0) {
|
|
369
|
+
return rawUrl;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const parsed = new URL(rawUrl, "https://saptools.local");
|
|
373
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
374
|
+
} catch {
|
|
375
|
+
return rawUrl;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function createSummary(normalizedUrl) {
|
|
379
|
+
return {
|
|
380
|
+
normalizedUrl,
|
|
381
|
+
displayUrl: normalizedUrl,
|
|
382
|
+
methods: /* @__PURE__ */ new Set(),
|
|
383
|
+
totalCount: 0,
|
|
384
|
+
statusCounts: { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, unknown: 0 },
|
|
385
|
+
latestStatus: null,
|
|
386
|
+
latestDurationMs: null,
|
|
387
|
+
latestSeenAt: ""
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function toImmutableSummary(summary) {
|
|
391
|
+
return {
|
|
392
|
+
normalizedUrl: summary.normalizedUrl,
|
|
393
|
+
displayUrl: summary.displayUrl,
|
|
394
|
+
methods: [...summary.methods].sort(),
|
|
395
|
+
totalCount: summary.totalCount,
|
|
396
|
+
statusCounts: { ...summary.statusCounts },
|
|
397
|
+
latestStatus: summary.latestStatus,
|
|
398
|
+
latestDurationMs: summary.latestDurationMs,
|
|
399
|
+
latestSeenAt: summary.latestSeenAt
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function toStatusBucket(status) {
|
|
403
|
+
if (status === null) {
|
|
404
|
+
return "unknown";
|
|
405
|
+
}
|
|
406
|
+
if (status >= 200 && status < 300) {
|
|
407
|
+
return "2xx";
|
|
408
|
+
}
|
|
409
|
+
if (status >= 300 && status < 400) {
|
|
410
|
+
return "3xx";
|
|
411
|
+
}
|
|
412
|
+
if (status >= 400 && status < 500) {
|
|
413
|
+
return "4xx";
|
|
414
|
+
}
|
|
415
|
+
if (status >= 500 && status < 600) {
|
|
416
|
+
return "5xx";
|
|
417
|
+
}
|
|
418
|
+
return "unknown";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/cf.ts
|
|
422
|
+
import { execFile, spawn } from "child_process";
|
|
423
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
424
|
+
import { connect as netConnect, createServer } from "net";
|
|
425
|
+
import { tmpdir } from "os";
|
|
426
|
+
import { join } from "path";
|
|
427
|
+
import { promisify } from "util";
|
|
428
|
+
import { getAllRegions } from "@saptools/cf-sync";
|
|
429
|
+
var execFileAsync = promisify(execFile);
|
|
430
|
+
var CF_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
431
|
+
var CF_COMMAND_TIMEOUT_MS = 18e4;
|
|
432
|
+
var CF_SSH_READY_TIMEOUT_MS = 6e4;
|
|
433
|
+
var INSPECTOR_SIGNAL_TIMEOUT_MS = 15e3;
|
|
434
|
+
var INSPECTOR_REMOTE_HOST = "127.0.0.1";
|
|
435
|
+
var INSPECTOR_REMOTE_PORT = 9229;
|
|
436
|
+
var TUNNEL_KEEPALIVE_SECONDS = 6 * 60 * 60;
|
|
437
|
+
var TUNNEL_READY_TIMEOUT_MS = 2e4;
|
|
438
|
+
var TUNNEL_READY_POLL_MS = 200;
|
|
439
|
+
async function prepareCfSession(target, dependencies = defaultCfDependencies) {
|
|
440
|
+
const apiEndpoint = resolveApiEndpoint(target);
|
|
441
|
+
const redactor = createSecretRedactor([target.email, target.password]);
|
|
442
|
+
const baseOptions = buildRunOptions(target, redactor);
|
|
443
|
+
await dependencies.runCf(["api", apiEndpoint], baseOptions);
|
|
444
|
+
await dependencies.runCf(["auth"], {
|
|
445
|
+
...baseOptions,
|
|
446
|
+
envOverrides: { CF_USERNAME: target.email, CF_PASSWORD: target.password }
|
|
447
|
+
});
|
|
448
|
+
await dependencies.runCf(["target", "-o", target.org, "-s", target.space], baseOptions);
|
|
449
|
+
}
|
|
450
|
+
async function ensureSshEnabled(target, dependencies = defaultCfDependencies) {
|
|
451
|
+
const redactor = createSecretRedactor([target.email, target.password]);
|
|
452
|
+
const options = buildRunOptions(target, redactor);
|
|
453
|
+
const status = await dependencies.runCf(["ssh-enabled", target.app], options);
|
|
454
|
+
if (parseSshStatus(status) === "enabled") {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
await dependencies.runCf(["enable-ssh", target.app], options);
|
|
458
|
+
await dependencies.runCf(["restart", target.app], options);
|
|
459
|
+
await dependencies.runCf(buildCfSshArgs(target.app, target.instanceIndex, ["-c", "true"]), {
|
|
460
|
+
...options,
|
|
461
|
+
timeoutMs: CF_SSH_READY_TIMEOUT_MS
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
async function tryStartNodeInspector(target, dependencies = defaultCfDependencies) {
|
|
465
|
+
try {
|
|
466
|
+
const redactor = createSecretRedactor([target.email, target.password]);
|
|
467
|
+
const stdout = await dependencies.runCf(
|
|
468
|
+
buildCfSshArgs(target.app, target.instanceIndex, ["-c", buildInspectorSignalCommand()]),
|
|
469
|
+
{ ...buildRunOptions(target, redactor), timeoutMs: INSPECTOR_SIGNAL_TIMEOUT_MS }
|
|
470
|
+
);
|
|
471
|
+
return hasInspectorReadyMarker(stdout);
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function openInspectorTunnel(target, dependencies = defaultTunnelDependencies) {
|
|
477
|
+
const localPort = await dependencies.allocatePort();
|
|
478
|
+
const handle = dependencies.spawnPortForward(buildPortForwardParams(target, localPort));
|
|
479
|
+
const ready = await raceForwardReadiness(handle, dependencies);
|
|
480
|
+
if (!ready) {
|
|
481
|
+
handle.stop();
|
|
482
|
+
return { status: "not-reachable" };
|
|
483
|
+
}
|
|
484
|
+
return { status: "ready", handle };
|
|
485
|
+
}
|
|
486
|
+
function buildCfSshArgs(appName, instanceIndex, tail) {
|
|
487
|
+
const args = ["ssh", appName];
|
|
488
|
+
if (instanceIndex !== void 0) {
|
|
489
|
+
args.push("-i", String(instanceIndex));
|
|
490
|
+
}
|
|
491
|
+
return [...args, ...tail];
|
|
492
|
+
}
|
|
493
|
+
function buildInspectorSignalCommand() {
|
|
494
|
+
return INSPECTOR_SIGNAL_COMMAND;
|
|
495
|
+
}
|
|
496
|
+
function createSecretRedactor(secrets) {
|
|
497
|
+
const values = secrets.map((secret) => secret.trim()).filter((secret) => secret.length > 0);
|
|
498
|
+
return (message) => values.reduce((current, secret) => current.split(secret).join("<redacted>"), message);
|
|
499
|
+
}
|
|
500
|
+
async function runCfCommand(args, options) {
|
|
501
|
+
const command = resolveCommand(options.command);
|
|
502
|
+
try {
|
|
503
|
+
const { stdout } = await execFileAsync(command.bin, [...command.argsPrefix, ...args], {
|
|
504
|
+
env: buildCfEnv(options.cfHomeDir, options.envOverrides),
|
|
505
|
+
maxBuffer: CF_MAX_BUFFER_BYTES,
|
|
506
|
+
timeout: options.timeoutMs ?? CF_COMMAND_TIMEOUT_MS
|
|
507
|
+
});
|
|
508
|
+
return stdout;
|
|
509
|
+
} catch (error) {
|
|
510
|
+
throw new Error(formatCfError(args, error, options.redactor), { cause: error });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function spawnPortForward(params) {
|
|
514
|
+
const command = resolveCommand(params.command);
|
|
515
|
+
const forwardSpec = `${String(params.localPort)}:${params.remoteHost}:${String(params.remotePort)}`;
|
|
516
|
+
const sshArgs = buildCfSshArgs(params.appName, params.instanceIndex, ["-L", forwardSpec, "-c", `sleep ${String(params.keepAliveSeconds)}`]);
|
|
517
|
+
const child = spawn(command.bin, [...command.argsPrefix, ...sshArgs], {
|
|
518
|
+
env: buildCfEnv(params.cfHomeDir),
|
|
519
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
520
|
+
});
|
|
521
|
+
return {
|
|
522
|
+
process: child,
|
|
523
|
+
localPort: params.localPort,
|
|
524
|
+
stop() {
|
|
525
|
+
if (!child.killed) {
|
|
526
|
+
child.kill();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
var defaultCfDependencies = {
|
|
532
|
+
runCf: runCfCommand
|
|
533
|
+
};
|
|
534
|
+
var defaultTunnelDependencies = {
|
|
535
|
+
allocatePort: findFreePort,
|
|
536
|
+
spawnPortForward,
|
|
537
|
+
waitForLocalPort
|
|
538
|
+
};
|
|
539
|
+
function buildRunOptions(target, redactor) {
|
|
540
|
+
return {
|
|
541
|
+
...target.cfHomeDir === void 0 ? {} : { cfHomeDir: target.cfHomeDir },
|
|
542
|
+
...target.command === void 0 ? {} : { command: target.command },
|
|
543
|
+
redactor
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function resolveApiEndpoint(target) {
|
|
547
|
+
if (target.apiEndpoint !== void 0 && target.apiEndpoint.trim().length > 0) {
|
|
548
|
+
return target.apiEndpoint.trim();
|
|
549
|
+
}
|
|
550
|
+
const region = getAllRegions().find((item) => item.key === target.region);
|
|
551
|
+
if (region === void 0) {
|
|
552
|
+
throw new Error(`Unknown CF region: ${target.region ?? "<missing>"}`);
|
|
553
|
+
}
|
|
554
|
+
return region.apiEndpoint;
|
|
555
|
+
}
|
|
556
|
+
function parseSshStatus(stdout) {
|
|
557
|
+
return stdout.toLowerCase().includes("enabled") && !stdout.toLowerCase().includes("disabled") ? "enabled" : "disabled";
|
|
558
|
+
}
|
|
559
|
+
function buildPortForwardParams(target, localPort) {
|
|
560
|
+
return {
|
|
561
|
+
appName: resolveTunnelAppName(target),
|
|
562
|
+
localPort,
|
|
563
|
+
remoteHost: INSPECTOR_REMOTE_HOST,
|
|
564
|
+
remotePort: INSPECTOR_REMOTE_PORT,
|
|
565
|
+
keepAliveSeconds: TUNNEL_KEEPALIVE_SECONDS,
|
|
566
|
+
...target.cfHomeDir === void 0 ? {} : { cfHomeDir: target.cfHomeDir },
|
|
567
|
+
...target.command === void 0 ? {} : { command: target.command },
|
|
568
|
+
...target.instanceIndex === void 0 ? {} : { instanceIndex: target.instanceIndex }
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function resolveTunnelAppName(target) {
|
|
572
|
+
const appName = target.appName ?? target.app;
|
|
573
|
+
if (appName === void 0 || appName.trim().length === 0) {
|
|
574
|
+
throw new Error("CF app name is required for the inspector tunnel.");
|
|
575
|
+
}
|
|
576
|
+
return appName;
|
|
577
|
+
}
|
|
578
|
+
async function raceForwardReadiness(handle, dependencies) {
|
|
579
|
+
let markFailed = () => {
|
|
580
|
+
return;
|
|
581
|
+
};
|
|
582
|
+
const failedEarly = new Promise((resolve) => {
|
|
583
|
+
markFailed = () => {
|
|
584
|
+
resolve(false);
|
|
585
|
+
};
|
|
586
|
+
handle.process.once("exit", markFailed);
|
|
587
|
+
handle.process.once("error", markFailed);
|
|
588
|
+
});
|
|
589
|
+
const ready = dependencies.waitForLocalPort(handle.localPort, TUNNEL_READY_TIMEOUT_MS);
|
|
590
|
+
const outcome = await Promise.race([ready, failedEarly]);
|
|
591
|
+
handle.process.removeListener("exit", markFailed);
|
|
592
|
+
handle.process.removeListener("error", markFailed);
|
|
593
|
+
return outcome;
|
|
594
|
+
}
|
|
595
|
+
function resolveCommand(command) {
|
|
596
|
+
const resolvedBin = command ?? process.env["CF_LIVE_TRACE_CF_BIN"] ?? "cf";
|
|
597
|
+
return /\.(?:c|m)?js$/i.test(resolvedBin) ? { bin: process.execPath, argsPrefix: [resolvedBin] } : { bin: resolvedBin, argsPrefix: [] };
|
|
598
|
+
}
|
|
599
|
+
function buildCfEnv(cfHomeDir, envOverrides) {
|
|
600
|
+
const env = { ...process.env };
|
|
601
|
+
delete env["SAP_EMAIL"];
|
|
602
|
+
delete env["SAP_PASSWORD"];
|
|
603
|
+
if (cfHomeDir !== void 0 && cfHomeDir.length > 0) {
|
|
604
|
+
env["CF_HOME"] = cfHomeDir;
|
|
605
|
+
}
|
|
606
|
+
return envOverrides === void 0 ? env : { ...env, ...envOverrides };
|
|
607
|
+
}
|
|
608
|
+
function formatCfError(args, error, redactor) {
|
|
609
|
+
const detail = extractErrorDetail(error);
|
|
610
|
+
const message = `cf ${formatArgs(args)} failed${detail.length > 0 ? `: ${detail}` : "."}`;
|
|
611
|
+
return redactor?.(message) ?? message;
|
|
612
|
+
}
|
|
613
|
+
function extractErrorDetail(error) {
|
|
614
|
+
if (!isRecord2(error)) {
|
|
615
|
+
return "";
|
|
616
|
+
}
|
|
617
|
+
const stderr = typeof error["stderr"] === "string" ? error["stderr"].trim() : "";
|
|
618
|
+
if (stderr.length > 0) {
|
|
619
|
+
return stderr;
|
|
620
|
+
}
|
|
621
|
+
return error["message"] instanceof Error ? error["message"].message : "";
|
|
622
|
+
}
|
|
623
|
+
function formatArgs(args) {
|
|
624
|
+
return args.join(" ");
|
|
625
|
+
}
|
|
626
|
+
function hasInspectorReadyMarker(stdout) {
|
|
627
|
+
return stdout.split(/\r?\n/).map((line) => line.trim()).includes("saptools-inspector-ready");
|
|
628
|
+
}
|
|
629
|
+
function findFreePort() {
|
|
630
|
+
return new Promise((resolve, reject) => {
|
|
631
|
+
const server = createServer();
|
|
632
|
+
server.once("error", reject);
|
|
633
|
+
server.listen(0, "127.0.0.1", () => {
|
|
634
|
+
const address = server.address();
|
|
635
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
636
|
+
server.close(() => {
|
|
637
|
+
if (port === 0) {
|
|
638
|
+
reject(new Error("Failed to allocate a local port."));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
resolve(port);
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
function waitForLocalPort(port, timeoutMs) {
|
|
647
|
+
const deadline = Date.now() + timeoutMs;
|
|
648
|
+
return new Promise((resolve) => {
|
|
649
|
+
const attempt = () => {
|
|
650
|
+
const socket = netConnect({ host: "127.0.0.1", port });
|
|
651
|
+
socket.once("connect", () => {
|
|
652
|
+
socket.destroy();
|
|
653
|
+
resolve(true);
|
|
654
|
+
});
|
|
655
|
+
socket.once("error", () => {
|
|
656
|
+
retryPortProbe(socket, deadline, attempt, resolve);
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
attempt();
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
function retryPortProbe(socket, deadline, attempt, resolve) {
|
|
663
|
+
socket.destroy();
|
|
664
|
+
if (Date.now() >= deadline) {
|
|
665
|
+
resolve(false);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
setTimeout(attempt, TUNNEL_READY_POLL_MS);
|
|
669
|
+
}
|
|
670
|
+
function isRecord2(value) {
|
|
671
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
672
|
+
}
|
|
673
|
+
var INSPECTOR_SIGNAL_COMMAND = [
|
|
674
|
+
'inspector_url="http://127.0.0.1:9229/json/list"',
|
|
675
|
+
'inspector_ready() { ((command -v curl >/dev/null 2>&1 && curl -fsS --max-time 1 "$inspector_url" >/dev/null 2>&1) || (command -v wget >/dev/null 2>&1 && wget -qO- -T 1 "$inspector_url" >/dev/null 2>&1)); }',
|
|
676
|
+
"if inspector_ready; then",
|
|
677
|
+
"echo saptools-inspector-ready",
|
|
678
|
+
"exit 0",
|
|
679
|
+
"fi",
|
|
680
|
+
'node_pid=""',
|
|
681
|
+
"best_score=-1",
|
|
682
|
+
"for pid_dir in /proc/[0-9]*; do",
|
|
683
|
+
'[ -d "$pid_dir" ] || continue',
|
|
684
|
+
'node_exe="$(readlink "$pid_dir/exe" 2>/dev/null || true)"',
|
|
685
|
+
'[ "${node_exe##*/}" = "node" ] || continue',
|
|
686
|
+
'node_cmdline="$(tr "\\000" " " < "$pid_dir/cmdline" 2>/dev/null || true)"',
|
|
687
|
+
'[ -n "$node_cmdline" ] || continue',
|
|
688
|
+
"score=10",
|
|
689
|
+
'if printf "%s\\n" "$node_cmdline" | grep -Eq "@sap/cds|cds/bin/serve|serve\\.js|server\\.js|app\\.js|dist|build|index\\.js"; then',
|
|
690
|
+
"score=20",
|
|
691
|
+
"fi",
|
|
692
|
+
'if [ "$score" -gt "$best_score" ]; then',
|
|
693
|
+
'best_score="$score"',
|
|
694
|
+
'node_pid="${pid_dir##*/}"',
|
|
695
|
+
"fi",
|
|
696
|
+
"done",
|
|
697
|
+
'if [ -z "$node_pid" ]; then',
|
|
698
|
+
"echo saptools-inspector-node-not-found",
|
|
699
|
+
"exit 0",
|
|
700
|
+
"fi",
|
|
701
|
+
'echo "saptools-inspector-node-pid=$node_pid"',
|
|
702
|
+
'if kill -USR1 "$node_pid" 2>/dev/null; then',
|
|
703
|
+
"echo saptools-inspector-signaled",
|
|
704
|
+
"else",
|
|
705
|
+
"echo saptools-inspector-signal-failed",
|
|
706
|
+
"exit 0",
|
|
707
|
+
"fi",
|
|
708
|
+
"attempt=0",
|
|
709
|
+
'while [ "$attempt" -lt 20 ]; do',
|
|
710
|
+
"if inspector_ready; then",
|
|
711
|
+
"echo saptools-inspector-ready",
|
|
712
|
+
"exit 0",
|
|
713
|
+
"fi",
|
|
714
|
+
"attempt=$((attempt + 1))",
|
|
715
|
+
"sleep 0.25",
|
|
716
|
+
"done",
|
|
717
|
+
"echo saptools-inspector-not-ready"
|
|
718
|
+
].join("\\n");
|
|
719
|
+
|
|
720
|
+
// src/inspector.ts
|
|
721
|
+
import { connectInspector } from "@saptools/cf-inspector";
|
|
722
|
+
async function connectRuntimeInspector(localPort) {
|
|
723
|
+
const session = await connectInspector({ port: localPort, host: "127.0.0.1" });
|
|
724
|
+
return new CdpRuntimeClient(session);
|
|
725
|
+
}
|
|
726
|
+
var CdpRuntimeClient = class {
|
|
727
|
+
constructor(session) {
|
|
728
|
+
this.session = session;
|
|
729
|
+
}
|
|
730
|
+
session;
|
|
731
|
+
async evaluate(expression, timeoutMs) {
|
|
732
|
+
const result = await raceEvaluate(
|
|
733
|
+
this.session.client.send("Runtime.evaluate", {
|
|
734
|
+
expression,
|
|
735
|
+
awaitPromise: true,
|
|
736
|
+
returnByValue: true,
|
|
737
|
+
silent: true
|
|
738
|
+
}),
|
|
739
|
+
timeoutMs
|
|
740
|
+
);
|
|
741
|
+
return extractEvaluateValue(result);
|
|
742
|
+
}
|
|
743
|
+
async close() {
|
|
744
|
+
await this.session.dispose();
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
async function raceEvaluate(promise, timeoutMs) {
|
|
748
|
+
let timer;
|
|
749
|
+
const timeout = new Promise((_, reject) => {
|
|
750
|
+
timer = setTimeout(() => {
|
|
751
|
+
reject(new Error("Runtime.evaluate timed out."));
|
|
752
|
+
}, timeoutMs);
|
|
753
|
+
});
|
|
754
|
+
try {
|
|
755
|
+
return await Promise.race([promise, timeout]);
|
|
756
|
+
} finally {
|
|
757
|
+
if (timer !== void 0) {
|
|
758
|
+
clearTimeout(timer);
|
|
759
|
+
}
|
|
760
|
+
promise.catch(() => {
|
|
761
|
+
return;
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function extractEvaluateValue(result) {
|
|
766
|
+
if (result.exceptionDetails !== void 0) {
|
|
767
|
+
throw new Error("Runtime.evaluate failed.");
|
|
768
|
+
}
|
|
769
|
+
return result.result?.value;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/session.ts
|
|
773
|
+
var DRAIN_INTERVAL_MS = 250;
|
|
774
|
+
var DRAIN_BATCH_SIZE = 50;
|
|
775
|
+
var RUNTIME_QUEUE_SIZE = 1e3;
|
|
776
|
+
var CONTROL_EVALUATE_TIMEOUT_MS = 5e3;
|
|
777
|
+
var DRAIN_EVALUATE_TIMEOUT_MS = 1e4;
|
|
778
|
+
var DRAIN_TIMEOUT_RETRY_LIMIT = 3;
|
|
779
|
+
var DRAIN_TRANSPORT_BODY_LIMIT = 2e4;
|
|
780
|
+
var defaultDependencies = {
|
|
781
|
+
prepareCfSession,
|
|
782
|
+
ensureSshEnabled,
|
|
783
|
+
tryStartNodeInspector,
|
|
784
|
+
openInspectorTunnel,
|
|
785
|
+
connectInspector: connectRuntimeInspector,
|
|
786
|
+
setInterval,
|
|
787
|
+
clearInterval
|
|
788
|
+
};
|
|
789
|
+
var LiveTraceSession = class {
|
|
790
|
+
constructor(options, dependencies = {}) {
|
|
791
|
+
this.options = options;
|
|
792
|
+
this.dependencies = { ...defaultDependencies, ...dependencies };
|
|
793
|
+
}
|
|
794
|
+
options;
|
|
795
|
+
dependencies;
|
|
796
|
+
events = [];
|
|
797
|
+
consecutiveDrainTimeouts = 0;
|
|
798
|
+
drainInFlight = false;
|
|
799
|
+
inspectorClient;
|
|
800
|
+
pollTimer;
|
|
801
|
+
state = "idle";
|
|
802
|
+
stopRequested = false;
|
|
803
|
+
tunnelHandle;
|
|
804
|
+
async start(options = {}) {
|
|
805
|
+
if (this.isRunning()) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
this.stopRequested = false;
|
|
809
|
+
await this.startRuntimeTrace(resolveStartOptions(options));
|
|
810
|
+
}
|
|
811
|
+
async stop(options) {
|
|
812
|
+
this.stopRequested = true;
|
|
813
|
+
if (!this.isRunning()) {
|
|
814
|
+
this.postState("stopped", `Trace stopped (${options.reason}).`, false, false);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const hadRuntimeHook = this.inspectorClient !== void 0;
|
|
818
|
+
this.postState("stopping", `Stopping trace (${options.reason}).`, hadRuntimeHook, hadRuntimeHook);
|
|
819
|
+
const uninstalled = await this.stopRuntimeTrace(options.uninstallRuntimeHook);
|
|
820
|
+
this.postState("stopped", `Trace stopped (${options.reason}).`, false, hadRuntimeHook && !uninstalled);
|
|
821
|
+
}
|
|
822
|
+
isRunning() {
|
|
823
|
+
return ["preparing", "enabling-ssh", "starting-inspector", "opening-tunnel", "injecting", "streaming", "stopping"].includes(this.state);
|
|
824
|
+
}
|
|
825
|
+
async startRuntimeTrace(options) {
|
|
826
|
+
try {
|
|
827
|
+
this.postState("preparing", "Preparing Cloud Foundry session.", false, false);
|
|
828
|
+
await this.dependencies.prepareCfSession(this.options.target);
|
|
829
|
+
if (this.shouldStop()) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
await this.startInspector(options);
|
|
833
|
+
} catch (error) {
|
|
834
|
+
this.log(`Live Trace startup failed for ${this.options.target.app}: ${formatError(error)}`);
|
|
835
|
+
await this.stopRuntimeTrace(false);
|
|
836
|
+
if (!this.shouldStop()) {
|
|
837
|
+
this.postState("error", "Runtime HTTP trace could not be started.", false, false);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async startInspector(options) {
|
|
842
|
+
this.postState("enabling-ssh", "Ensuring CF SSH access.", false, false);
|
|
843
|
+
await this.dependencies.ensureSshEnabled(this.options.target);
|
|
844
|
+
if (this.shouldStop()) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
this.postState("starting-inspector", "Requesting Node Inspector startup.", false, false);
|
|
848
|
+
await this.dependencies.tryStartNodeInspector(this.options.target);
|
|
849
|
+
if (this.shouldStop()) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
this.postState("opening-tunnel", "Opening Node Inspector tunnel.", false, false);
|
|
853
|
+
const tunnel = await this.dependencies.openInspectorTunnel(this.options.target);
|
|
854
|
+
await this.attachInspector(tunnel, options);
|
|
855
|
+
}
|
|
856
|
+
async attachInspector(tunnel, options) {
|
|
857
|
+
if (this.shouldStop()) {
|
|
858
|
+
stopLateTunnel(tunnel);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (tunnel.status !== "ready") {
|
|
862
|
+
this.postState("error", "Node Inspector is not reachable on 127.0.0.1:9229.", false, false);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
this.tunnelHandle = tunnel.handle;
|
|
866
|
+
this.inspectorClient = await this.dependencies.connectInspector(tunnel.handle.localPort);
|
|
867
|
+
this.postState("injecting", "Installing runtime HTTP trace hook.", false, false);
|
|
868
|
+
await this.installRuntimeHook(options);
|
|
869
|
+
this.startPolling(options.maxBodyBytes);
|
|
870
|
+
this.postState("streaming", "Streaming runtime HTTP trace events.", true, false);
|
|
871
|
+
}
|
|
872
|
+
async installRuntimeHook(options) {
|
|
873
|
+
await this.requireInspector().evaluate(buildInstallExpression({
|
|
874
|
+
appId: this.options.target.app,
|
|
875
|
+
instance: String(this.options.target.instanceIndex ?? 0),
|
|
876
|
+
captureHeaders: options.captureHeaders,
|
|
877
|
+
captureRequestBody: options.captureRequestBody,
|
|
878
|
+
captureResponseBody: options.captureResponseBody,
|
|
879
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
880
|
+
maxEvents: options.runtimeQueueSize
|
|
881
|
+
}), CONTROL_EVALUATE_TIMEOUT_MS);
|
|
882
|
+
}
|
|
883
|
+
startPolling(maxBodyBytes) {
|
|
884
|
+
this.stopPolling();
|
|
885
|
+
this.consecutiveDrainTimeouts = 0;
|
|
886
|
+
this.pollTimer = this.dependencies.setInterval(() => {
|
|
887
|
+
void this.drainTraceEvents(maxBodyBytes);
|
|
888
|
+
}, DRAIN_INTERVAL_MS);
|
|
889
|
+
}
|
|
890
|
+
async drainTraceEvents(maxBodyBytes) {
|
|
891
|
+
if (this.drainInFlight || this.inspectorClient === void 0 || this.state !== "streaming") {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
this.drainInFlight = true;
|
|
895
|
+
try {
|
|
896
|
+
const payload = await this.inspectorClient.evaluate(
|
|
897
|
+
buildDrainExpression(DRAIN_BATCH_SIZE, resolveDrainTransportBodyLimit(maxBodyBytes)),
|
|
898
|
+
DRAIN_EVALUATE_TIMEOUT_MS
|
|
899
|
+
);
|
|
900
|
+
this.consecutiveDrainTimeouts = 0;
|
|
901
|
+
this.publishDrainedEvents(payload, maxBodyBytes);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
await this.handleDrainFailure(error);
|
|
904
|
+
} finally {
|
|
905
|
+
this.drainInFlight = false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
publishDrainedEvents(payload, maxBodyBytes) {
|
|
909
|
+
const drained = parseDrainResult(payload, { appId: this.options.target.app, maxBodyBytes });
|
|
910
|
+
if (drained.events.length === 0) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
this.events.push(...drained.events);
|
|
914
|
+
this.events.splice(0, Math.max(0, this.events.length - RUNTIME_QUEUE_SIZE));
|
|
915
|
+
this.options.onEvents?.(drained.events);
|
|
916
|
+
this.options.onSummary?.(buildUrlSummaries(this.events));
|
|
917
|
+
}
|
|
918
|
+
async handleDrainFailure(error) {
|
|
919
|
+
if (isEvaluateTimeout(error)) {
|
|
920
|
+
this.consecutiveDrainTimeouts += 1;
|
|
921
|
+
if (this.consecutiveDrainTimeouts < DRAIN_TIMEOUT_RETRY_LIMIT) {
|
|
922
|
+
this.log(`Live Trace drain timed out for ${this.options.target.app}; retrying (${String(this.consecutiveDrainTimeouts)}/${String(DRAIN_TIMEOUT_RETRY_LIMIT)}).`);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
this.log(`Live Trace stream failed for ${this.options.target.app}: ${formatError(error)}`);
|
|
927
|
+
await this.stopRuntimeTrace(false);
|
|
928
|
+
this.postState("error", "Runtime HTTP trace connection was lost.", false, true);
|
|
929
|
+
}
|
|
930
|
+
async stopRuntimeTrace(uninstallRuntimeHook) {
|
|
931
|
+
this.stopPolling();
|
|
932
|
+
this.consecutiveDrainTimeouts = 0;
|
|
933
|
+
const uninstalled = await this.stopInspectorHook(uninstallRuntimeHook);
|
|
934
|
+
await this.inspectorClient?.close();
|
|
935
|
+
this.inspectorClient = void 0;
|
|
936
|
+
this.tunnelHandle?.stop();
|
|
937
|
+
this.tunnelHandle = void 0;
|
|
938
|
+
return uninstalled;
|
|
939
|
+
}
|
|
940
|
+
async stopInspectorHook(uninstallRuntimeHook) {
|
|
941
|
+
if (this.inspectorClient === void 0) {
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
await this.inspectorClient.evaluate(buildStopExpression({ uninstallRuntimeHook }), CONTROL_EVALUATE_TIMEOUT_MS);
|
|
946
|
+
return uninstallRuntimeHook;
|
|
947
|
+
} catch (error) {
|
|
948
|
+
this.log(`Live Trace cleanup failed for ${this.options.target.app}: ${formatError(error)}`);
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
stopPolling() {
|
|
953
|
+
if (this.pollTimer === void 0) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
this.dependencies.clearInterval(this.pollTimer);
|
|
957
|
+
this.pollTimer = void 0;
|
|
958
|
+
}
|
|
959
|
+
postState(state, message, runtimeHookInstalled, runtimeHookMayRemain) {
|
|
960
|
+
this.state = state;
|
|
961
|
+
this.options.onState?.({
|
|
962
|
+
state,
|
|
963
|
+
app: this.options.target.app,
|
|
964
|
+
instance: String(this.options.target.instanceIndex ?? 0),
|
|
965
|
+
message,
|
|
966
|
+
runtimeHookInstalled,
|
|
967
|
+
runtimeHookMayRemain
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
requireInspector() {
|
|
971
|
+
if (this.inspectorClient === void 0) {
|
|
972
|
+
throw new Error("Inspector client is not connected.");
|
|
973
|
+
}
|
|
974
|
+
return this.inspectorClient;
|
|
975
|
+
}
|
|
976
|
+
shouldStop() {
|
|
977
|
+
return this.stopRequested;
|
|
978
|
+
}
|
|
979
|
+
log(message) {
|
|
980
|
+
this.options.onLog?.(message);
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
function resolveStartOptions(options) {
|
|
984
|
+
return {
|
|
985
|
+
captureHeaders: options.captureHeaders ?? true,
|
|
986
|
+
captureRequestBody: options.captureRequestBody ?? true,
|
|
987
|
+
captureResponseBody: options.captureResponseBody ?? true,
|
|
988
|
+
maxBodyBytes: options.maxBodyBytes ?? 4096,
|
|
989
|
+
runtimeQueueSize: options.runtimeQueueSize ?? RUNTIME_QUEUE_SIZE
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
function stopLateTunnel(tunnel) {
|
|
993
|
+
if (tunnel.status === "ready") {
|
|
994
|
+
tunnel.handle.stop();
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
function resolveDrainTransportBodyLimit(maxBodyBytes) {
|
|
998
|
+
return maxBodyBytes > 0 ? Math.min(maxBodyBytes, DRAIN_TRANSPORT_BODY_LIMIT) : DRAIN_TRANSPORT_BODY_LIMIT;
|
|
999
|
+
}
|
|
1000
|
+
function isEvaluateTimeout(error) {
|
|
1001
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1002
|
+
return message.includes("Runtime.evaluate timed out");
|
|
1003
|
+
}
|
|
1004
|
+
function formatError(error) {
|
|
1005
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1006
|
+
return message.trim().length > 0 ? message.trim() : "Unknown error";
|
|
1007
|
+
}
|
|
1008
|
+
export {
|
|
1009
|
+
CF_LIVE_TRACE_GLOBAL_NAME,
|
|
1010
|
+
CF_LIVE_TRACE_RUNTIME_SOURCE,
|
|
1011
|
+
CF_LIVE_TRACE_RUNTIME_VERSION,
|
|
1012
|
+
LiveTraceSession,
|
|
1013
|
+
buildCfSshArgs,
|
|
1014
|
+
buildDrainExpression,
|
|
1015
|
+
buildInspectorSignalCommand,
|
|
1016
|
+
buildInstallExpression,
|
|
1017
|
+
buildStopExpression,
|
|
1018
|
+
buildUrlSummaries,
|
|
1019
|
+
createSecretRedactor,
|
|
1020
|
+
normalizeEventUrl,
|
|
1021
|
+
openInspectorTunnel,
|
|
1022
|
+
parseDrainResult,
|
|
1023
|
+
prepareCfSession,
|
|
1024
|
+
truncatePreview,
|
|
1025
|
+
tryStartNodeInspector
|
|
1026
|
+
};
|
|
1027
|
+
//# sourceMappingURL=index.js.map
|