@apifuse/connector-sdk 2.0.0-beta.1
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/README.md +44 -0
- package/bin/apifuse-check.ts +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/connectors-yaml.test.ts +135 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +665 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type ApiFuseConfig,
|
|
11
|
+
type ConnectorContext,
|
|
12
|
+
type ConnectorDefinition,
|
|
13
|
+
ConnectorError,
|
|
14
|
+
createAuthManager,
|
|
15
|
+
createHttpClient,
|
|
16
|
+
createSessionStore,
|
|
17
|
+
createStateContext,
|
|
18
|
+
createTlsClient,
|
|
19
|
+
executeOperation,
|
|
20
|
+
getConnectorBaseUrl,
|
|
21
|
+
type HttpClient,
|
|
22
|
+
loadApiFuseConfig,
|
|
23
|
+
type Span,
|
|
24
|
+
type TlsClient,
|
|
25
|
+
type TlsResponse,
|
|
26
|
+
wrapWithInstrumentation,
|
|
27
|
+
} from "../src";
|
|
28
|
+
import {
|
|
29
|
+
computeStats,
|
|
30
|
+
groupSpansByName,
|
|
31
|
+
type PerfStats,
|
|
32
|
+
} from "../src/runtime/perf";
|
|
33
|
+
import {
|
|
34
|
+
createTraceContext,
|
|
35
|
+
resolveTraceContextOptions,
|
|
36
|
+
} from "../src/runtime/trace";
|
|
37
|
+
import { renderWaterfall } from "../src/runtime/waterfall";
|
|
38
|
+
import type { BrowserClient } from "../src/types";
|
|
39
|
+
|
|
40
|
+
type CliArgs = {
|
|
41
|
+
compareProxy: boolean;
|
|
42
|
+
concurrency: number;
|
|
43
|
+
connectorPath: string;
|
|
44
|
+
exportPath?: string;
|
|
45
|
+
flame: boolean;
|
|
46
|
+
operation: string;
|
|
47
|
+
runs: number;
|
|
48
|
+
warmup: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type FixtureReplay = {
|
|
52
|
+
raw: unknown;
|
|
53
|
+
rawText: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type RunResult = {
|
|
57
|
+
durationMs: number;
|
|
58
|
+
mode: "live" | "fixture";
|
|
59
|
+
spans: Span[];
|
|
60
|
+
status: "ok" | "error";
|
|
61
|
+
waterfall: string;
|
|
62
|
+
proxyEnabled: boolean;
|
|
63
|
+
error?: string;
|
|
64
|
+
output?: unknown;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type ProfileSuite = {
|
|
68
|
+
breakdown: Array<{ avgMs: number; name: string; percent: number }>;
|
|
69
|
+
insights: string[];
|
|
70
|
+
label: string;
|
|
71
|
+
runs: RunResult[];
|
|
72
|
+
stats: PerfStats;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type FlameNode = {
|
|
76
|
+
children: FlameNode[];
|
|
77
|
+
depth: number;
|
|
78
|
+
span: Span;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const DEFAULT_RUNS = 10;
|
|
82
|
+
const DEFAULT_WARMUP = 2;
|
|
83
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
84
|
+
const BAR_WIDTH = 20;
|
|
85
|
+
|
|
86
|
+
async function main() {
|
|
87
|
+
try {
|
|
88
|
+
const args = parseArgs(process.argv.slice(2));
|
|
89
|
+
const connectorDirectory = resolve(process.cwd(), args.connectorPath);
|
|
90
|
+
const connectorEntry = resolveConnectorEntry(connectorDirectory);
|
|
91
|
+
const connector = await loadConnector(connectorEntry);
|
|
92
|
+
const connectorId = basename(connectorDirectory);
|
|
93
|
+
const config = await loadApiFuseConfig(process.cwd());
|
|
94
|
+
const operation = getOperation(connector, args.operation);
|
|
95
|
+
const inputSchema = getOperationSchema(connector, operation, "input");
|
|
96
|
+
const outputSchema = getOperationSchema(connector, operation, "output");
|
|
97
|
+
const fixtureReplay = await loadFixtureReplay(connectorDirectory);
|
|
98
|
+
const inputTemplate = resolveInputTemplate(connector, inputSchema);
|
|
99
|
+
|
|
100
|
+
const directSuite = await runProfileSuite({
|
|
101
|
+
args,
|
|
102
|
+
config,
|
|
103
|
+
connector,
|
|
104
|
+
connectorId,
|
|
105
|
+
fixtureReplay,
|
|
106
|
+
inputSchema,
|
|
107
|
+
inputTemplate,
|
|
108
|
+
operationName: args.operation,
|
|
109
|
+
outputSchema,
|
|
110
|
+
proxyEnabled: false,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let proxySuite: ProfileSuite | undefined;
|
|
114
|
+
if (args.compareProxy) {
|
|
115
|
+
assertProxyConfigured(config);
|
|
116
|
+
proxySuite = await runProfileSuite({
|
|
117
|
+
args,
|
|
118
|
+
config,
|
|
119
|
+
connector,
|
|
120
|
+
connectorId,
|
|
121
|
+
fixtureReplay,
|
|
122
|
+
inputSchema,
|
|
123
|
+
inputTemplate,
|
|
124
|
+
operationName: args.operation,
|
|
125
|
+
outputSchema,
|
|
126
|
+
proxyEnabled: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const report = renderReport({
|
|
131
|
+
connectorId,
|
|
132
|
+
operationName: args.operation,
|
|
133
|
+
runs: args.runs,
|
|
134
|
+
suite: directSuite,
|
|
135
|
+
proxySuite,
|
|
136
|
+
});
|
|
137
|
+
console.log(report);
|
|
138
|
+
|
|
139
|
+
let flamePath: string | undefined;
|
|
140
|
+
if (args.flame) {
|
|
141
|
+
flamePath = await writeFlamegraph({
|
|
142
|
+
connectorId,
|
|
143
|
+
operationName: args.operation,
|
|
144
|
+
outputPath: args.exportPath,
|
|
145
|
+
representativeRun: selectRepresentativeRun(directSuite.runs),
|
|
146
|
+
fallbackDirectory: process.cwd(),
|
|
147
|
+
label: `${connectorId}/${args.operation}`,
|
|
148
|
+
proxyEnabled: false,
|
|
149
|
+
stats: directSuite.stats,
|
|
150
|
+
mode: directSuite.runs.some((run) => run.mode === "fixture")
|
|
151
|
+
? "fixture"
|
|
152
|
+
: "live",
|
|
153
|
+
});
|
|
154
|
+
console.log(`Flamegraph: ${flamePath}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (args.exportPath) {
|
|
158
|
+
await writeExport(args.exportPath, {
|
|
159
|
+
connector: connectorId,
|
|
160
|
+
operation: args.operation,
|
|
161
|
+
runs: args.runs,
|
|
162
|
+
warmup: args.warmup,
|
|
163
|
+
concurrency: args.concurrency,
|
|
164
|
+
direct: directSuite,
|
|
165
|
+
proxy: proxySuite,
|
|
166
|
+
flamePath,
|
|
167
|
+
});
|
|
168
|
+
console.log(`Exported JSON: ${resolve(process.cwd(), args.exportPath)}`);
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
handleCliError(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
176
|
+
let connectorPath: string | undefined;
|
|
177
|
+
let operation: string | undefined;
|
|
178
|
+
let runs = DEFAULT_RUNS;
|
|
179
|
+
let warmup = DEFAULT_WARMUP;
|
|
180
|
+
let concurrency = DEFAULT_CONCURRENCY;
|
|
181
|
+
let compareProxy = false;
|
|
182
|
+
let exportPath: string | undefined;
|
|
183
|
+
let flame = false;
|
|
184
|
+
|
|
185
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
186
|
+
const arg = argv[index];
|
|
187
|
+
|
|
188
|
+
if (!arg) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!arg.startsWith("-")) {
|
|
193
|
+
if (connectorPath) {
|
|
194
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
connectorPath = arg;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (arg === "--compare-proxy") {
|
|
202
|
+
compareProxy = true;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (arg === "--flame") {
|
|
207
|
+
flame = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (arg === "--operation" || arg === "-o") {
|
|
212
|
+
operation = requireArgValue(argv, index, arg);
|
|
213
|
+
index += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (arg.startsWith("--operation=")) {
|
|
218
|
+
operation = arg.slice("--operation=".length);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (arg.startsWith("-o=")) {
|
|
223
|
+
operation = arg.slice("-o=".length);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (arg === "--runs" || arg === "-n") {
|
|
228
|
+
runs = parsePositiveInteger(requireArgValue(argv, index, arg), arg);
|
|
229
|
+
index += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (arg.startsWith("--runs=")) {
|
|
234
|
+
runs = parsePositiveInteger(arg.slice("--runs=".length), "--runs");
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (arg.startsWith("-n=")) {
|
|
239
|
+
runs = parsePositiveInteger(arg.slice("-n=".length), "-n");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (arg === "--warmup") {
|
|
244
|
+
warmup = parseNonNegativeInteger(requireArgValue(argv, index, arg), arg);
|
|
245
|
+
index += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (arg.startsWith("--warmup=")) {
|
|
250
|
+
warmup = parseNonNegativeInteger(
|
|
251
|
+
arg.slice("--warmup=".length),
|
|
252
|
+
"--warmup",
|
|
253
|
+
);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (arg === "--concurrency" || arg === "-c") {
|
|
258
|
+
concurrency = parsePositiveInteger(
|
|
259
|
+
requireArgValue(argv, index, arg),
|
|
260
|
+
arg,
|
|
261
|
+
);
|
|
262
|
+
index += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (arg.startsWith("--concurrency=")) {
|
|
267
|
+
concurrency = parsePositiveInteger(
|
|
268
|
+
arg.slice("--concurrency=".length),
|
|
269
|
+
"--concurrency",
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (arg.startsWith("-c=")) {
|
|
275
|
+
concurrency = parsePositiveInteger(arg.slice("-c=".length), "-c");
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (arg === "--export") {
|
|
280
|
+
exportPath = requireArgValue(argv, index, arg);
|
|
281
|
+
index += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (arg.startsWith("--export=")) {
|
|
286
|
+
exportPath = arg.slice("--export=".length);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!connectorPath || !operation) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
[
|
|
296
|
+
"Usage: apifuse perf <connector-path> [options]",
|
|
297
|
+
"",
|
|
298
|
+
"Options:",
|
|
299
|
+
" --operation, -o <name> operation to profile (required)",
|
|
300
|
+
" --runs, -n <number> number of runs (default: 10)",
|
|
301
|
+
" --warmup <number> warmup runs (default: 2)",
|
|
302
|
+
" --concurrency, -c <n> concurrent requests (default: 1)",
|
|
303
|
+
" --compare-proxy run with proxy on/off and compare",
|
|
304
|
+
" --export <path> export results to JSON file",
|
|
305
|
+
" --flame generate flamegraph SVG",
|
|
306
|
+
].join("\n"),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
compareProxy,
|
|
312
|
+
concurrency,
|
|
313
|
+
connectorPath,
|
|
314
|
+
exportPath,
|
|
315
|
+
flame,
|
|
316
|
+
operation,
|
|
317
|
+
runs,
|
|
318
|
+
warmup,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function requireArgValue(
|
|
323
|
+
argv: string[],
|
|
324
|
+
index: number,
|
|
325
|
+
option: string,
|
|
326
|
+
): string {
|
|
327
|
+
const value = argv[index + 1];
|
|
328
|
+
if (!value) {
|
|
329
|
+
throw new Error(`Missing value for ${option}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function parsePositiveInteger(value: string, option: string): number {
|
|
336
|
+
const parsed = Number.parseInt(value, 10);
|
|
337
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
338
|
+
throw new Error(`Invalid value for ${option}: ${value}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return parsed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseNonNegativeInteger(value: string, option: string): number {
|
|
345
|
+
const parsed = Number.parseInt(value, 10);
|
|
346
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
347
|
+
throw new Error(`Invalid value for ${option}: ${value}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return parsed;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function resolveConnectorEntry(connectorDirectory: string): string {
|
|
354
|
+
for (const candidate of ["index.ts", "index.js"]) {
|
|
355
|
+
const entryPath = resolve(connectorDirectory, candidate);
|
|
356
|
+
if (existsSync(entryPath)) {
|
|
357
|
+
return entryPath;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
throw new Error(`Connector entry not found in ${connectorDirectory}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function loadConnector(
|
|
365
|
+
connectorEntry: string,
|
|
366
|
+
): Promise<ConnectorDefinition> {
|
|
367
|
+
const mod = (await import(pathToFileURL(connectorEntry).href)) as {
|
|
368
|
+
default?: ConnectorDefinition;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (!mod.default) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Connector module has no default export: ${connectorEntry}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return mod.default;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function getOperation(connector: ConnectorDefinition, operationName: string) {
|
|
381
|
+
const operation = connector.operations[operationName];
|
|
382
|
+
if (!operation) {
|
|
383
|
+
throw new ConnectorError(`Unknown operation: ${operationName}`, {
|
|
384
|
+
code: "OPERATION_NOT_FOUND",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return operation;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function getOperationSchema(
|
|
392
|
+
_connector: ConnectorDefinition,
|
|
393
|
+
operation: unknown,
|
|
394
|
+
kind: "input" | "output",
|
|
395
|
+
) {
|
|
396
|
+
if (operation && typeof operation === "object" && kind in operation) {
|
|
397
|
+
const operationSchema = Reflect.get(operation, kind);
|
|
398
|
+
if (isSchema(operationSchema)) {
|
|
399
|
+
return operationSchema;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
throw new ConnectorError(`Operation missing ${kind} schema`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isSchema(value: unknown): value is { parse(input: unknown): unknown } {
|
|
407
|
+
return value !== null && typeof value === "object" && "parse" in value;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function resolveInputTemplate(
|
|
411
|
+
connector: ConnectorDefinition,
|
|
412
|
+
inputSchema: { parse(input: unknown): unknown },
|
|
413
|
+
): unknown {
|
|
414
|
+
const firstOp = Object.values(connector.operations)[0];
|
|
415
|
+
if (firstOp?.fixtures?.request !== undefined) {
|
|
416
|
+
return firstOp.fixtures.request;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
return inputSchema.parse({});
|
|
421
|
+
} catch {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"No fixture request found. Add fixtures.request to the operation or make the input schema parse an empty object.",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function loadFixtureReplay(
|
|
429
|
+
connectorDirectory: string,
|
|
430
|
+
): Promise<FixtureReplay | null> {
|
|
431
|
+
const fixturePath = resolve(connectorDirectory, "__fixtures__", "raw.json");
|
|
432
|
+
if (!existsSync(fixturePath)) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const rawText = await readFile(fixturePath, "utf8");
|
|
437
|
+
return {
|
|
438
|
+
raw: JSON.parse(rawText),
|
|
439
|
+
rawText,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function assertProxyConfigured(config: ApiFuseConfig): void {
|
|
444
|
+
if (config.proxy?.url || process.env.APIFUSE_PROXY_URL) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
throw new Error(
|
|
449
|
+
"--compare-proxy requires a proxy URL in apifuse.config.ts or APIFUSE_PROXY_URL.",
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function runProfileSuite(options: {
|
|
454
|
+
args: CliArgs;
|
|
455
|
+
config: ApiFuseConfig;
|
|
456
|
+
connector: ConnectorDefinition;
|
|
457
|
+
connectorId: string;
|
|
458
|
+
fixtureReplay: FixtureReplay | null;
|
|
459
|
+
inputSchema: { parse(input: unknown): unknown };
|
|
460
|
+
inputTemplate: unknown;
|
|
461
|
+
operationName: string;
|
|
462
|
+
outputSchema: { parse(input: unknown): unknown };
|
|
463
|
+
proxyEnabled: boolean;
|
|
464
|
+
}): Promise<ProfileSuite> {
|
|
465
|
+
for (let index = 0; index < options.args.warmup; index += 1) {
|
|
466
|
+
await profileRun({
|
|
467
|
+
config: options.config,
|
|
468
|
+
connector: options.connector,
|
|
469
|
+
connectorId: options.connectorId,
|
|
470
|
+
fixtureReplay: options.fixtureReplay,
|
|
471
|
+
inputSchema: options.inputSchema,
|
|
472
|
+
inputTemplate: options.inputTemplate,
|
|
473
|
+
operationName: options.operationName,
|
|
474
|
+
outputSchema: options.outputSchema,
|
|
475
|
+
proxyEnabled: options.proxyEnabled,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const runs: RunResult[] = [];
|
|
480
|
+
for (
|
|
481
|
+
let index = 0;
|
|
482
|
+
index < options.args.runs;
|
|
483
|
+
index += options.args.concurrency
|
|
484
|
+
) {
|
|
485
|
+
const batchSize = Math.min(
|
|
486
|
+
options.args.concurrency,
|
|
487
|
+
options.args.runs - index,
|
|
488
|
+
);
|
|
489
|
+
const batch = Array.from({ length: batchSize }, () =>
|
|
490
|
+
profileRun({
|
|
491
|
+
config: options.config,
|
|
492
|
+
connector: options.connector,
|
|
493
|
+
connectorId: options.connectorId,
|
|
494
|
+
fixtureReplay: options.fixtureReplay,
|
|
495
|
+
inputSchema: options.inputSchema,
|
|
496
|
+
inputTemplate: options.inputTemplate,
|
|
497
|
+
operationName: options.operationName,
|
|
498
|
+
outputSchema: options.outputSchema,
|
|
499
|
+
proxyEnabled: options.proxyEnabled,
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
runs.push(...(await Promise.all(batch)));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const stats = computeStats(runs.map((run) => run.durationMs));
|
|
506
|
+
const groupedSpans = groupSpansByName(runs.map((run) => run.spans));
|
|
507
|
+
const rootSpanName = `${options.connectorId}/${options.operationName}`;
|
|
508
|
+
const breakdown = [...groupedSpans.entries()]
|
|
509
|
+
.filter(([name]) => name !== rootSpanName)
|
|
510
|
+
.map(([name, durations]) => {
|
|
511
|
+
const avgMs =
|
|
512
|
+
durations.reduce((sum, value) => sum + value, 0) / durations.length;
|
|
513
|
+
const percent = stats.avg > 0 ? (avgMs / stats.avg) * 100 : 0;
|
|
514
|
+
return { avgMs, name, percent };
|
|
515
|
+
})
|
|
516
|
+
.sort((left, right) => right.avgMs - left.avgMs);
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
breakdown,
|
|
520
|
+
insights: buildInsights(runs, stats, breakdown),
|
|
521
|
+
label: options.proxyEnabled ? "proxy: on" : "proxy: off",
|
|
522
|
+
runs,
|
|
523
|
+
stats,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function profileRun(options: {
|
|
528
|
+
config: ApiFuseConfig;
|
|
529
|
+
connector: ConnectorDefinition;
|
|
530
|
+
connectorId: string;
|
|
531
|
+
fixtureReplay: FixtureReplay | null;
|
|
532
|
+
inputSchema: { parse(input: unknown): unknown };
|
|
533
|
+
inputTemplate: unknown;
|
|
534
|
+
operationName: string;
|
|
535
|
+
outputSchema: { parse(input: unknown): unknown };
|
|
536
|
+
proxyEnabled: boolean;
|
|
537
|
+
}): Promise<RunResult> {
|
|
538
|
+
try {
|
|
539
|
+
return await executeProfileRun({ ...options, forceFixtureReplay: false });
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (!options.fixtureReplay || !looksLikeNetworkFailure(error)) {
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return executeProfileRun({ ...options, forceFixtureReplay: true });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function looksLikeNetworkFailure(error: unknown): boolean {
|
|
550
|
+
if (!(error instanceof Error)) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return /network|http|tls|fetch|timeout|transport/i.test(error.message);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function executeProfileRun(options: {
|
|
558
|
+
config: ApiFuseConfig;
|
|
559
|
+
connector: ConnectorDefinition;
|
|
560
|
+
connectorId: string;
|
|
561
|
+
fixtureReplay: FixtureReplay | null;
|
|
562
|
+
forceFixtureReplay: boolean;
|
|
563
|
+
inputSchema: { parse(input: unknown): unknown };
|
|
564
|
+
inputTemplate: unknown;
|
|
565
|
+
operationName: string;
|
|
566
|
+
outputSchema: { parse(input: unknown): unknown };
|
|
567
|
+
proxyEnabled: boolean;
|
|
568
|
+
}): Promise<RunResult> {
|
|
569
|
+
const _operation = getOperation(options.connector, options.operationName);
|
|
570
|
+
const traceContext = createTraceContext(
|
|
571
|
+
resolveTraceContextOptions(options.config.trace),
|
|
572
|
+
);
|
|
573
|
+
const session = createSessionStore({
|
|
574
|
+
backend: options.config.session?.storage,
|
|
575
|
+
databasePath: options.config.session?.path,
|
|
576
|
+
} as Parameters<typeof createSessionStore>[0]);
|
|
577
|
+
const authManager = createAuthManager(options.connector.auth, session);
|
|
578
|
+
const baseContext = createBaseContext({
|
|
579
|
+
authManager,
|
|
580
|
+
config: options.config,
|
|
581
|
+
connector: options.connector,
|
|
582
|
+
fixtureReplay: options.fixtureReplay,
|
|
583
|
+
forceFixtureReplay: options.forceFixtureReplay,
|
|
584
|
+
proxyEnabled: options.proxyEnabled,
|
|
585
|
+
session,
|
|
586
|
+
traceContext,
|
|
587
|
+
});
|
|
588
|
+
const ctx = wrapWithInstrumentation(baseContext);
|
|
589
|
+
const input = cloneValue(options.inputTemplate);
|
|
590
|
+
const rootSpanName = `${options.connectorId}/${options.operationName}`;
|
|
591
|
+
|
|
592
|
+
const startedAt = performance.now();
|
|
593
|
+
const output = await ctx.trace.span(rootSpanName, async () => {
|
|
594
|
+
const normalizedInput = await ctx.trace.span("normalizeRequest", async () =>
|
|
595
|
+
options.inputSchema.parse(input),
|
|
596
|
+
);
|
|
597
|
+
const result = options.connector.auth
|
|
598
|
+
? await authManager.wrapWithAutoRefresh(ctx, () =>
|
|
599
|
+
executeOperation(
|
|
600
|
+
options.connector,
|
|
601
|
+
options.operationName,
|
|
602
|
+
ctx,
|
|
603
|
+
normalizedInput,
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
: await executeOperation(
|
|
607
|
+
options.connector,
|
|
608
|
+
options.operationName,
|
|
609
|
+
ctx,
|
|
610
|
+
normalizedInput,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
return ctx.trace.span("transformResponse", async () =>
|
|
614
|
+
options.outputSchema.parse(result),
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
const durationMs = performance.now() - startedAt;
|
|
618
|
+
const spans = ctx.trace.getSpans();
|
|
619
|
+
const waterfall = renderWaterfall(spans, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
path: `/v1/${options.connectorId}/${options.operationName}`,
|
|
622
|
+
status: 200,
|
|
623
|
+
totalMs: Math.max(0, Math.round(durationMs)),
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
durationMs,
|
|
628
|
+
mode: options.forceFixtureReplay ? "fixture" : "live",
|
|
629
|
+
output,
|
|
630
|
+
proxyEnabled: options.proxyEnabled,
|
|
631
|
+
spans,
|
|
632
|
+
status: "ok",
|
|
633
|
+
waterfall,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function createBaseContext(options: {
|
|
638
|
+
authManager: ReturnType<typeof createAuthManager>;
|
|
639
|
+
config: ApiFuseConfig;
|
|
640
|
+
connector: ConnectorDefinition;
|
|
641
|
+
fixtureReplay: FixtureReplay | null;
|
|
642
|
+
forceFixtureReplay: boolean;
|
|
643
|
+
proxyEnabled: boolean;
|
|
644
|
+
session: ReturnType<typeof createSessionStore>;
|
|
645
|
+
traceContext: ReturnType<typeof createTraceContext>;
|
|
646
|
+
}): ConnectorContext {
|
|
647
|
+
const upstream = {
|
|
648
|
+
...{ proxy: options.connector.proxy },
|
|
649
|
+
proxy: options.proxyEnabled,
|
|
650
|
+
};
|
|
651
|
+
const apifuseConfig = options.proxyEnabled ? options.config : {};
|
|
652
|
+
const http =
|
|
653
|
+
options.forceFixtureReplay && options.fixtureReplay
|
|
654
|
+
? createFixtureHttpClient(options.fixtureReplay.raw)
|
|
655
|
+
: createHttpClient(getConnectorBaseUrl(options.connector), {
|
|
656
|
+
apifuseConfig,
|
|
657
|
+
upstream,
|
|
658
|
+
});
|
|
659
|
+
const tls =
|
|
660
|
+
options.forceFixtureReplay && options.fixtureReplay
|
|
661
|
+
? createFixtureTlsClient(options.fixtureReplay.rawText)
|
|
662
|
+
: createTlsClient(getConnectorBaseUrl(options.connector), {
|
|
663
|
+
apifuseConfig,
|
|
664
|
+
upstream,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
http,
|
|
669
|
+
tls,
|
|
670
|
+
browser: createBrowserStub(),
|
|
671
|
+
session: options.session,
|
|
672
|
+
state: createStateContext(),
|
|
673
|
+
trace: options.traceContext,
|
|
674
|
+
auth: options.authManager.createAuthContext(),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function createFixtureHttpClient(raw: unknown): HttpClient {
|
|
679
|
+
return {
|
|
680
|
+
request: async () => createFixtureResponse(raw),
|
|
681
|
+
get: async () => createFixtureResponse(raw),
|
|
682
|
+
post: async () => createFixtureResponse(raw),
|
|
683
|
+
put: async () => createFixtureResponse(raw),
|
|
684
|
+
delete: async () => createFixtureResponse(raw),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function createFixtureResponse(raw: unknown) {
|
|
689
|
+
return {
|
|
690
|
+
data: cloneValue(raw),
|
|
691
|
+
meta: {
|
|
692
|
+
requestId: crypto.randomUUID(),
|
|
693
|
+
duration: 0,
|
|
694
|
+
cached: true,
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function createFixtureTlsClient(rawText: string): TlsClient {
|
|
700
|
+
const createResponse = async (): Promise<TlsResponse> => ({
|
|
701
|
+
status: 200,
|
|
702
|
+
ok: true,
|
|
703
|
+
headers: { "content-type": "application/json" },
|
|
704
|
+
rawHeaders: [["content-type", "application/json"]],
|
|
705
|
+
body: rawText,
|
|
706
|
+
cookies: { get: () => undefined, getAll: () => ({}), toString: () => "" },
|
|
707
|
+
json: async <T>() => JSON.parse(rawText) as T,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
fetch: async () => createResponse(),
|
|
712
|
+
createSession() {
|
|
713
|
+
return {
|
|
714
|
+
fetch: async () => createResponse(),
|
|
715
|
+
close() {},
|
|
716
|
+
};
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function createBrowserStub(): BrowserClient {
|
|
722
|
+
return {
|
|
723
|
+
engine: "playwright-stealth",
|
|
724
|
+
async newPage() {
|
|
725
|
+
throw new ConnectorError(
|
|
726
|
+
"Browser runtime is not supported by apifuse perf yet.",
|
|
727
|
+
{
|
|
728
|
+
code: "BROWSER_RUNTIME_UNSUPPORTED",
|
|
729
|
+
},
|
|
730
|
+
);
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function buildInsights(
|
|
736
|
+
runs: RunResult[],
|
|
737
|
+
stats: PerfStats,
|
|
738
|
+
breakdown: Array<{ avgMs: number; name: string; percent: number }>,
|
|
739
|
+
): string[] {
|
|
740
|
+
const insights: string[] = [];
|
|
741
|
+
const allSpans = runs.flatMap((run) => run.spans);
|
|
742
|
+
const tlsSpans = allSpans.filter((span) => span.name === "tls.fetch");
|
|
743
|
+
const dnsSpans = allSpans.filter((span) => span.name === "dns");
|
|
744
|
+
const transform = breakdown.find(
|
|
745
|
+
(entry) => entry.name === "transformResponse",
|
|
746
|
+
);
|
|
747
|
+
const responseSizes = allSpans
|
|
748
|
+
.map((span) => span.attributes.response_size)
|
|
749
|
+
.filter((value): value is number => typeof value === "number");
|
|
750
|
+
const reuseFlags = tlsSpans
|
|
751
|
+
.map((span) => span.attributes.connection_reused)
|
|
752
|
+
.filter((value): value is boolean => typeof value === "boolean");
|
|
753
|
+
|
|
754
|
+
if (reuseFlags.length > 0) {
|
|
755
|
+
const reusePercent = Math.round(
|
|
756
|
+
(reuseFlags.filter(Boolean).length / reuseFlags.length) * 100,
|
|
757
|
+
);
|
|
758
|
+
insights.push(
|
|
759
|
+
reusePercent >= 80
|
|
760
|
+
? `✓ TLS connection reuse: ${reusePercent}% (good)`
|
|
761
|
+
: `⚠ TLS connection reuse: ${reusePercent}% — consider session reuse`,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (transform) {
|
|
766
|
+
insights.push(
|
|
767
|
+
transform.percent < 2
|
|
768
|
+
? `✓ Transform overhead: <2% (good)`
|
|
769
|
+
: `⚠ Transform overhead: ${formatPercent(transform.percent)} — review response shaping`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (dnsSpans.length > 0) {
|
|
774
|
+
const dnsAvg =
|
|
775
|
+
dnsSpans.reduce((sum, span) => sum + span.duration_ms, 0) /
|
|
776
|
+
dnsSpans.length;
|
|
777
|
+
if (dnsAvg >= 5) {
|
|
778
|
+
insights.push(
|
|
779
|
+
`⚠ DNS resolution: ${formatDuration(dnsAvg)} avg — consider DNS caching`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (responseSizes.length > 0) {
|
|
785
|
+
const avgSize =
|
|
786
|
+
responseSizes.reduce((sum, value) => sum + value, 0) /
|
|
787
|
+
responseSizes.length;
|
|
788
|
+
if (avgSize >= 4096) {
|
|
789
|
+
insights.push(
|
|
790
|
+
`⚠ Response size: ${formatBytes(avgSize)} avg — check gzip (Accept-Encoding)`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (insights.length === 0) {
|
|
796
|
+
insights.push(
|
|
797
|
+
stats.p95 > stats.p50 * 1.5
|
|
798
|
+
? "⚠ Tail latency spread is high — inspect slow outlier runs"
|
|
799
|
+
: "✓ No obvious bottlenecks detected",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return insights;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function renderReport(options: {
|
|
807
|
+
connectorId: string;
|
|
808
|
+
operationName: string;
|
|
809
|
+
proxySuite?: ProfileSuite;
|
|
810
|
+
runs: number;
|
|
811
|
+
suite: ProfileSuite;
|
|
812
|
+
}): string {
|
|
813
|
+
const lines = [
|
|
814
|
+
`┌ Performance Report: ${options.connectorId}/${options.operationName} (${options.runs} runs)`,
|
|
815
|
+
"│",
|
|
816
|
+
"│ Total",
|
|
817
|
+
`│ p50: ${formatDuration(options.suite.stats.p50)} p95: ${formatDuration(options.suite.stats.p95)} p99: ${formatDuration(options.suite.stats.p99)} avg: ${formatDuration(options.suite.stats.avg)}`,
|
|
818
|
+
"│",
|
|
819
|
+
"│ Breakdown (avg)",
|
|
820
|
+
];
|
|
821
|
+
|
|
822
|
+
for (const row of options.suite.breakdown) {
|
|
823
|
+
lines.push(
|
|
824
|
+
`│ ${row.name.padEnd(22)} ${formatDuration(row.avgMs).padStart(7)} ${renderBar(row.percent)} ${formatPercent(row.percent).padStart(3)}`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
lines.push("│");
|
|
829
|
+
lines.push("│ Insights:");
|
|
830
|
+
for (const insight of options.suite.insights) {
|
|
831
|
+
lines.push(`│ ${insight}`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (options.proxySuite) {
|
|
835
|
+
const delta = options.proxySuite.stats.p50 - options.suite.stats.p50;
|
|
836
|
+
const deltaPercent =
|
|
837
|
+
options.suite.stats.p50 > 0 ? (delta / options.suite.stats.p50) * 100 : 0;
|
|
838
|
+
lines.push("│");
|
|
839
|
+
lines.push("│ Proxy Comparison (--compare-proxy):");
|
|
840
|
+
lines.push(
|
|
841
|
+
`│ proxy: off → p50: ${formatDuration(options.suite.stats.p50)}`,
|
|
842
|
+
);
|
|
843
|
+
lines.push(
|
|
844
|
+
`│ proxy: on → p50: ${formatDuration(options.proxySuite.stats.p50)} (${delta >= 0 ? "+" : ""}${Math.round(deltaPercent)}%)`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
lines.push("└──────────────────────────────────────────────────────");
|
|
849
|
+
return lines.join("\n");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function renderBar(percent: number): string {
|
|
853
|
+
if (percent < 10) {
|
|
854
|
+
return `░${" ".repeat(BAR_WIDTH - 1)}`;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const blocks = Math.max(1, Math.min(BAR_WIDTH, Math.round(percent / 5)));
|
|
858
|
+
return `${"█".repeat(blocks)}${" ".repeat(BAR_WIDTH - blocks)}`;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function formatDuration(ms: number): string {
|
|
862
|
+
if (ms >= 1000) {
|
|
863
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (ms >= 10) {
|
|
867
|
+
return `${Math.round(ms)}ms`;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return `${ms.toFixed(1)}ms`;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function formatPercent(value: number): string {
|
|
874
|
+
return `${Math.round(value)}%`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function formatBytes(value: number): string {
|
|
878
|
+
if (value >= 1024 * 1024) {
|
|
879
|
+
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (value >= 1024) {
|
|
883
|
+
return `${(value / 1024).toFixed(1)}KB`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return `${Math.round(value)}B`;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function selectRepresentativeRun(runs: RunResult[]): RunResult {
|
|
890
|
+
const sorted = [...runs].sort(
|
|
891
|
+
(left, right) => left.durationMs - right.durationMs,
|
|
892
|
+
);
|
|
893
|
+
const middle = sorted[Math.floor(sorted.length / 2)];
|
|
894
|
+
if (middle) {
|
|
895
|
+
return middle;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const first = runs[0];
|
|
899
|
+
if (!first) {
|
|
900
|
+
throw new Error("Expected at least one run result");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return first;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function writeExport(filePath: string, payload: unknown): Promise<void> {
|
|
907
|
+
const absolutePath = resolve(process.cwd(), filePath);
|
|
908
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
909
|
+
await writeFile(absolutePath, JSON.stringify(payload, null, 2));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function writeFlamegraph(options: {
|
|
913
|
+
connectorId: string;
|
|
914
|
+
operationName: string;
|
|
915
|
+
outputPath?: string;
|
|
916
|
+
representativeRun: RunResult;
|
|
917
|
+
fallbackDirectory: string;
|
|
918
|
+
label: string;
|
|
919
|
+
proxyEnabled: boolean;
|
|
920
|
+
stats: PerfStats;
|
|
921
|
+
mode: "fixture" | "live";
|
|
922
|
+
}): Promise<string> {
|
|
923
|
+
const svg = buildFlamegraphSvg(options.representativeRun.spans, {
|
|
924
|
+
label: options.label,
|
|
925
|
+
mode: options.mode,
|
|
926
|
+
proxyEnabled: options.proxyEnabled,
|
|
927
|
+
stats: options.stats,
|
|
928
|
+
});
|
|
929
|
+
const absolutePath = resolve(
|
|
930
|
+
options.outputPath
|
|
931
|
+
? `${stripExtension(resolve(process.cwd(), options.outputPath))}.flame.svg`
|
|
932
|
+
: resolve(
|
|
933
|
+
options.fallbackDirectory,
|
|
934
|
+
`${sanitizeFileName(options.connectorId)}-${sanitizeFileName(options.operationName)}.flame.svg`,
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
938
|
+
await writeFile(absolutePath, svg);
|
|
939
|
+
return absolutePath;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function stripExtension(filePath: string): string {
|
|
943
|
+
const extension = extname(filePath);
|
|
944
|
+
return extension ? filePath.slice(0, -extension.length) : filePath;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function sanitizeFileName(value: string): string {
|
|
948
|
+
return value.replace(/[^a-z0-9-_]+/gi, "-").replace(/-+/g, "-");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function buildFlamegraphSvg(
|
|
952
|
+
spans: Span[],
|
|
953
|
+
meta: {
|
|
954
|
+
label: string;
|
|
955
|
+
mode: "fixture" | "live";
|
|
956
|
+
proxyEnabled: boolean;
|
|
957
|
+
stats: PerfStats;
|
|
958
|
+
},
|
|
959
|
+
): string {
|
|
960
|
+
if (spans.length === 0) {
|
|
961
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="120"><text x="16" y="32">No spans captured</text></svg>`;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const tree = buildFlameTree(spans);
|
|
965
|
+
const flattened = flattenFlameTree(tree);
|
|
966
|
+
const root = findRootSpan(spans);
|
|
967
|
+
const totalDuration = Math.max(1, root.duration_ms);
|
|
968
|
+
const width = 1200;
|
|
969
|
+
const rowHeight = 26;
|
|
970
|
+
const chartTop = 64;
|
|
971
|
+
const maxDepth = Math.max(...flattened.map((node) => node.depth), 0);
|
|
972
|
+
const height = chartTop + (maxDepth + 1) * rowHeight + 24;
|
|
973
|
+
|
|
974
|
+
const rects = flattened
|
|
975
|
+
.map((node) => {
|
|
976
|
+
const x =
|
|
977
|
+
12 +
|
|
978
|
+
((node.span.startedAt - root.startedAt) / totalDuration) * (width - 24);
|
|
979
|
+
const rectWidth = Math.max(
|
|
980
|
+
1,
|
|
981
|
+
(node.span.duration_ms / totalDuration) * (width - 24),
|
|
982
|
+
);
|
|
983
|
+
const y = chartTop + node.depth * rowHeight;
|
|
984
|
+
const label = escapeXml(
|
|
985
|
+
`${node.span.name} (${formatDuration(node.span.duration_ms)})`,
|
|
986
|
+
);
|
|
987
|
+
const title = escapeXml(
|
|
988
|
+
`${node.span.name}\n${formatDuration(node.span.duration_ms)}\nstatus: ${node.span.status}`,
|
|
989
|
+
);
|
|
990
|
+
return `<g><title>${title}</title><rect x="${x.toFixed(2)}" y="${y}" width="${rectWidth.toFixed(2)}" height="20" rx="3" fill="${spanColor(node.span.name)}" /><text x="${(x + 4).toFixed(2)}" y="${y + 14}" font-size="11" fill="#0f172a">${label}</text></g>`;
|
|
991
|
+
})
|
|
992
|
+
.join("");
|
|
993
|
+
|
|
994
|
+
return [
|
|
995
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
|
|
996
|
+
`<rect width="100%" height="100%" fill="#ffffff" />`,
|
|
997
|
+
`<text x="16" y="24" font-size="18" font-family="ui-sans-serif, system-ui" fill="#0f172a">${escapeXml(meta.label)} flamegraph</text>`,
|
|
998
|
+
`<text x="16" y="44" font-size="12" font-family="ui-sans-serif, system-ui" fill="#475569">mode: ${meta.mode} · proxy: ${meta.proxyEnabled ? "on" : "off"} · p50: ${formatDuration(meta.stats.p50)} · p95: ${formatDuration(meta.stats.p95)}</text>`,
|
|
999
|
+
rects,
|
|
1000
|
+
`</svg>`,
|
|
1001
|
+
].join("");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function buildFlameTree(spans: Span[]): FlameNode[] {
|
|
1005
|
+
const sorted = [...spans].sort(
|
|
1006
|
+
(left, right) => left.startedAt - right.startedAt,
|
|
1007
|
+
);
|
|
1008
|
+
const nodeMap = new Map<string, FlameNode>();
|
|
1009
|
+
|
|
1010
|
+
for (const span of sorted) {
|
|
1011
|
+
nodeMap.set(span.id, { children: [], depth: 0, span });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const roots: FlameNode[] = [];
|
|
1015
|
+
for (const span of sorted) {
|
|
1016
|
+
const node = nodeMap.get(span.id);
|
|
1017
|
+
if (!node) {
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (span.parentId) {
|
|
1021
|
+
const parent = nodeMap.get(span.parentId);
|
|
1022
|
+
if (parent) {
|
|
1023
|
+
node.depth = parent.depth + 1;
|
|
1024
|
+
parent.children.push(node);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
roots.push(node);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return roots;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function flattenFlameTree(nodes: FlameNode[]): FlameNode[] {
|
|
1036
|
+
const output: FlameNode[] = [];
|
|
1037
|
+
for (const node of nodes) {
|
|
1038
|
+
output.push(node);
|
|
1039
|
+
output.push(...flattenFlameTree(node.children));
|
|
1040
|
+
}
|
|
1041
|
+
return output;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function findRootSpan(spans: Span[]): Span {
|
|
1045
|
+
const root = spans.find((span) => !span.parentId);
|
|
1046
|
+
if (root) {
|
|
1047
|
+
return root;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const sorted = [...spans].sort(
|
|
1051
|
+
(left, right) => left.startedAt - right.startedAt,
|
|
1052
|
+
);
|
|
1053
|
+
const first = sorted[0];
|
|
1054
|
+
if (!first) {
|
|
1055
|
+
throw new Error("Expected at least one span");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return first;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function spanColor(name: string): string {
|
|
1062
|
+
if (name.startsWith("tls.")) {
|
|
1063
|
+
return "#60a5fa";
|
|
1064
|
+
}
|
|
1065
|
+
if (name.startsWith("browser.")) {
|
|
1066
|
+
return "#4ade80";
|
|
1067
|
+
}
|
|
1068
|
+
if (name.startsWith("http.")) {
|
|
1069
|
+
return "#f59e0b";
|
|
1070
|
+
}
|
|
1071
|
+
if (name === "normalizeRequest") {
|
|
1072
|
+
return "#a78bfa";
|
|
1073
|
+
}
|
|
1074
|
+
if (name === "transformResponse") {
|
|
1075
|
+
return "#f472b6";
|
|
1076
|
+
}
|
|
1077
|
+
return "#cbd5e1";
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function escapeXml(value: string): string {
|
|
1081
|
+
return value
|
|
1082
|
+
.replaceAll("&", "&")
|
|
1083
|
+
.replaceAll("<", "<")
|
|
1084
|
+
.replaceAll(">", ">")
|
|
1085
|
+
.replaceAll('"', """)
|
|
1086
|
+
.replaceAll("'", "'");
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function cloneValue<T>(value: T): T {
|
|
1090
|
+
return structuredClone(value);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function handleCliError(error: unknown): never {
|
|
1094
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1095
|
+
console.error(message);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (import.meta.main) {
|
|
1100
|
+
await main();
|
|
1101
|
+
}
|