@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,424 @@
|
|
|
1
|
+
import type { ConnectorContext } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
type CreateTraceContextOptions,
|
|
4
|
+
createTraceContext,
|
|
5
|
+
getTraceRecorder,
|
|
6
|
+
type TraceContext,
|
|
7
|
+
} from "./trace";
|
|
8
|
+
|
|
9
|
+
export interface InstrumentationOptions extends CreateTraceContextOptions {}
|
|
10
|
+
|
|
11
|
+
export type InstrumentedConnectorContext<T extends ConnectorContext> = Omit<
|
|
12
|
+
T,
|
|
13
|
+
"trace"
|
|
14
|
+
> & {
|
|
15
|
+
trace: TraceContext;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type InstrumentedNamespace = "http" | "tls" | "browser" | "session" | "state";
|
|
19
|
+
|
|
20
|
+
const BROWSER_PAGE_METHODS = new Set([
|
|
21
|
+
"goto",
|
|
22
|
+
"fill",
|
|
23
|
+
"click",
|
|
24
|
+
"type",
|
|
25
|
+
"waitForSelector",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function getErrorStatus(error: unknown): number | undefined {
|
|
29
|
+
if (
|
|
30
|
+
typeof error === "object" &&
|
|
31
|
+
error !== null &&
|
|
32
|
+
"status" in error &&
|
|
33
|
+
typeof error.status === "number"
|
|
34
|
+
) {
|
|
35
|
+
return error.status;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getResponseDuration(result: unknown): number | undefined {
|
|
42
|
+
if (
|
|
43
|
+
typeof result === "object" &&
|
|
44
|
+
result !== null &&
|
|
45
|
+
"meta" in result &&
|
|
46
|
+
typeof result.meta === "object" &&
|
|
47
|
+
result.meta !== null &&
|
|
48
|
+
"duration" in result.meta &&
|
|
49
|
+
typeof result.meta.duration === "number"
|
|
50
|
+
) {
|
|
51
|
+
return result.meta.duration;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getResponseStatus(
|
|
58
|
+
namespace: InstrumentedNamespace,
|
|
59
|
+
result: unknown,
|
|
60
|
+
): number | undefined {
|
|
61
|
+
if (
|
|
62
|
+
typeof result === "object" &&
|
|
63
|
+
result !== null &&
|
|
64
|
+
"status" in result &&
|
|
65
|
+
typeof result.status === "number"
|
|
66
|
+
) {
|
|
67
|
+
return result.status;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (namespace === "http") {
|
|
71
|
+
return 200;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getUrl(
|
|
78
|
+
namespace: InstrumentedNamespace,
|
|
79
|
+
args: unknown[],
|
|
80
|
+
result?: unknown,
|
|
81
|
+
): string | undefined {
|
|
82
|
+
if (typeof args[0] === "string") {
|
|
83
|
+
return args[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
namespace === "browser" &&
|
|
88
|
+
typeof result === "object" &&
|
|
89
|
+
result !== null &&
|
|
90
|
+
"url" in result &&
|
|
91
|
+
typeof result.url === "string"
|
|
92
|
+
) {
|
|
93
|
+
return result.url;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getMethod(
|
|
100
|
+
namespace: InstrumentedNamespace,
|
|
101
|
+
methodName: string,
|
|
102
|
+
args: unknown[],
|
|
103
|
+
): string | undefined {
|
|
104
|
+
if (namespace === "http") {
|
|
105
|
+
return methodName.toUpperCase();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (namespace === "tls") {
|
|
109
|
+
const options =
|
|
110
|
+
typeof args[1] === "object" && args[1] !== null ? args[1] : undefined;
|
|
111
|
+
if (options && "method" in options && typeof options.method === "string") {
|
|
112
|
+
return options.method.toUpperCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return "GET";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildSpanAttributes(
|
|
122
|
+
namespace: InstrumentedNamespace,
|
|
123
|
+
methodName: string,
|
|
124
|
+
args: unknown[],
|
|
125
|
+
result?: unknown,
|
|
126
|
+
error?: unknown,
|
|
127
|
+
): Record<string, string | number | boolean> {
|
|
128
|
+
const attributes: Record<string, string | number | boolean> = {};
|
|
129
|
+
const url = getUrl(namespace, args, result);
|
|
130
|
+
const method = getMethod(namespace, methodName, args);
|
|
131
|
+
const status = error
|
|
132
|
+
? getErrorStatus(error)
|
|
133
|
+
: getResponseStatus(namespace, result);
|
|
134
|
+
const duration = error ? undefined : getResponseDuration(result);
|
|
135
|
+
|
|
136
|
+
if (url) {
|
|
137
|
+
attributes.url = url;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (method) {
|
|
141
|
+
attributes.method = method;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (status !== undefined && (namespace === "http" || namespace === "tls")) {
|
|
145
|
+
attributes.status = status;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (duration !== undefined) {
|
|
149
|
+
attributes.duration_ms = duration;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (namespace === "session" || namespace === "state") {
|
|
153
|
+
attributes.operation = methodName;
|
|
154
|
+
const key = typeof args[0] === "string" ? args[0] : undefined;
|
|
155
|
+
if (key) {
|
|
156
|
+
attributes.key = key;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return attributes;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getBrowserPageAttributes(
|
|
164
|
+
methodName: string,
|
|
165
|
+
args: unknown[],
|
|
166
|
+
elapsedMs?: number,
|
|
167
|
+
error?: unknown,
|
|
168
|
+
): Record<string, string | number | boolean> {
|
|
169
|
+
const attributes: Record<string, string | number | boolean> = {};
|
|
170
|
+
|
|
171
|
+
if (methodName === "goto") {
|
|
172
|
+
const url = typeof args[0] === "string" ? args[0] : undefined;
|
|
173
|
+
if (url) {
|
|
174
|
+
attributes.url = url;
|
|
175
|
+
}
|
|
176
|
+
if (error === undefined) {
|
|
177
|
+
attributes.navigation_ms = elapsedMs ?? 0;
|
|
178
|
+
}
|
|
179
|
+
return attributes;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const selector = typeof args[0] === "string" ? args[0] : undefined;
|
|
183
|
+
if (selector) {
|
|
184
|
+
attributes.selector = selector;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (error === undefined) {
|
|
188
|
+
const key = methodName === "waitForSelector" ? "wait_ms" : "action_ms";
|
|
189
|
+
attributes[key] = elapsedMs ?? 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return attributes;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function wrapPage<T extends object>(page: T, trace: TraceContext): T {
|
|
196
|
+
if (page === null || page === undefined) {
|
|
197
|
+
return page;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const recorder = getTraceRecorder(trace);
|
|
201
|
+
if (!recorder) {
|
|
202
|
+
return page;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const wrappedMethods = new Map<PropertyKey, unknown>();
|
|
206
|
+
|
|
207
|
+
return new Proxy(page, {
|
|
208
|
+
get(pageTarget, property, receiver) {
|
|
209
|
+
const value = Reflect.get(pageTarget, property, receiver);
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
typeof value !== "function" ||
|
|
213
|
+
property === "constructor" ||
|
|
214
|
+
!BROWSER_PAGE_METHODS.has(String(property))
|
|
215
|
+
) {
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (wrappedMethods.has(property)) {
|
|
220
|
+
return wrappedMethods.get(property);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const methodName = String(property);
|
|
224
|
+
const wrapped = (...args: unknown[]) => {
|
|
225
|
+
let elapsedMs = 0;
|
|
226
|
+
|
|
227
|
+
return recorder.runSpan(
|
|
228
|
+
`browser.page.${methodName}`,
|
|
229
|
+
async () => {
|
|
230
|
+
const startedAt = Date.now();
|
|
231
|
+
const result = await Reflect.apply(value, pageTarget, args);
|
|
232
|
+
elapsedMs = Date.now() - startedAt;
|
|
233
|
+
return result;
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
onSuccess: () =>
|
|
237
|
+
getBrowserPageAttributes(methodName, args, elapsedMs),
|
|
238
|
+
onError: (error) =>
|
|
239
|
+
getBrowserPageAttributes(methodName, args, undefined, error),
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
wrappedMethods.set(property, wrapped);
|
|
245
|
+
return wrapped;
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function wrapNamespace<T extends object>(
|
|
251
|
+
namespace: InstrumentedNamespace,
|
|
252
|
+
target: T,
|
|
253
|
+
trace: TraceContext,
|
|
254
|
+
): T {
|
|
255
|
+
const recorder = getTraceRecorder(trace);
|
|
256
|
+
if (!recorder) {
|
|
257
|
+
return target;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const wrappedMethods = new Map<PropertyKey, unknown>();
|
|
261
|
+
|
|
262
|
+
return new Proxy(target, {
|
|
263
|
+
get(namespaceTarget, property, receiver) {
|
|
264
|
+
const value = Reflect.get(namespaceTarget, property, receiver);
|
|
265
|
+
|
|
266
|
+
if (typeof value !== "function" || property === "constructor") {
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (namespace === "browser" && property === "newPage") {
|
|
271
|
+
if (wrappedMethods.has(property)) {
|
|
272
|
+
return wrappedMethods.get(property);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const wrapped = (...args: unknown[]) => {
|
|
276
|
+
let allocateMs = 0;
|
|
277
|
+
|
|
278
|
+
return recorder.runSpan(
|
|
279
|
+
"browser.newPage",
|
|
280
|
+
async () => {
|
|
281
|
+
const startedAt = Date.now();
|
|
282
|
+
const page = await Reflect.apply(value, namespaceTarget, args);
|
|
283
|
+
allocateMs = Date.now() - startedAt;
|
|
284
|
+
return wrapPage(page, trace);
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
onSuccess: (result) => {
|
|
288
|
+
const attributes: Record<string, string | number | boolean> = {
|
|
289
|
+
allocate_ms: allocateMs,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (
|
|
293
|
+
result &&
|
|
294
|
+
typeof result === "object" &&
|
|
295
|
+
"pageId" in result &&
|
|
296
|
+
typeof (result as { pageId?: unknown }).pageId === "string"
|
|
297
|
+
) {
|
|
298
|
+
attributes.page_id = (result as { pageId: string }).pageId;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (
|
|
302
|
+
namespaceTarget &&
|
|
303
|
+
typeof namespaceTarget === "object" &&
|
|
304
|
+
"engine" in namespaceTarget &&
|
|
305
|
+
typeof (namespaceTarget as { engine?: unknown }).engine ===
|
|
306
|
+
"string"
|
|
307
|
+
) {
|
|
308
|
+
attributes.engine = (
|
|
309
|
+
namespaceTarget as { engine: string }
|
|
310
|
+
).engine;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return attributes;
|
|
314
|
+
},
|
|
315
|
+
onError: (error) =>
|
|
316
|
+
getBrowserPageAttributes("newPage", args, undefined, error),
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
wrappedMethods.set(property, wrapped);
|
|
322
|
+
return wrapped;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (wrappedMethods.has(property)) {
|
|
326
|
+
return wrappedMethods.get(property);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const methodName = String(property);
|
|
330
|
+
if (namespace === "browser") {
|
|
331
|
+
const wrapped = (...args: unknown[]) => {
|
|
332
|
+
let elapsedMs = 0;
|
|
333
|
+
|
|
334
|
+
return recorder.runSpan(
|
|
335
|
+
`browser.${methodName}`,
|
|
336
|
+
async () => {
|
|
337
|
+
const startedAt = Date.now();
|
|
338
|
+
const result = await Reflect.apply(value, namespaceTarget, args);
|
|
339
|
+
elapsedMs = Date.now() - startedAt;
|
|
340
|
+
return result;
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
onSuccess: () =>
|
|
344
|
+
getBrowserPageAttributes(methodName, args, elapsedMs),
|
|
345
|
+
onError: (error) =>
|
|
346
|
+
getBrowserPageAttributes(methodName, args, undefined, error),
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
wrappedMethods.set(property, wrapped);
|
|
352
|
+
return wrapped;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const wrapped = (...args: unknown[]) =>
|
|
356
|
+
recorder.runSpan(
|
|
357
|
+
`${namespace}.${methodName}`,
|
|
358
|
+
() => Reflect.apply(value, namespaceTarget, args),
|
|
359
|
+
{
|
|
360
|
+
onSuccess: (result) =>
|
|
361
|
+
buildSpanAttributes(namespace, methodName, args, result),
|
|
362
|
+
onError: (error) =>
|
|
363
|
+
buildSpanAttributes(
|
|
364
|
+
namespace,
|
|
365
|
+
methodName,
|
|
366
|
+
args,
|
|
367
|
+
undefined,
|
|
368
|
+
error,
|
|
369
|
+
),
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
wrappedMethods.set(property, wrapped);
|
|
374
|
+
return wrapped;
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function hasTraceOverrides(options: InstrumentationOptions): boolean {
|
|
380
|
+
return options.maxSpans !== undefined || options.onSpan !== undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function wrapWithInstrumentation<T extends ConnectorContext>(
|
|
384
|
+
ctx: T,
|
|
385
|
+
options: InstrumentationOptions = {},
|
|
386
|
+
): InstrumentedConnectorContext<T> {
|
|
387
|
+
const trace =
|
|
388
|
+
getTraceRecorder(ctx.trace) && !hasTraceOverrides(options)
|
|
389
|
+
? (ctx.trace as TraceContext)
|
|
390
|
+
: createTraceContext(options);
|
|
391
|
+
const wrappedTargets = new Map<InstrumentedNamespace, unknown>();
|
|
392
|
+
|
|
393
|
+
return new Proxy(ctx, {
|
|
394
|
+
get(target, property, receiver) {
|
|
395
|
+
if (property === "trace") {
|
|
396
|
+
return trace;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (
|
|
400
|
+
property === "http" ||
|
|
401
|
+
property === "tls" ||
|
|
402
|
+
property === "browser" ||
|
|
403
|
+
property === "session" ||
|
|
404
|
+
property === "state"
|
|
405
|
+
) {
|
|
406
|
+
const namespace = property;
|
|
407
|
+
if (wrappedTargets.has(namespace)) {
|
|
408
|
+
return wrappedTargets.get(namespace);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const value = Reflect.get(target, property, receiver);
|
|
412
|
+
if (!value || typeof value !== "object") {
|
|
413
|
+
return value;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const wrapped = wrapNamespace(namespace, value, trace);
|
|
417
|
+
wrappedTargets.set(namespace, wrapped);
|
|
418
|
+
return wrapped;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return Reflect.get(target, property, receiver);
|
|
422
|
+
},
|
|
423
|
+
}) as unknown as InstrumentedConnectorContext<T>;
|
|
424
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { TraceSpan } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface OTLPExportOptions {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let nextTraceId = 1n;
|
|
10
|
+
let replayableTraceId: { signature: string; traceId: string } | null = null;
|
|
11
|
+
|
|
12
|
+
function createBatchSignature(
|
|
13
|
+
spans: TraceSpan[],
|
|
14
|
+
resourceAttributes?: Record<string, string>,
|
|
15
|
+
): string {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
resourceAttributes: resourceAttributes ?? null,
|
|
18
|
+
spans,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createTraceId(signature: string): string {
|
|
23
|
+
if (replayableTraceId?.signature === signature) {
|
|
24
|
+
const traceId = replayableTraceId.traceId;
|
|
25
|
+
replayableTraceId = null;
|
|
26
|
+
return traceId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const traceId = nextTraceId.toString(16).padStart(32, "0");
|
|
30
|
+
nextTraceId += 1n;
|
|
31
|
+
replayableTraceId = { signature, traceId };
|
|
32
|
+
return traceId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeHexId(
|
|
36
|
+
value: string | undefined,
|
|
37
|
+
length: number,
|
|
38
|
+
): string | undefined {
|
|
39
|
+
if (!value) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const normalized = value.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
|
|
44
|
+
return normalized.padStart(length, "0").slice(-length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toAttributeValue(
|
|
48
|
+
value: unknown,
|
|
49
|
+
): Record<string, string | number | boolean> {
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
return { stringValue: value };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof value === "number") {
|
|
55
|
+
return { doubleValue: value };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof value === "boolean") {
|
|
59
|
+
return { boolValue: value };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { stringValue: String(value) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function spansToOTLP(
|
|
66
|
+
spans: TraceSpan[],
|
|
67
|
+
resourceAttributes?: Record<string, string>,
|
|
68
|
+
): {
|
|
69
|
+
resourceSpans: Array<{
|
|
70
|
+
resource: {
|
|
71
|
+
attributes: Array<{
|
|
72
|
+
key: string;
|
|
73
|
+
value: Record<string, string>;
|
|
74
|
+
}>;
|
|
75
|
+
};
|
|
76
|
+
scopeSpans: Array<{
|
|
77
|
+
scope: { name: string; version: string };
|
|
78
|
+
spans: Array<{
|
|
79
|
+
attributes: Array<{
|
|
80
|
+
key: string;
|
|
81
|
+
value: Record<string, string | number | boolean>;
|
|
82
|
+
}>;
|
|
83
|
+
endTimeUnixNano: string;
|
|
84
|
+
kind: number;
|
|
85
|
+
name: string;
|
|
86
|
+
parentSpanId?: string;
|
|
87
|
+
spanId: string;
|
|
88
|
+
startTimeUnixNano: string;
|
|
89
|
+
status: { code: number };
|
|
90
|
+
traceId: string;
|
|
91
|
+
}>;
|
|
92
|
+
}>;
|
|
93
|
+
}>;
|
|
94
|
+
} {
|
|
95
|
+
const traceId = createTraceId(
|
|
96
|
+
createBatchSignature(spans, resourceAttributes),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
resourceSpans: [
|
|
101
|
+
{
|
|
102
|
+
resource: {
|
|
103
|
+
attributes: Object.entries(resourceAttributes ?? {}).map(
|
|
104
|
+
([key, value]) => ({
|
|
105
|
+
key,
|
|
106
|
+
value: { stringValue: value },
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
scopeSpans: [
|
|
111
|
+
{
|
|
112
|
+
scope: {
|
|
113
|
+
name: "apifuse-connector-sdk",
|
|
114
|
+
version: "0.1.0",
|
|
115
|
+
},
|
|
116
|
+
spans: spans.map((span) => ({
|
|
117
|
+
traceId,
|
|
118
|
+
spanId: normalizeHexId(span.id, 16) ?? "0000000000000001",
|
|
119
|
+
parentSpanId: normalizeHexId(span.parentId, 16),
|
|
120
|
+
name: span.name,
|
|
121
|
+
kind: 2,
|
|
122
|
+
startTimeUnixNano: String(span.startedAt * 1_000_000),
|
|
123
|
+
endTimeUnixNano: String(span.endedAt * 1_000_000),
|
|
124
|
+
status: { code: span.status === "ok" ? 1 : 2 },
|
|
125
|
+
attributes: Object.entries(span.attributes ?? {}).map(
|
|
126
|
+
([key, value]) => ({
|
|
127
|
+
key,
|
|
128
|
+
value: toAttributeValue(value),
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
})),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function exportSpansOTLP(
|
|
140
|
+
spans: TraceSpan[],
|
|
141
|
+
options: OTLPExportOptions,
|
|
142
|
+
resourceAttributes?: Record<string, string>,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
if (spans.length === 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timer = setTimeout(() => controller.abort(), options.timeout ?? 5_000);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(options.endpoint, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: {
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
...options.headers,
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify(spansToOTLP(spans, resourceAttributes)),
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
throw new Error(`HTTP ${response.status}`);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
console.warn("[apifuse] OTLP export failed:", message);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Span } from "./trace";
|
|
2
|
+
|
|
3
|
+
export type PerfStats = {
|
|
4
|
+
p50: number;
|
|
5
|
+
p95: number;
|
|
6
|
+
p99: number;
|
|
7
|
+
avg: number;
|
|
8
|
+
min: number;
|
|
9
|
+
max: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function computePercentile(sortedValues: number[], p: number): number {
|
|
13
|
+
if (sortedValues.length === 0) {
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (sortedValues.length === 1) {
|
|
18
|
+
return sortedValues[0] ?? 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const percentile = Math.min(100, Math.max(0, p));
|
|
22
|
+
const position = (percentile / 100) * (sortedValues.length - 1);
|
|
23
|
+
const lowerIndex = Math.floor(position);
|
|
24
|
+
const upperIndex = Math.ceil(position);
|
|
25
|
+
const lower = sortedValues[lowerIndex] ?? 0;
|
|
26
|
+
const upper = sortedValues[upperIndex] ?? lower;
|
|
27
|
+
|
|
28
|
+
if (lowerIndex === upperIndex) {
|
|
29
|
+
return lower;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const weight = position - lowerIndex;
|
|
33
|
+
return lower + (upper - lower) * weight;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function computeStats(durations: number[]): PerfStats {
|
|
37
|
+
if (durations.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
p50: 0,
|
|
40
|
+
p95: 0,
|
|
41
|
+
p99: 0,
|
|
42
|
+
avg: 0,
|
|
43
|
+
min: 0,
|
|
44
|
+
max: 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
49
|
+
const total = sorted.reduce((sum, value) => sum + value, 0);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
p50: computePercentile(sorted, 50),
|
|
53
|
+
p95: computePercentile(sorted, 95),
|
|
54
|
+
p99: computePercentile(sorted, 99),
|
|
55
|
+
avg: total / sorted.length,
|
|
56
|
+
min: sorted[0] ?? 0,
|
|
57
|
+
max: sorted[sorted.length - 1] ?? 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function groupSpansByName(allSpans: Span[][]): Map<string, number[]> {
|
|
62
|
+
const grouped = new Map<string, number[]>();
|
|
63
|
+
|
|
64
|
+
for (const spans of allSpans) {
|
|
65
|
+
for (const span of spans) {
|
|
66
|
+
const durations = grouped.get(span.name) ?? [];
|
|
67
|
+
durations.push(span.duration_ms);
|
|
68
|
+
grouped.set(span.name, durations);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return grouped;
|
|
73
|
+
}
|