@apifuse/provider-sdk 2.2.0-beta.55 → 2.2.0-beta.56
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/CHANGELOG.md +4 -0
- package/bin/apifuse-pack-types.ts +121 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/runtime/proxy-telemetry.d.ts +53 -2
- package/dist/runtime/proxy-telemetry.js +106 -65
- package/dist/runtime/request-telemetry.d.ts +70 -0
- package/dist/runtime/request-telemetry.js +228 -0
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/dist/server/serve-implementation.js +58 -20
- package/package.json +1 -1
- package/src/index.ts +13 -0
- package/src/runtime/proxy-telemetry.ts +160 -74
- package/src/runtime/request-telemetry.ts +376 -0
- package/src/server/index.ts +14 -0
- package/src/server/serve-implementation.ts +93 -21
|
@@ -9,8 +9,19 @@ import type {
|
|
|
9
9
|
ProxyVendorName,
|
|
10
10
|
SmartproxyAllocatorBodyClass,
|
|
11
11
|
} from "../config/loader.js";
|
|
12
|
+
import {
|
|
13
|
+
closedEnum,
|
|
14
|
+
RequestTelemetry,
|
|
15
|
+
type ClosedEnum,
|
|
16
|
+
type TelemetryContributor,
|
|
17
|
+
} from "./request-telemetry.js";
|
|
18
|
+
import { createTraceContext } from "./trace.js";
|
|
12
19
|
|
|
13
|
-
export
|
|
20
|
+
export { PROVIDER_TELEMETRY_HEADER } from "./request-telemetry.js";
|
|
21
|
+
|
|
22
|
+
declare const PROXY_HASH: unique symbol;
|
|
23
|
+
/** Bounded hexadecimal host hash used in proxy attempt samples. */
|
|
24
|
+
export type ProxyHash = ClosedEnum<string> & { readonly [PROXY_HASH]: true };
|
|
14
25
|
|
|
15
26
|
export type ProxyTelemetryResolvedPayload = {
|
|
16
27
|
kind: "resolved";
|
|
@@ -94,14 +105,51 @@ export type ProxyTelemetryLogPayload =
|
|
|
94
105
|
| ProxyTelemetryResolvedPayload
|
|
95
106
|
| ProxyTelemetryUnresolvedPayload;
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
/** Concrete gateway-safe projection of the unchanged proxy sibling. */
|
|
109
|
+
export type ProxyTelemetryHeaderPayload = {
|
|
110
|
+
kind: ClosedEnum<"resolved" | "unresolved">;
|
|
111
|
+
provider?: ClosedEnum<ProxyVendorName>;
|
|
112
|
+
userAgentSource?: ClosedEnum<ProxyUserAgentSource>;
|
|
113
|
+
protocol?: ClosedEnum<ProxyProtocol>;
|
|
114
|
+
cacheStatus?: ClosedEnum<ProxyCacheStatus>;
|
|
115
|
+
cacheHit?: boolean;
|
|
116
|
+
resolutionMs?: number;
|
|
117
|
+
allocatorMs?: number;
|
|
118
|
+
allocatorStatus?: number;
|
|
119
|
+
allocatorBodyClass?: ClosedEnum<SmartproxyAllocatorBodyClass>;
|
|
120
|
+
allocatorAttempts?: number;
|
|
121
|
+
lockWaitMs?: number;
|
|
122
|
+
redisReadMs?: number;
|
|
123
|
+
redisWriteMs?: number;
|
|
124
|
+
poolAgeMs?: number;
|
|
125
|
+
poolExpiresInMs?: number;
|
|
126
|
+
attempts?: number;
|
|
127
|
+
refreshes?: number;
|
|
128
|
+
attemptSamples?: {
|
|
129
|
+
n: number;
|
|
130
|
+
a: number;
|
|
131
|
+
i?: number;
|
|
132
|
+
h?: ProxyHash;
|
|
133
|
+
o: ClosedEnum<ProxyAttemptTelemetryEvent["outcome"]>;
|
|
134
|
+
c?: ClosedEnum<string>;
|
|
135
|
+
s?: number;
|
|
136
|
+
d?: number;
|
|
137
|
+
}[];
|
|
138
|
+
vendors?: ClosedEnum<ProxyVendorName>[];
|
|
139
|
+
failovers?: {
|
|
140
|
+
v: ClosedEnum<ProxyVendorName>;
|
|
141
|
+
nx?: ClosedEnum<ProxyVendorName>;
|
|
142
|
+
p: ClosedEnum<ProxyVendorFailoverTelemetryEvent["phase"]>;
|
|
143
|
+
r: ClosedEnum<ProxyVendorFailoverTelemetryEvent["reason"]>;
|
|
144
|
+
a?: number;
|
|
145
|
+
}[];
|
|
100
146
|
};
|
|
101
147
|
|
|
102
|
-
const MAX_HEADER_BYTES = 4_096;
|
|
103
148
|
const MAX_PROXY_ATTEMPT_SAMPLES = 24;
|
|
104
149
|
const MAX_PROXY_FAILOVER_SAMPLES = 12;
|
|
150
|
+
const MAX_RETAINED_PROXY_RESOLUTIONS = 64;
|
|
151
|
+
const PROXY_HEADER_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
|
|
152
|
+
const PROXY_HEADER_HASH = /^[0-9a-f]{1,16}$/;
|
|
105
153
|
|
|
106
154
|
const CACHE_STATUS_SEVERITY: Record<ProxyCacheStatus, number> = {
|
|
107
155
|
disabled: 0,
|
|
@@ -128,19 +176,48 @@ function worseStatus(left: ProxyCacheStatus, right: ProxyCacheStatus): ProxyCach
|
|
|
128
176
|
return CACHE_STATUS_SEVERITY[right] > CACHE_STATUS_SEVERITY[left] ? right : left;
|
|
129
177
|
}
|
|
130
178
|
|
|
131
|
-
function
|
|
132
|
-
|
|
179
|
+
function aggregateResolution(
|
|
180
|
+
acc: ProxyResolutionTelemetryEvent,
|
|
181
|
+
event: ProxyResolutionTelemetryEvent,
|
|
182
|
+
): ProxyResolutionTelemetryEvent {
|
|
183
|
+
return {
|
|
184
|
+
provider: event.provider,
|
|
185
|
+
userAgentSource: event.userAgentSource ?? acc.userAgentSource,
|
|
186
|
+
cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
|
|
187
|
+
cacheHit: acc.cacheHit && event.cacheHit,
|
|
188
|
+
resolutionMs: acc.resolutionMs + event.resolutionMs,
|
|
189
|
+
allocatorMs: sumOptional(acc.allocatorMs, event.allocatorMs),
|
|
190
|
+
allocatorStatus: event.allocatorStatus ?? acc.allocatorStatus,
|
|
191
|
+
allocatorBodyClass: event.allocatorBodyClass ?? acc.allocatorBodyClass,
|
|
192
|
+
allocatorAttempts: sumOptional(acc.allocatorAttempts, event.allocatorAttempts),
|
|
193
|
+
lockWaitMs: sumOptional(acc.lockWaitMs, event.lockWaitMs),
|
|
194
|
+
redisReadMs: sumOptional(acc.redisReadMs, event.redisReadMs),
|
|
195
|
+
redisWriteMs: sumOptional(acc.redisWriteMs, event.redisWriteMs),
|
|
196
|
+
poolAgeMs: maxOptional(acc.poolAgeMs, event.poolAgeMs),
|
|
197
|
+
poolExpiresInMs: maxOptional(acc.poolExpiresInMs, event.poolExpiresInMs),
|
|
198
|
+
attempts: acc.attempts + event.attempts,
|
|
199
|
+
refreshes: sumOptional(acc.refreshes, event.refreshes),
|
|
200
|
+
};
|
|
133
201
|
}
|
|
134
202
|
|
|
135
|
-
export class ProxyTelemetryCollector
|
|
203
|
+
export class ProxyTelemetryCollector
|
|
204
|
+
implements
|
|
205
|
+
ProxyTelemetrySink,
|
|
206
|
+
TelemetryContributor<ProxyTelemetryLogPayload, ProxyTelemetryHeaderPayload>
|
|
207
|
+
{
|
|
208
|
+
readonly key = "proxy" as const;
|
|
136
209
|
#events: ProxyResolutionTelemetryEvent[] = [];
|
|
210
|
+
#resolutionCount = 0;
|
|
211
|
+
#aggregateResolution: ProxyResolutionTelemetryEvent | undefined;
|
|
212
|
+
#lastSuccessfulResolution: ProxyResolutionTelemetryEvent | undefined;
|
|
213
|
+
#vendors: ProxyVendorName[] = [];
|
|
137
214
|
#attempts: ProxyAttemptTelemetryEvent[] = [];
|
|
138
215
|
#failovers: ProxyVendorFailoverTelemetryEvent[] = [];
|
|
139
216
|
#unresolvedVendors: ProxyVendorName[] = [];
|
|
140
217
|
|
|
141
218
|
recordProxyResolution(event: ProxyResolutionTelemetryEvent): void {
|
|
142
219
|
const outcome = event.outcome === "error" ? "error" : "ok";
|
|
143
|
-
|
|
220
|
+
const normalized: ProxyResolutionTelemetryEvent = {
|
|
144
221
|
provider: event.provider,
|
|
145
222
|
outcome,
|
|
146
223
|
...(event.userAgentSource ? { userAgentSource: event.userAgentSource } : {}),
|
|
@@ -174,12 +251,29 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
|
|
|
174
251
|
attempts: Math.max(1, Math.floor(event.attempts || 1)),
|
|
175
252
|
refreshes:
|
|
176
253
|
event.refreshes === undefined ? undefined : Math.max(0, Math.floor(event.refreshes)),
|
|
177
|
-
}
|
|
254
|
+
};
|
|
255
|
+
this.#resolutionCount += 1;
|
|
256
|
+
if (this.#events.length < MAX_RETAINED_PROXY_RESOLUTIONS) this.#events.push(normalized);
|
|
257
|
+
this.#aggregateResolution = this.#aggregateResolution
|
|
258
|
+
? aggregateResolution(this.#aggregateResolution, normalized)
|
|
259
|
+
: normalized;
|
|
260
|
+
if (outcome === "ok") this.#lastSuccessfulResolution = normalized;
|
|
261
|
+
if (!this.#vendors.includes(event.provider)) this.#vendors.push(event.provider);
|
|
178
262
|
if (outcome === "error" && !this.#unresolvedVendors.includes(event.provider)) {
|
|
179
263
|
this.#unresolvedVendors.push(event.provider);
|
|
180
264
|
}
|
|
181
265
|
}
|
|
182
266
|
|
|
267
|
+
/** Number of raw resolution events retained for bounded-history verification. */
|
|
268
|
+
get retainedResolutionEventCount(): number {
|
|
269
|
+
return this.#events.length;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Total resolutions included in the incremental aggregate. */
|
|
273
|
+
get resolutionEventCount(): number {
|
|
274
|
+
return this.#resolutionCount;
|
|
275
|
+
}
|
|
276
|
+
|
|
183
277
|
recordProxyVendorFailover(event: ProxyVendorFailoverTelemetryEvent): void {
|
|
184
278
|
if (this.#failovers.length >= MAX_PROXY_FAILOVER_SAMPLES) return;
|
|
185
279
|
this.#failovers.push({
|
|
@@ -213,8 +307,7 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
|
|
|
213
307
|
}
|
|
214
308
|
|
|
215
309
|
toLogPayload(): ProxyTelemetryLogPayload | undefined {
|
|
216
|
-
const
|
|
217
|
-
const okEvents = this.#events.filter((event) => event.outcome !== "error");
|
|
310
|
+
const aggregate = this.#aggregateResolution;
|
|
218
311
|
const attemptSamples = this.#attempts.map((attempt, index) => ({
|
|
219
312
|
n: index + 1,
|
|
220
313
|
a: attempt.attempt,
|
|
@@ -233,33 +326,8 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
|
|
|
233
326
|
...(failover.attempt === undefined ? {} : { a: failover.attempt }),
|
|
234
327
|
}));
|
|
235
328
|
|
|
236
|
-
if (
|
|
237
|
-
if (!
|
|
238
|
-
const failureEvents = this.#events.filter((event) => event.outcome === "error");
|
|
239
|
-
const [firstFailure, ...remainingFailures] = failureEvents;
|
|
240
|
-
const aggregate = firstFailure
|
|
241
|
-
? remainingFailures.reduce<ProxyResolutionTelemetryEvent>(
|
|
242
|
-
(acc, event) => ({
|
|
243
|
-
provider: event.provider,
|
|
244
|
-
outcome: "error",
|
|
245
|
-
cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
|
|
246
|
-
cacheHit: acc.cacheHit && event.cacheHit,
|
|
247
|
-
resolutionMs: acc.resolutionMs + event.resolutionMs,
|
|
248
|
-
allocatorMs: sumOptional(acc.allocatorMs, event.allocatorMs),
|
|
249
|
-
allocatorStatus: event.allocatorStatus ?? acc.allocatorStatus,
|
|
250
|
-
allocatorBodyClass: event.allocatorBodyClass ?? acc.allocatorBodyClass,
|
|
251
|
-
allocatorAttempts: sumOptional(acc.allocatorAttempts, event.allocatorAttempts),
|
|
252
|
-
lockWaitMs: sumOptional(acc.lockWaitMs, event.lockWaitMs),
|
|
253
|
-
redisReadMs: sumOptional(acc.redisReadMs, event.redisReadMs),
|
|
254
|
-
redisWriteMs: sumOptional(acc.redisWriteMs, event.redisWriteMs),
|
|
255
|
-
poolAgeMs: maxOptional(acc.poolAgeMs, event.poolAgeMs),
|
|
256
|
-
poolExpiresInMs: maxOptional(acc.poolExpiresInMs, event.poolExpiresInMs),
|
|
257
|
-
attempts: acc.attempts + event.attempts,
|
|
258
|
-
refreshes: sumOptional(acc.refreshes, event.refreshes),
|
|
259
|
-
}),
|
|
260
|
-
firstFailure,
|
|
261
|
-
)
|
|
262
|
-
: undefined;
|
|
329
|
+
if (!this.#lastSuccessfulResolution) {
|
|
330
|
+
if (!aggregate && failovers.length === 0) return undefined;
|
|
263
331
|
return {
|
|
264
332
|
kind: "unresolved",
|
|
265
333
|
vendors: [...this.#unresolvedVendors],
|
|
@@ -301,33 +369,8 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
|
|
|
301
369
|
}
|
|
302
370
|
|
|
303
371
|
// The serving vendor/protocol is the last successful resolution.
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
for (const event of this.#events) {
|
|
307
|
-
if (!vendors.includes(event.provider)) vendors.push(event.provider);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const aggregate = rest.reduce<ProxyResolutionTelemetryEvent>(
|
|
311
|
-
(acc, event) => ({
|
|
312
|
-
provider: event.provider,
|
|
313
|
-
userAgentSource: event.userAgentSource ?? acc.userAgentSource,
|
|
314
|
-
cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
|
|
315
|
-
cacheHit: acc.cacheHit && event.cacheHit,
|
|
316
|
-
resolutionMs: acc.resolutionMs + event.resolutionMs,
|
|
317
|
-
allocatorMs: sumOptional(acc.allocatorMs, event.allocatorMs),
|
|
318
|
-
allocatorStatus: event.allocatorStatus ?? acc.allocatorStatus,
|
|
319
|
-
allocatorBodyClass: event.allocatorBodyClass ?? acc.allocatorBodyClass,
|
|
320
|
-
allocatorAttempts: sumOptional(acc.allocatorAttempts, event.allocatorAttempts),
|
|
321
|
-
lockWaitMs: sumOptional(acc.lockWaitMs, event.lockWaitMs),
|
|
322
|
-
redisReadMs: sumOptional(acc.redisReadMs, event.redisReadMs),
|
|
323
|
-
redisWriteMs: sumOptional(acc.redisWriteMs, event.redisWriteMs),
|
|
324
|
-
poolAgeMs: maxOptional(acc.poolAgeMs, event.poolAgeMs),
|
|
325
|
-
poolExpiresInMs: maxOptional(acc.poolExpiresInMs, event.poolExpiresInMs),
|
|
326
|
-
attempts: acc.attempts + event.attempts,
|
|
327
|
-
refreshes: sumOptional(acc.refreshes, event.refreshes),
|
|
328
|
-
}),
|
|
329
|
-
first,
|
|
330
|
-
);
|
|
372
|
+
if (!aggregate) return undefined;
|
|
373
|
+
const serving = this.#lastSuccessfulResolution;
|
|
331
374
|
return {
|
|
332
375
|
kind: "resolved",
|
|
333
376
|
provider: serving.provider,
|
|
@@ -356,18 +399,61 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
|
|
|
356
399
|
attempts: aggregate.attempts,
|
|
357
400
|
...(aggregate.refreshes !== undefined ? { refreshes: aggregate.refreshes } : {}),
|
|
358
401
|
...(attemptSamples.length > 0 ? { attemptSamples } : {}),
|
|
359
|
-
...(vendors.length > 1 ? { vendors } : {}),
|
|
402
|
+
...(this.#vendors.length > 1 ? { vendors: [...this.#vendors] } : {}),
|
|
360
403
|
...(failovers.length > 0 ? { failovers } : {}),
|
|
361
404
|
};
|
|
362
405
|
}
|
|
363
406
|
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
407
|
+
toHeaderPayload(log: ProxyTelemetryLogPayload): ProxyTelemetryHeaderPayload {
|
|
408
|
+
const attemptSamples = log.attemptSamples?.map((sample) => ({
|
|
409
|
+
n: sample.n,
|
|
410
|
+
a: sample.a,
|
|
411
|
+
...(sample.i === undefined ? {} : { i: sample.i }),
|
|
412
|
+
...(sample.h && PROXY_HEADER_HASH.test(sample.h)
|
|
413
|
+
? { h: closedEnum(sample.h) as ProxyHash }
|
|
414
|
+
: {}),
|
|
415
|
+
o: closedEnum(sample.o),
|
|
416
|
+
...(sample.c && PROXY_HEADER_TOKEN.test(sample.c) ? { c: closedEnum(sample.c) } : {}),
|
|
417
|
+
...(sample.s === undefined ? {} : { s: sample.s }),
|
|
418
|
+
...(sample.d === undefined ? {} : { d: sample.d }),
|
|
419
|
+
}));
|
|
420
|
+
const failovers = log.failovers?.map((failover) => ({
|
|
421
|
+
...failover,
|
|
422
|
+
v: closedEnum(failover.v),
|
|
423
|
+
...(failover.nx ? { nx: closedEnum(failover.nx) } : {}),
|
|
424
|
+
p: closedEnum(failover.p),
|
|
425
|
+
r: closedEnum(failover.r),
|
|
426
|
+
}));
|
|
427
|
+
if (log.kind === "resolved") {
|
|
428
|
+
return {
|
|
429
|
+
...log,
|
|
430
|
+
kind: closedEnum(log.kind),
|
|
431
|
+
provider: closedEnum(log.provider),
|
|
432
|
+
...(log.userAgentSource ? { userAgentSource: closedEnum(log.userAgentSource) } : {}),
|
|
433
|
+
...(log.protocol ? { protocol: closedEnum(log.protocol) } : {}),
|
|
434
|
+
cacheStatus: closedEnum(log.cacheStatus),
|
|
435
|
+
...(log.allocatorBodyClass
|
|
436
|
+
? { allocatorBodyClass: closedEnum(log.allocatorBodyClass) }
|
|
437
|
+
: {}),
|
|
438
|
+
...(attemptSamples ? { attemptSamples } : {}),
|
|
439
|
+
...(log.vendors ? { vendors: log.vendors.map((vendor) => closedEnum(vendor)) } : {}),
|
|
440
|
+
...(failovers ? { failovers } : {}),
|
|
441
|
+
} as ProxyTelemetryHeaderPayload;
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
...log,
|
|
445
|
+
kind: closedEnum(log.kind),
|
|
446
|
+
vendors: log.vendors.map((vendor) => closedEnum(vendor)),
|
|
447
|
+
...(log.cacheStatus ? { cacheStatus: closedEnum(log.cacheStatus) } : {}),
|
|
448
|
+
...(log.allocatorBodyClass ? { allocatorBodyClass: closedEnum(log.allocatorBodyClass) } : {}),
|
|
449
|
+
...(failovers ? { failovers } : {}),
|
|
450
|
+
...(attemptSamples ? { attemptSamples } : {}),
|
|
451
|
+
} as ProxyTelemetryHeaderPayload;
|
|
452
|
+
}
|
|
368
453
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
454
|
+
toHeaderValue(): string | undefined {
|
|
455
|
+
const telemetry = new RequestTelemetry(createTraceContext());
|
|
456
|
+
telemetry.register(this);
|
|
457
|
+
return telemetry.toHeaderValue();
|
|
372
458
|
}
|
|
373
459
|
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { PROVIDER_OBSERVABILITY_TAXONOMY_VERSION } from "../observability.js";
|
|
2
|
+
import type { ProxyTelemetrySink } from "../config/loader.js";
|
|
3
|
+
import type { ProxyTelemetryLogPayload } from "./proxy-telemetry.js";
|
|
4
|
+
import type { Span, TraceContext } from "./trace.js";
|
|
5
|
+
|
|
6
|
+
export const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
|
|
7
|
+
|
|
8
|
+
export type TelemetryKey =
|
|
9
|
+
| "proxy"
|
|
10
|
+
| "resolver"
|
|
11
|
+
| "http"
|
|
12
|
+
| "stealth"
|
|
13
|
+
| "native"
|
|
14
|
+
| "browser"
|
|
15
|
+
| "ocr"
|
|
16
|
+
| "stt"
|
|
17
|
+
| "cache"
|
|
18
|
+
| "state"
|
|
19
|
+
| "events";
|
|
20
|
+
|
|
21
|
+
declare const CLOSED_ENUM: unique symbol;
|
|
22
|
+
|
|
23
|
+
/** A string from an SDK-declared, closed observability taxonomy. */
|
|
24
|
+
export type ClosedEnum<T extends string> = T & { readonly [CLOSED_ENUM]: true };
|
|
25
|
+
|
|
26
|
+
/** Brands a value from an SDK-declared closed string union without changing it. */
|
|
27
|
+
export function closedEnum<T extends string>(value: T): ClosedEnum<T> {
|
|
28
|
+
return value as ClosedEnum<T>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Explicit exemption for the existing implementation-shaped cache key list. */
|
|
32
|
+
export type TenantOpaqueKeys = string[] & { readonly __tenantOpaqueCacheKeys: true };
|
|
33
|
+
|
|
34
|
+
/** Names the deliberate tenant cache-key exemption without changing its wire value. */
|
|
35
|
+
export function tenantOpaqueKeys(value: string[]): TenantOpaqueKeys {
|
|
36
|
+
return value as TenantOpaqueKeys;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compile-time projection for the gateway ingestion contract. Plain strings,
|
|
41
|
+
* URLs, hostnames, free text, unknown values, and escape-hatch properties reduce to `never`.
|
|
42
|
+
*/
|
|
43
|
+
export type GatewayIngestible<T> = 0 extends 1 & T
|
|
44
|
+
? never
|
|
45
|
+
: T extends object
|
|
46
|
+
? keyof T extends never
|
|
47
|
+
? never
|
|
48
|
+
: {
|
|
49
|
+
[K in keyof T]: 0 extends 1 & T[K]
|
|
50
|
+
? never
|
|
51
|
+
: unknown extends T[K]
|
|
52
|
+
? never
|
|
53
|
+
: [NonNullable<T[K]>] extends [number | boolean | ClosedEnum<string>]
|
|
54
|
+
? T[K]
|
|
55
|
+
: [NonNullable<T[K]>] extends [readonly (infer U)[]]
|
|
56
|
+
? 0 extends 1 & U
|
|
57
|
+
? never
|
|
58
|
+
: unknown extends U
|
|
59
|
+
? never
|
|
60
|
+
: [U] extends [
|
|
61
|
+
number | boolean | ClosedEnum<string> | GatewayIngestible<U>,
|
|
62
|
+
]
|
|
63
|
+
? T[K]
|
|
64
|
+
: never
|
|
65
|
+
: [NonNullable<T[K]>] extends [object]
|
|
66
|
+
? [NonNullable<T[K]>] extends [GatewayIngestible<NonNullable<T[K]>>]
|
|
67
|
+
? T[K]
|
|
68
|
+
: never
|
|
69
|
+
: never;
|
|
70
|
+
}
|
|
71
|
+
: never;
|
|
72
|
+
|
|
73
|
+
/** Compile-time projection for tenant-visible, identity-neutral metadata. */
|
|
74
|
+
export type TenantNeutral<T> = 0 extends 1 & T
|
|
75
|
+
? never
|
|
76
|
+
: T extends object
|
|
77
|
+
? {
|
|
78
|
+
[K in keyof T]: K extends
|
|
79
|
+
| `vendor${string}`
|
|
80
|
+
| "provider"
|
|
81
|
+
| "engine"
|
|
82
|
+
| "model"
|
|
83
|
+
| `${string}Host`
|
|
84
|
+
? never
|
|
85
|
+
: 0 extends 1 & T[K]
|
|
86
|
+
? never
|
|
87
|
+
: unknown extends T[K]
|
|
88
|
+
? never
|
|
89
|
+
: [NonNullable<T[K]>] extends [
|
|
90
|
+
string[] & { readonly __tenantOpaqueCacheKeys: true },
|
|
91
|
+
]
|
|
92
|
+
? K extends "keys"
|
|
93
|
+
? T[K]
|
|
94
|
+
: never
|
|
95
|
+
: [NonNullable<T[K]>] extends [number | boolean | ClosedEnum<string>]
|
|
96
|
+
? T[K]
|
|
97
|
+
: [NonNullable<T[K]>] extends [object]
|
|
98
|
+
? [NonNullable<T[K]>] extends [TenantNeutral<NonNullable<T[K]>>]
|
|
99
|
+
? T[K]
|
|
100
|
+
: never
|
|
101
|
+
: never;
|
|
102
|
+
}
|
|
103
|
+
: never;
|
|
104
|
+
|
|
105
|
+
export interface SpanIndex {
|
|
106
|
+
readonly spans: readonly Span[];
|
|
107
|
+
readonly byName: ReadonlyMap<string, readonly Span[]>;
|
|
108
|
+
count(name: string): number;
|
|
109
|
+
durationMs(name: string): number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface TelemetryContributor<Log extends object, Header extends object> {
|
|
113
|
+
readonly key: TelemetryKey;
|
|
114
|
+
toLogPayload(spans: SpanIndex): Log | undefined;
|
|
115
|
+
toHeaderPayload(
|
|
116
|
+
log: Log,
|
|
117
|
+
): 0 extends 1 & Header
|
|
118
|
+
? never
|
|
119
|
+
: [Header] extends [GatewayIngestible<Header>]
|
|
120
|
+
? Header | undefined
|
|
121
|
+
: never;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type RequestTelemetryLogPayload = {
|
|
125
|
+
proxy?: ProxyTelemetryLogPayload;
|
|
126
|
+
} & Partial<Record<Exclude<TelemetryKey, "proxy">, object>>;
|
|
127
|
+
|
|
128
|
+
type RegisteredTelemetryContributor = {
|
|
129
|
+
readonly key: TelemetryKey;
|
|
130
|
+
toLogPayload(spans: SpanIndex): object | undefined;
|
|
131
|
+
toHeaderPayload(log: object): object | undefined;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type ProviderTelemetryEnvelope = {
|
|
135
|
+
v: 1;
|
|
136
|
+
taxonomy: typeof PROVIDER_OBSERVABILITY_TAXONOMY_VERSION;
|
|
137
|
+
truncated?: true;
|
|
138
|
+
} & Partial<Record<Exclude<TelemetryKey, "events">, object>>;
|
|
139
|
+
|
|
140
|
+
const MAX_HEADER_BYTES = 4_096;
|
|
141
|
+
const MAX_INGESTIBLE_ARRAY_LENGTH = 64;
|
|
142
|
+
const MAX_INGESTIBLE_OBJECT_KEYS = 32;
|
|
143
|
+
const MAX_INGESTIBLE_DEPTH = 4;
|
|
144
|
+
const INGESTIBLE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
|
|
145
|
+
const HEADER_PRIORITY = [
|
|
146
|
+
"proxy",
|
|
147
|
+
"resolver",
|
|
148
|
+
"native",
|
|
149
|
+
"http",
|
|
150
|
+
"stealth",
|
|
151
|
+
"browser",
|
|
152
|
+
"ocr",
|
|
153
|
+
"stt",
|
|
154
|
+
"cache",
|
|
155
|
+
"state",
|
|
156
|
+
] as const satisfies readonly Exclude<TelemetryKey, "events">[];
|
|
157
|
+
const warnedInvalidKeys = new Set<TelemetryKey>();
|
|
158
|
+
|
|
159
|
+
function createSpanIndex(trace: TraceContext): SpanIndex {
|
|
160
|
+
const spans = trace.getSpans().slice();
|
|
161
|
+
const mutableByName = new Map<string, Span[]>();
|
|
162
|
+
for (const span of spans) {
|
|
163
|
+
const named = mutableByName.get(span.name);
|
|
164
|
+
if (named) named.push(span);
|
|
165
|
+
else mutableByName.set(span.name, [span]);
|
|
166
|
+
}
|
|
167
|
+
const byName = new Map<string, readonly Span[]>(mutableByName);
|
|
168
|
+
return {
|
|
169
|
+
spans,
|
|
170
|
+
byName,
|
|
171
|
+
count(name): number {
|
|
172
|
+
return byName.get(name)?.length ?? 0;
|
|
173
|
+
},
|
|
174
|
+
durationMs(name): number {
|
|
175
|
+
return (byName.get(name) ?? []).reduce((total, span) => total + span.duration_ms, 0);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
181
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
182
|
+
const prototype = Object.getPrototypeOf(value);
|
|
183
|
+
return prototype === Object.prototype || prototype === null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Bounded structural defence in depth against casts and accidental free text.
|
|
188
|
+
* Closed-enum brands are erased at runtime; the type-level guard is the contract,
|
|
189
|
+
* while this check only enforces safe token shapes and collection bounds.
|
|
190
|
+
*/
|
|
191
|
+
export function isGatewayIngestible(value: unknown): boolean {
|
|
192
|
+
const ancestors = new Set<object>();
|
|
193
|
+
const visit = (candidate: unknown, depth: number): boolean => {
|
|
194
|
+
if (typeof candidate === "number") return Number.isFinite(candidate);
|
|
195
|
+
if (typeof candidate === "boolean") return true;
|
|
196
|
+
if (typeof candidate === "string") return INGESTIBLE_TOKEN.test(candidate);
|
|
197
|
+
if (candidate === null || typeof candidate !== "object") return false;
|
|
198
|
+
if (depth >= MAX_INGESTIBLE_DEPTH || ancestors.has(candidate)) return false;
|
|
199
|
+
|
|
200
|
+
if (Array.isArray(candidate)) {
|
|
201
|
+
if (candidate.length > MAX_INGESTIBLE_ARRAY_LENGTH) return false;
|
|
202
|
+
ancestors.add(candidate);
|
|
203
|
+
const valid = candidate.every(
|
|
204
|
+
(item, index) => Object.hasOwn(candidate, index) && visit(item, depth + 1),
|
|
205
|
+
);
|
|
206
|
+
ancestors.delete(candidate);
|
|
207
|
+
return valid;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isPlainRecord(candidate)) return false;
|
|
211
|
+
const keys = Object.keys(candidate);
|
|
212
|
+
if (keys.length > MAX_INGESTIBLE_OBJECT_KEYS) return false;
|
|
213
|
+
ancestors.add(candidate);
|
|
214
|
+
const valid = keys.every((key) => visit(candidate[key], depth + 1));
|
|
215
|
+
ancestors.delete(candidate);
|
|
216
|
+
return valid;
|
|
217
|
+
};
|
|
218
|
+
try {
|
|
219
|
+
return visit(value, 0);
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function encodeEnvelope(envelope: ProviderTelemetryEnvelope): string {
|
|
226
|
+
return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64url");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function warnInvalidSibling(key: TelemetryKey): void {
|
|
230
|
+
if (warnedInvalidKeys.has(key)) return;
|
|
231
|
+
warnedInvalidKeys.add(key);
|
|
232
|
+
try {
|
|
233
|
+
console.warn(
|
|
234
|
+
`[provider-sdk] Dropped invalid telemetry sibling "${key}"; contributors must return serializable log objects and bounded gateway-safe header values.`,
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
// A host-provided console must not let telemetry fail the request path.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isProxySink(value: unknown): value is ProxyTelemetrySink {
|
|
242
|
+
if (value === null || typeof value !== "object") return false;
|
|
243
|
+
try {
|
|
244
|
+
return typeof Reflect.get(value, "recordProxyResolution") === "function";
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One finalisation owner for every request-scoped telemetry contributor. */
|
|
251
|
+
export class RequestTelemetry {
|
|
252
|
+
readonly trace: TraceContext;
|
|
253
|
+
readonly contributors: Readonly<
|
|
254
|
+
Partial<
|
|
255
|
+
Record<
|
|
256
|
+
TelemetryKey,
|
|
257
|
+
{
|
|
258
|
+
readonly key: TelemetryKey;
|
|
259
|
+
toLogPayload(spans: SpanIndex): object | undefined;
|
|
260
|
+
}
|
|
261
|
+
>
|
|
262
|
+
>
|
|
263
|
+
>;
|
|
264
|
+
readonly #registered: Partial<Record<TelemetryKey, RegisteredTelemetryContributor>> = {};
|
|
265
|
+
readonly #exposed: Partial<
|
|
266
|
+
Record<
|
|
267
|
+
TelemetryKey,
|
|
268
|
+
{
|
|
269
|
+
readonly key: TelemetryKey;
|
|
270
|
+
toLogPayload(spans: SpanIndex): object | undefined;
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
> = {};
|
|
274
|
+
#proxy: ProxyTelemetrySink | undefined;
|
|
275
|
+
|
|
276
|
+
constructor(trace: TraceContext) {
|
|
277
|
+
this.trace = trace;
|
|
278
|
+
this.contributors = this.#exposed;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
register<Log extends object, Header extends object>(
|
|
282
|
+
contributor: TelemetryContributor<Log, Header> &
|
|
283
|
+
(0 extends 1 & Log
|
|
284
|
+
? never
|
|
285
|
+
: 0 extends 1 & Header
|
|
286
|
+
? never
|
|
287
|
+
: [Header] extends [GatewayIngestible<Header>]
|
|
288
|
+
? unknown
|
|
289
|
+
: never),
|
|
290
|
+
): void {
|
|
291
|
+
if (this.#registered[contributor.key]) {
|
|
292
|
+
throw new TypeError(`Telemetry contributor "${contributor.key}" is already registered.`);
|
|
293
|
+
}
|
|
294
|
+
const registered: RegisteredTelemetryContributor = {
|
|
295
|
+
key: contributor.key,
|
|
296
|
+
toLogPayload: (spans) => contributor.toLogPayload(spans),
|
|
297
|
+
toHeaderPayload: (log) => contributor.toHeaderPayload(log as Log),
|
|
298
|
+
};
|
|
299
|
+
this.#registered[contributor.key] = registered;
|
|
300
|
+
this.#exposed[contributor.key] = contributor;
|
|
301
|
+
if (contributor.key === "proxy" && isProxySink(contributor)) this.#proxy = contributor;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
get proxy(): ProxyTelemetrySink | undefined {
|
|
305
|
+
return this.#proxy;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
toLogPayload(): RequestTelemetryLogPayload | undefined {
|
|
309
|
+
const spans = createSpanIndex(this.trace);
|
|
310
|
+
const payload: Partial<Record<TelemetryKey, object>> = {};
|
|
311
|
+
for (const key of Object.keys(this.#registered) as TelemetryKey[]) {
|
|
312
|
+
const contributor = this.#registered[key];
|
|
313
|
+
if (!contributor) continue;
|
|
314
|
+
try {
|
|
315
|
+
const sibling = contributor.toLogPayload(spans);
|
|
316
|
+
if (sibling === undefined) continue;
|
|
317
|
+
JSON.stringify(sibling);
|
|
318
|
+
payload[key] = sibling;
|
|
319
|
+
} catch {
|
|
320
|
+
warnInvalidSibling(key);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return Object.keys(payload).length > 0 ? (payload as RequestTelemetryLogPayload) : undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
toHeaderValue(): string | undefined {
|
|
327
|
+
const spans = createSpanIndex(this.trace);
|
|
328
|
+
const siblings: Partial<Record<Exclude<TelemetryKey, "events">, object>> = {};
|
|
329
|
+
for (const key of HEADER_PRIORITY) {
|
|
330
|
+
const contributor = this.#registered[key];
|
|
331
|
+
if (!contributor) continue;
|
|
332
|
+
try {
|
|
333
|
+
const log = contributor.toLogPayload(spans);
|
|
334
|
+
if (log === undefined) continue;
|
|
335
|
+
const projected = contributor.toHeaderPayload(log);
|
|
336
|
+
if (projected === undefined) continue;
|
|
337
|
+
if (!isGatewayIngestible(projected)) {
|
|
338
|
+
warnInvalidSibling(key);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const serialized = JSON.stringify(projected);
|
|
342
|
+
const decoded: unknown = JSON.parse(serialized);
|
|
343
|
+
if (!isGatewayIngestible(decoded) || !isPlainRecord(decoded)) {
|
|
344
|
+
warnInvalidSibling(key);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
siblings[key] = decoded;
|
|
348
|
+
} catch {
|
|
349
|
+
warnInvalidSibling(key);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (Object.keys(siblings).length === 0) return undefined;
|
|
354
|
+
|
|
355
|
+
// Gateway uses permissive json.Unmarshal (unknown keys are ignored) but
|
|
356
|
+
// requires v === 1. The observability taxonomy is additive and independent.
|
|
357
|
+
const envelope: ProviderTelemetryEnvelope = {
|
|
358
|
+
v: 1,
|
|
359
|
+
taxonomy: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
360
|
+
...siblings,
|
|
361
|
+
};
|
|
362
|
+
let encoded = encodeEnvelope(envelope);
|
|
363
|
+
if (encoded.length <= MAX_HEADER_BYTES) return encoded;
|
|
364
|
+
|
|
365
|
+
for (let index = HEADER_PRIORITY.length - 1; index >= 0; index -= 1) {
|
|
366
|
+
const key = HEADER_PRIORITY[index];
|
|
367
|
+
if (!key || !(key in envelope)) continue;
|
|
368
|
+
delete envelope[key];
|
|
369
|
+
if (!HEADER_PRIORITY.some((candidate) => candidate in envelope)) return undefined;
|
|
370
|
+
envelope.truncated = true;
|
|
371
|
+
encoded = encodeEnvelope(envelope);
|
|
372
|
+
if (encoded.length <= MAX_HEADER_BYTES) return encoded;
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|