@apifuse/provider-sdk 2.2.0-beta.59 → 2.2.0-beta.60
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/AUTHORING.md +16 -0
- package/CHANGELOG.md +4 -0
- package/bin/apifuse-dev.ts +2 -1
- package/bin/apifuse-perf.ts +41 -4
- package/bin/apifuse-record.ts +3 -2
- package/dist/config/loader.js +13 -12
- package/dist/engine.js +3 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lint.js +36 -15
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/browser.js +2 -1
- package/dist/runtime/cache.js +3 -2
- package/dist/runtime/diagnostic-env-values.d.ts +5 -0
- package/dist/runtime/diagnostic-env-values.js +57 -0
- package/dist/runtime/diagnostic-env.d.ts +11 -0
- package/dist/runtime/diagnostic-env.js +35 -0
- package/dist/runtime/diagnostic-redactor.d.ts +49 -0
- package/dist/runtime/diagnostic-redactor.js +569 -0
- package/dist/runtime/egress-lease.d.ts +2 -0
- package/dist/runtime/egress-lease.js +4 -1
- package/dist/runtime/env.js +2 -1
- package/dist/runtime/instrumentation.js +3 -0
- package/dist/runtime/ocr.js +2 -1
- package/dist/runtime/otlp.js +5 -4
- package/dist/runtime/proxy-nodemaven.js +3 -2
- package/dist/runtime/request-options.d.ts +7 -0
- package/dist/runtime/request-options.js +62 -22
- package/dist/runtime/resolver-vendors/browser.js +0 -1
- package/dist/runtime/resolver.js +3 -2
- package/dist/runtime/stt.js +3 -2
- package/dist/runtime/trace.d.ts +5 -0
- package/dist/runtime/trace.js +115 -20
- package/dist/schema.d.ts +11 -0
- package/dist/schema.js +56 -2
- package/dist/server/self-test-redaction.js +8 -33
- package/dist/server/sensitive-values.d.ts +26 -0
- package/dist/server/sensitive-values.js +131 -0
- package/dist/server/serve-implementation.js +219 -78
- package/dist/server/trace-output.d.ts +2 -1
- package/dist/server/trace-output.js +46 -14
- package/dist/trace-sanitization.d.ts +4 -3
- package/dist/trace-sanitization.js +45 -22
- package/package.json +2 -1
- package/src/config/loader.ts +13 -12
- package/src/engine.ts +3 -2
- package/src/index.ts +2 -0
- package/src/lint.ts +42 -16
- package/src/provider.ts +1 -0
- package/src/runtime/browser.ts +2 -1
- package/src/runtime/cache.ts +3 -2
- package/src/runtime/diagnostic-env-values.ts +56 -0
- package/src/runtime/diagnostic-env.ts +55 -0
- package/src/runtime/diagnostic-redactor.ts +662 -0
- package/src/runtime/egress-lease.ts +6 -1
- package/src/runtime/env.ts +2 -1
- package/src/runtime/instrumentation.ts +3 -0
- package/src/runtime/ocr.ts +2 -1
- package/src/runtime/otlp.ts +5 -4
- package/src/runtime/proxy-nodemaven.ts +3 -2
- package/src/runtime/request-options.ts +76 -28
- package/src/runtime/resolver-vendors/browser.ts +0 -1
- package/src/runtime/resolver.ts +3 -2
- package/src/runtime/stt.ts +3 -2
- package/src/runtime/trace.ts +168 -26
- package/src/schema.ts +63 -2
- package/src/server/self-test-redaction.ts +10 -40
- package/src/server/sensitive-values.ts +200 -0
- package/src/server/serve-implementation.ts +301 -96
- package/src/server/trace-output.ts +77 -17
- package/src/trace-sanitization.ts +70 -23
package/src/runtime/env.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { isEngineOwnedEnvName } from "../engine.js";
|
|
2
3
|
import type { EnvContext } from "../types.js";
|
|
3
4
|
|
|
@@ -20,7 +21,7 @@ export function createEnvContext(allowedKeys?: string[]): EnvContext {
|
|
|
20
21
|
return undefined;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
return
|
|
24
|
+
return readDiagnosticEnv(key);
|
|
24
25
|
},
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerDiagnosticSensitiveValues } from "./diagnostic-redactor.js";
|
|
1
2
|
import type {
|
|
2
3
|
HttpStreamResponse,
|
|
3
4
|
ProviderContext,
|
|
@@ -570,6 +571,7 @@ function wrapStealthRedirects(
|
|
|
570
571
|
run(...args: Parameters<StealthSession["redirects"]["run"]>) {
|
|
571
572
|
const diagnosticArgs = [args[0].url, args[0]];
|
|
572
573
|
const diagnostics = snapshotRequestDiagnostics("stealth", "fetch", diagnosticArgs);
|
|
574
|
+
registerDiagnosticSensitiveValues(trace, diagnostics.sensitiveValues);
|
|
573
575
|
let result: ReturnType<StealthSession["redirects"]["run"]>;
|
|
574
576
|
try {
|
|
575
577
|
result = redirects.run(...args);
|
|
@@ -750,6 +752,7 @@ function wrapNamespace<T extends object>(
|
|
|
750
752
|
return Reflect.apply(value, namespaceTarget, args);
|
|
751
753
|
}
|
|
752
754
|
const requestDiagnostics = snapshotRequestDiagnostics(namespace, methodName, args);
|
|
755
|
+
registerDiagnosticSensitiveValues(trace, requestDiagnostics.sensitiveValues);
|
|
753
756
|
// Invoke first and decide by the RETURN VALUE. `runSpan` always
|
|
754
757
|
// returns a Promise, so unconditionally span-wrapping every member
|
|
755
758
|
// silently rewrote synchronous contracts: `ctx.state.namespace()`
|
package/src/runtime/ocr.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { ProviderError, TransportError } from "../errors.js";
|
|
2
3
|
import type {
|
|
3
4
|
OcrCaptchaCandidate,
|
|
@@ -96,7 +97,7 @@ export function createUnsupportedOcrClient(reason?: string): OcrContext {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
function normalizedEnvValue(env: EnvLike, key: string): string | undefined {
|
|
99
|
-
const value = env
|
|
100
|
+
const value = readDiagnosticEnv(key, env)?.trim();
|
|
100
101
|
return value ? value : undefined;
|
|
101
102
|
}
|
|
102
103
|
|
package/src/runtime/otlp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { AsyncResource } from "node:async_hooks";
|
|
2
3
|
|
|
3
4
|
import type { TraceSpan } from "../types.js";
|
|
@@ -153,12 +154,12 @@ export function resolveOTLPExportOptions(
|
|
|
153
154
|
{ source: EXPLICIT_ENDPOINT_SOURCE, value: explicit.endpoint, appendPath: false },
|
|
154
155
|
{
|
|
155
156
|
source: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
|
|
156
|
-
value:
|
|
157
|
+
value: readDiagnosticEnv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env),
|
|
157
158
|
appendPath: false,
|
|
158
159
|
},
|
|
159
160
|
{
|
|
160
161
|
source: OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
161
|
-
value:
|
|
162
|
+
value: readDiagnosticEnv(OTEL_EXPORTER_OTLP_ENDPOINT, env),
|
|
162
163
|
appendPath: true,
|
|
163
164
|
},
|
|
164
165
|
];
|
|
@@ -173,11 +174,11 @@ export function resolveOTLPExportOptions(
|
|
|
173
174
|
}
|
|
174
175
|
const endpoint = candidate.appendPath ? appendTracesPath(parsed.url) : candidate.value;
|
|
175
176
|
|
|
176
|
-
const tracesHeaders = presentValue(env
|
|
177
|
+
const tracesHeaders = presentValue(readDiagnosticEnv(OTEL_EXPORTER_OTLP_TRACES_HEADERS, env));
|
|
177
178
|
const headersSource =
|
|
178
179
|
tracesHeaders !== undefined ? OTEL_EXPORTER_OTLP_TRACES_HEADERS : OTEL_EXPORTER_OTLP_HEADERS;
|
|
179
180
|
const envHeaders = parseHeaderList(
|
|
180
|
-
tracesHeaders ?? presentValue(env
|
|
181
|
+
tracesHeaders ?? presentValue(readDiagnosticEnv(OTEL_EXPORTER_OTLP_HEADERS, env)),
|
|
181
182
|
);
|
|
182
183
|
if (!headersAreSendable(envHeaders)) {
|
|
183
184
|
return { status: "invalid", source: headersSource, reason: INVALID_HEADERS };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import type { ProviderProxyPolicy } from "../types.js";
|
|
@@ -43,11 +44,11 @@ export function hasNodemavenCredentials(): boolean {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function readNodemavenUsername(): string | undefined {
|
|
46
|
-
return
|
|
47
|
+
return readDiagnosticEnv(NODEMAVEN_USERNAME_ENV)?.trim() || undefined;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
function readNodemavenPassword(): string | undefined {
|
|
50
|
-
return
|
|
51
|
+
return readDiagnosticEnv(NODEMAVEN_PASSWORD_ENV)?.trim() || undefined;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
function resolveNodemavenFilter(value: string | undefined): string {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isProviderError } from "../errors.js";
|
|
1
2
|
import type {
|
|
2
3
|
HttpClient,
|
|
3
4
|
RequestOptions,
|
|
@@ -5,7 +6,6 @@ import type {
|
|
|
5
6
|
RequestParams,
|
|
6
7
|
RequestParamValue,
|
|
7
8
|
} from "../types.js";
|
|
8
|
-
import { isProviderError } from "../errors.js";
|
|
9
9
|
|
|
10
10
|
export const REDACTED_QUERY_VALUE = "[REDACTED]";
|
|
11
11
|
|
|
@@ -161,15 +161,34 @@ export function serializeRequestUrl(
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
const MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH =
|
|
164
|
+
const MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH = 8;
|
|
165
165
|
|
|
166
|
-
function normalizePercentEscapes(value: string): string {
|
|
166
|
+
export function normalizePercentEscapes(value: string): string {
|
|
167
167
|
return value.replace(/%[\da-f]{2}/gi, (percentEscape) => percentEscape.toUpperCase());
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
function sensitiveValueVariants(value: string): string[] {
|
|
171
171
|
if (!value || value === REDACTED_QUERY_VALUE) return [];
|
|
172
172
|
|
|
173
|
+
// Owner-visible policy: short credentials are literal, case-sensitive tokens only.
|
|
174
|
+
// Encoded short needles collide with benign cursors and ordinary diagnostic text.
|
|
175
|
+
if (value.length < 8) return [value];
|
|
176
|
+
// These ASCII characters are identical under NFC, JSON, URI-component and
|
|
177
|
+
// form encoding. Avoid constructing those identical variants for common tokens.
|
|
178
|
+
if (/^[A-Za-z0-9._*-]+$/.test(value)) {
|
|
179
|
+
const base64 = Buffer.from(value, "utf8").toString("base64");
|
|
180
|
+
const base64Url = base64.replaceAll("+", "-").replaceAll("/", "_");
|
|
181
|
+
return [
|
|
182
|
+
...new Set([
|
|
183
|
+
value,
|
|
184
|
+
base64,
|
|
185
|
+
base64.replace(/=+$/, ""),
|
|
186
|
+
base64Url,
|
|
187
|
+
base64Url.replace(/=+$/, ""),
|
|
188
|
+
]),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
const normalized = value.normalize("NFC");
|
|
173
192
|
const formEncoded = new URLSearchParams({ value }).toString().slice("value=".length);
|
|
174
193
|
let componentEncoded: string;
|
|
175
194
|
try {
|
|
@@ -179,15 +198,42 @@ function sensitiveValueVariants(value: string): string[] {
|
|
|
179
198
|
// still safe to scrub, and URLSearchParams supplies its well-formed form.
|
|
180
199
|
componentEncoded = value;
|
|
181
200
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
const base64 = Buffer.from(value, "utf8").toString("base64");
|
|
202
|
+
const base64Url = base64.replaceAll("+", "-").replaceAll("/", "_");
|
|
203
|
+
const jsonEscaped = JSON.stringify(value).slice(1, -1);
|
|
204
|
+
return [
|
|
205
|
+
...new Set(
|
|
206
|
+
[
|
|
207
|
+
value,
|
|
208
|
+
normalized,
|
|
209
|
+
componentEncoded,
|
|
210
|
+
formEncoded,
|
|
211
|
+
jsonEscaped,
|
|
212
|
+
base64,
|
|
213
|
+
base64.replace(/=+$/, ""),
|
|
214
|
+
base64Url,
|
|
215
|
+
base64Url.replace(/=+$/, ""),
|
|
216
|
+
].map(normalizePercentEscapes),
|
|
217
|
+
),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type SensitiveValueVariant = {
|
|
222
|
+
value: string;
|
|
223
|
+
requiresTokenBoundary: boolean;
|
|
224
|
+
caseInsensitive: boolean;
|
|
225
|
+
};
|
|
186
226
|
|
|
187
|
-
function allSensitiveValueVariants(
|
|
227
|
+
export function allSensitiveValueVariants(
|
|
228
|
+
sensitiveValues: readonly string[],
|
|
229
|
+
): SensitiveValueVariant[] {
|
|
188
230
|
const variants = new Map<string, SensitiveValueVariant>();
|
|
189
231
|
for (const sensitiveValue of sensitiveValues) {
|
|
190
232
|
const requiresTokenBoundary = sensitiveValue.length < MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH;
|
|
233
|
+
// Long values are unlikely to be ordinary prose, so case folding is worth
|
|
234
|
+
// the extra over-redaction protection. Values of 4-7 characters remain
|
|
235
|
+
// case-sensitive because they commonly occur as words and identifiers.
|
|
236
|
+
const caseInsensitive = sensitiveValue.length >= 8;
|
|
191
237
|
for (const value of sensitiveValueVariants(sensitiveValue)) {
|
|
192
238
|
const existing = variants.get(value);
|
|
193
239
|
variants.set(value, {
|
|
@@ -195,6 +241,7 @@ function allSensitiveValueVariants(sensitiveValues: readonly string[]): Sensitiv
|
|
|
195
241
|
requiresTokenBoundary: existing
|
|
196
242
|
? existing.requiresTokenBoundary && requiresTokenBoundary
|
|
197
243
|
: requiresTokenBoundary,
|
|
244
|
+
caseInsensitive: existing ? existing.caseInsensitive || caseInsensitive : caseInsensitive,
|
|
198
245
|
});
|
|
199
246
|
}
|
|
200
247
|
}
|
|
@@ -209,28 +256,29 @@ function isTokenCharacter(value: string | undefined): boolean {
|
|
|
209
256
|
}
|
|
210
257
|
|
|
211
258
|
function replaceSensitiveVariant(text: string, variant: SensitiveValueVariant): string {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
224
|
-
return text.replace(new RegExp(pattern, "gu"), (match, offset: number, source: string) => {
|
|
225
|
-
const end = offset + match.length;
|
|
259
|
+
const normalized = normalizePercentEscapes(text);
|
|
260
|
+
const comparable = variant.caseInsensitive ? normalized.toLowerCase() : text;
|
|
261
|
+
const needle = variant.caseInsensitive ? variant.value.toLowerCase() : variant.value;
|
|
262
|
+
let cursor = 0;
|
|
263
|
+
let output = "";
|
|
264
|
+
for (
|
|
265
|
+
let index = comparable.indexOf(needle);
|
|
266
|
+
index !== -1;
|
|
267
|
+
index = comparable.indexOf(needle, index + 1)
|
|
268
|
+
) {
|
|
269
|
+
const end = index + needle.length;
|
|
226
270
|
if (
|
|
227
271
|
variant.requiresTokenBoundary &&
|
|
228
|
-
(isTokenCharacter(
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return REDACTED_QUERY_VALUE;
|
|
233
|
-
|
|
272
|
+
(isTokenCharacter(comparable[index - 1]) || isTokenCharacter(comparable[end]))
|
|
273
|
+
)
|
|
274
|
+
continue;
|
|
275
|
+
// Unicode folding can change offsets; suppress the field instead of slicing a secret.
|
|
276
|
+
if (comparable.length !== text.length) return REDACTED_QUERY_VALUE;
|
|
277
|
+
output += text.slice(cursor, index) + REDACTED_QUERY_VALUE;
|
|
278
|
+
cursor = end;
|
|
279
|
+
index = end - 1;
|
|
280
|
+
}
|
|
281
|
+
return cursor ? output + text.slice(cursor) : text;
|
|
234
282
|
}
|
|
235
283
|
|
|
236
284
|
/** Scrubs raw and URL-encoded secret values from diagnostic text. */
|
package/src/runtime/resolver.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -309,12 +310,12 @@ type ResolverChainEntry = {
|
|
|
309
310
|
};
|
|
310
311
|
|
|
311
312
|
function normalizedEnvValue(env: EnvLike, key: string): string | undefined {
|
|
312
|
-
const value = env
|
|
313
|
+
const value = readDiagnosticEnv(key, env)?.trim();
|
|
313
314
|
return value ? value : undefined;
|
|
314
315
|
}
|
|
315
316
|
|
|
316
317
|
function readPositiveIntegerEnv(env: EnvLike, name: string): string | undefined {
|
|
317
|
-
const raw = env
|
|
318
|
+
const raw = readDiagnosticEnv(name, env)?.trim();
|
|
318
319
|
if (!raw) return undefined;
|
|
319
320
|
if (!/^[1-9]\d*$/.test(raw)) {
|
|
320
321
|
throw new Error(`${name} must be a positive integer`);
|
package/src/runtime/stt.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { ProviderError, TransportError, ValidationError } from "../errors.js";
|
|
2
3
|
import type {
|
|
3
4
|
Bcp47Locale,
|
|
@@ -69,7 +70,7 @@ export function createUnsupportedSttClient(reason?: string): SttContext {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function normalizedEnvValue(env: EnvLike, key: string): string | undefined {
|
|
72
|
-
const value = env
|
|
73
|
+
const value = readDiagnosticEnv(key, env)?.trim();
|
|
73
74
|
return value ? value : undefined;
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -99,7 +100,7 @@ export function createSttClientFromEnv(
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
const accountId = env.APIFUSE__CLOUDFLARE__ACCOUNT_ID?.trim();
|
|
102
|
-
const apiToken = env
|
|
103
|
+
const apiToken = readDiagnosticEnv(APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV, env)?.trim();
|
|
103
104
|
if (!accountId || !apiToken) {
|
|
104
105
|
return createUnsupportedSttClient(
|
|
105
106
|
`STT backend ${backend} requires ${CLOUDFLARE_ACCOUNT_ID_ENV} and ${APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV}`,
|
package/src/runtime/trace.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
|
|
2
|
+
import { sanitizeSpanNameForOutput } from "../trace-sanitization.js";
|
|
3
3
|
import type {
|
|
4
4
|
TraceContext as BaseTraceContext,
|
|
5
5
|
TraceAttributeValue,
|
|
6
6
|
TraceConfig,
|
|
7
7
|
TraceSpan,
|
|
8
8
|
} from "../types.js";
|
|
9
|
+
import {
|
|
10
|
+
type DiagnosticRedactor,
|
|
11
|
+
diagnosticStructuredRedactor,
|
|
12
|
+
redactDiagnosticAttributeKey,
|
|
13
|
+
copyDiagnosticAttributeTypes,
|
|
14
|
+
redactedKeyAllocator,
|
|
15
|
+
isRedactedKey,
|
|
16
|
+
redactCriticalDiagnosticText,
|
|
17
|
+
diagnosticAttributeRedactor,
|
|
18
|
+
REDACTION_FAILED,
|
|
19
|
+
redactDiagnosticText,
|
|
20
|
+
} from "./diagnostic-redactor.js";
|
|
9
21
|
import { exportSpansOTLP, type OTLPExportOptions } from "./otlp.js";
|
|
22
|
+
import { REDACTED_QUERY_VALUE } from "./request-options.js";
|
|
10
23
|
|
|
11
24
|
export type SpanAttributeValue = TraceAttributeValue;
|
|
12
25
|
|
|
@@ -19,6 +32,8 @@ export interface TraceContext extends BaseTraceContext {
|
|
|
19
32
|
export interface CreateTraceContextOptions {
|
|
20
33
|
maxSpans?: number;
|
|
21
34
|
onSpan?: (span: Span) => void;
|
|
35
|
+
/** Redacts diagnostic attribute values and errors before recording or calling onSpan. */
|
|
36
|
+
redact?: DiagnosticRedactor;
|
|
22
37
|
exportOptions?: OTLPExportOptions;
|
|
23
38
|
resourceAttributes?: Record<string, string>;
|
|
24
39
|
/** W3C-compatible 32-character lowercase hexadecimal trace id used for export. */
|
|
@@ -42,7 +57,7 @@ type PendingSpan = {
|
|
|
42
57
|
startedAt: number;
|
|
43
58
|
parentId?: string;
|
|
44
59
|
sequence: number;
|
|
45
|
-
attributes: Record<string, SpanAttributeValue>;
|
|
60
|
+
attributes: Record<string, SpanAttributeValue | bigint>;
|
|
46
61
|
};
|
|
47
62
|
|
|
48
63
|
type CompletedSpanEntry = {
|
|
@@ -52,14 +67,22 @@ type CompletedSpanEntry = {
|
|
|
52
67
|
exported: boolean;
|
|
53
68
|
};
|
|
54
69
|
|
|
70
|
+
const TRACE_RESOURCE_ATTRIBUTE_SANITIZER = Symbol.for(
|
|
71
|
+
"@apifuse/provider-sdk/runtime/trace-resource-attribute-sanitizer",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
type ResourceAttributesWithSanitizer = Record<string, string> & {
|
|
75
|
+
[TRACE_RESOURCE_ATTRIBUTE_SANITIZER]?: (
|
|
76
|
+
attributes: Record<string, string>,
|
|
77
|
+
) => Record<string, string>;
|
|
78
|
+
};
|
|
79
|
+
|
|
55
80
|
export interface TraceRecorder {
|
|
56
81
|
runSpan<T>(name: string, fn: () => Promise<T> | T, options?: SpanHookOptions<T>): Promise<T>;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
export const TRACE_RECORDER = Symbol.for("@apifuse/provider-sdk/runtime/trace-recorder");
|
|
60
|
-
const TRACE_EXPORT_METADATA = Symbol.for(
|
|
61
|
-
"@apifuse/provider-sdk/runtime/trace-export-metadata",
|
|
62
|
-
);
|
|
85
|
+
const TRACE_EXPORT_METADATA = Symbol.for("@apifuse/provider-sdk/runtime/trace-export-metadata");
|
|
63
86
|
|
|
64
87
|
type TraceExportMetadata = {
|
|
65
88
|
update(input: { traceId?: string; resourceAttributes?: Record<string, string> }): void;
|
|
@@ -109,30 +132,104 @@ export function resolveTraceContextOptions(config?: TraceConfig): CreateTraceCon
|
|
|
109
132
|
};
|
|
110
133
|
}
|
|
111
134
|
|
|
112
|
-
function normalizeAttributeValue(
|
|
113
|
-
|
|
114
|
-
|
|
135
|
+
function normalizeAttributeValue(
|
|
136
|
+
value: unknown,
|
|
137
|
+
redact?: DiagnosticRedactor,
|
|
138
|
+
retainBigints = false,
|
|
139
|
+
): SpanAttributeValue | bigint | undefined {
|
|
140
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
141
|
+
// Typed columns are contracts: unlike string tokens, number/boolean/bigint
|
|
142
|
+
// values only consult the registry at >=8 characters in string form.
|
|
143
|
+
const text = String(value);
|
|
144
|
+
if (text.length < 8) return value;
|
|
145
|
+
const redacted = redactDiagnosticText(text, diagnosticStructuredRedactor(redact));
|
|
146
|
+
return redacted === text ? value : redacted;
|
|
147
|
+
}
|
|
148
|
+
if (typeof value === "bigint") {
|
|
149
|
+
// Bigints are not valid OTLP attribute primitives, so retain their prior
|
|
150
|
+
// string normalization while still checking their diagnostic form.
|
|
151
|
+
const text = String(value);
|
|
152
|
+
if (text.length < 8) return retainBigints ? value : text;
|
|
153
|
+
const redacted = redactDiagnosticText(text, diagnosticStructuredRedactor(redact));
|
|
154
|
+
return retainBigints && redacted === text ? value : redacted;
|
|
115
155
|
}
|
|
116
156
|
|
|
117
157
|
if (value === null || value === undefined) {
|
|
118
158
|
return undefined;
|
|
119
159
|
}
|
|
120
160
|
|
|
121
|
-
return String(value);
|
|
161
|
+
return redactDiagnosticText(String(value), redact);
|
|
122
162
|
}
|
|
123
163
|
|
|
164
|
+
function normalizeAttributes(
|
|
165
|
+
attributes: Record<string, unknown> | undefined,
|
|
166
|
+
redact: DiagnosticRedactor | undefined,
|
|
167
|
+
retainBigints: true,
|
|
168
|
+
): Record<string, SpanAttributeValue | bigint>;
|
|
169
|
+
function normalizeAttributes(
|
|
170
|
+
attributes?: Record<string, unknown>,
|
|
171
|
+
redact?: DiagnosticRedactor,
|
|
172
|
+
): Record<string, SpanAttributeValue>;
|
|
124
173
|
function normalizeAttributes(
|
|
125
174
|
attributes?: Record<string, unknown>,
|
|
126
|
-
|
|
175
|
+
redact?: DiagnosticRedactor,
|
|
176
|
+
retainBigints = false,
|
|
177
|
+
): Record<string, SpanAttributeValue | bigint> {
|
|
127
178
|
if (!attributes) {
|
|
128
179
|
return {};
|
|
129
180
|
}
|
|
130
181
|
|
|
182
|
+
const nextKey = redactedKeyAllocator(Object.keys(attributes));
|
|
131
183
|
const normalizedEntries = Object.entries(attributes)
|
|
132
|
-
.map(([key, value]) =>
|
|
133
|
-
|
|
184
|
+
.map(([key, value]) => {
|
|
185
|
+
const redactedKey = isRedactedKey(key) ? key : redactDiagnosticAttributeKey(key, redact);
|
|
186
|
+
const keyChanged = redactedKey !== key;
|
|
187
|
+
const normalizedKey = keyChanged ? nextKey() : key;
|
|
188
|
+
// Once a key changes, its original sensitivity classification is no
|
|
189
|
+
// longer available to later output sanitizers. Suppress the value here.
|
|
190
|
+
const normalizedValue = keyChanged
|
|
191
|
+
? redactedKey === REDACTION_FAILED
|
|
192
|
+
? REDACTION_FAILED
|
|
193
|
+
: REDACTED_QUERY_VALUE
|
|
194
|
+
: normalizeAttributeValue(value, diagnosticAttributeRedactor(key, redact), retainBigints);
|
|
195
|
+
return [normalizedKey, normalizedValue] as const;
|
|
196
|
+
})
|
|
197
|
+
.filter(
|
|
198
|
+
(entry): entry is readonly [string, SpanAttributeValue | bigint] => entry[1] !== undefined,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return copyDiagnosticAttributeTypes(attributes, Object.fromEntries(normalizedEntries));
|
|
202
|
+
}
|
|
134
203
|
|
|
135
|
-
|
|
204
|
+
function prepareResourceAttributesForExport(
|
|
205
|
+
attributes: Record<string, string> | undefined,
|
|
206
|
+
redact?: DiagnosticRedactor,
|
|
207
|
+
sanitize?: (attributes: Record<string, string>) => Record<string, string>,
|
|
208
|
+
): Record<string, string> | undefined {
|
|
209
|
+
if (!attributes) return undefined;
|
|
210
|
+
if (sanitize) {
|
|
211
|
+
const sanitized = sanitize({ ...attributes });
|
|
212
|
+
if (!sanitized || typeof sanitized !== "object") {
|
|
213
|
+
throw new Error("resource attribute sanitizer returned no attributes");
|
|
214
|
+
}
|
|
215
|
+
return sanitized;
|
|
216
|
+
}
|
|
217
|
+
const nextKey = redactedKeyAllocator(Object.keys(attributes));
|
|
218
|
+
return Object.fromEntries(
|
|
219
|
+
Object.entries(attributes).map(([key, value]) => {
|
|
220
|
+
const redactedKey = isRedactedKey(key) ? key : redactDiagnosticAttributeKey(key, redact);
|
|
221
|
+
const keyChanged = redactedKey !== key;
|
|
222
|
+
const sanitizedKey = keyChanged ? nextKey() : sanitizeSpanNameForOutput(key);
|
|
223
|
+
return [
|
|
224
|
+
sanitizedKey,
|
|
225
|
+
keyChanged
|
|
226
|
+
? redactedKey === REDACTION_FAILED
|
|
227
|
+
? REDACTION_FAILED
|
|
228
|
+
: REDACTED_QUERY_VALUE
|
|
229
|
+
: sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact)),
|
|
230
|
+
];
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
136
233
|
}
|
|
137
234
|
|
|
138
235
|
function insertCompletedSpan(
|
|
@@ -158,7 +255,10 @@ function prepareSpanForExport(
|
|
|
158
255
|
span: Span,
|
|
159
256
|
sanitize: CreateTraceContextOptions["sanitizeSpanForExport"],
|
|
160
257
|
): Span {
|
|
161
|
-
const copy: Span = {
|
|
258
|
+
const copy: Span = {
|
|
259
|
+
...span,
|
|
260
|
+
attributes: copyDiagnosticAttributeTypes(span.attributes, { ...span.attributes }),
|
|
261
|
+
};
|
|
162
262
|
if (!sanitize) return copy;
|
|
163
263
|
const sanitized = sanitize(copy);
|
|
164
264
|
if (!sanitized || typeof sanitized !== "object") {
|
|
@@ -171,6 +271,25 @@ export function getTraceRecorder(trace: BaseTraceContext): TraceRecorder | null
|
|
|
171
271
|
return (trace as Partial<InternalTraceContext>)[TRACE_RECORDER] ?? null;
|
|
172
272
|
}
|
|
173
273
|
|
|
274
|
+
const droppedOTLPSpans = new Map<string, number>();
|
|
275
|
+
|
|
276
|
+
/** Internal, read-only process counters; no reset can re-arm a warning. */
|
|
277
|
+
export function getDroppedOTLPSpanCount(reason: string): number {
|
|
278
|
+
return droppedOTLPSpans.get(reason) ?? 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function warnDroppedOTLPSpans(reason: string, count: number): void {
|
|
282
|
+
const previous = getDroppedOTLPSpanCount(reason);
|
|
283
|
+
const total = previous + count;
|
|
284
|
+
droppedOTLPSpans.set(reason, total);
|
|
285
|
+
if (previous !== 0) return;
|
|
286
|
+
try {
|
|
287
|
+
console.warn(`[apifuse] OTLP export skipped; reason=${reason}; dropped_spans=${total}`);
|
|
288
|
+
} catch {
|
|
289
|
+
// Warning sinks are best-effort. The counter/latch survives a failed sink.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
174
293
|
export function createTraceContext(options: CreateTraceContextOptions = {}): TraceContext {
|
|
175
294
|
if (options.traceId !== undefined) assertValidTraceId(options.traceId);
|
|
176
295
|
const maxSpans = options.maxSpans ?? 1000;
|
|
@@ -180,9 +299,14 @@ export function createTraceContext(options: CreateTraceContextOptions = {}): Tra
|
|
|
180
299
|
// Export configuration (which can carry collector credentials) stays in this closure;
|
|
181
300
|
// the context object handed to provider code never exposes it.
|
|
182
301
|
const exportOptions = options.exportOptions;
|
|
183
|
-
const
|
|
184
|
-
|
|
302
|
+
const resourceAttributeOptions = options.resourceAttributes as
|
|
303
|
+
| ResourceAttributesWithSanitizer
|
|
304
|
+
| undefined;
|
|
305
|
+
const exportResourceAttributes = resourceAttributeOptions
|
|
306
|
+
? { ...resourceAttributeOptions }
|
|
185
307
|
: undefined;
|
|
308
|
+
const exportResourceAttributeSanitizer =
|
|
309
|
+
resourceAttributeOptions?.[TRACE_RESOURCE_ATTRIBUTE_SANITIZER];
|
|
186
310
|
// One trace id per context so every export batch of this request shares it and
|
|
187
311
|
// two processes can never mint the same id.
|
|
188
312
|
let exportTraceId = options.traceId ?? crypto.randomUUID().replace(/-/g, "");
|
|
@@ -203,15 +327,30 @@ export function createTraceContext(options: CreateTraceContextOptions = {}): Tra
|
|
|
203
327
|
if (pending.length === 0) return;
|
|
204
328
|
// Sanitization runs off the request path; a faulty sanitizer drops the batch, never the request.
|
|
205
329
|
let spans: Span[];
|
|
330
|
+
let resourceAttributes: Record<string, string> | undefined;
|
|
206
331
|
try {
|
|
207
332
|
spans = pending.map((entry) =>
|
|
208
333
|
prepareSpanForExport(entry.span, options.sanitizeSpanForExport),
|
|
209
334
|
);
|
|
335
|
+
resourceAttributes = prepareResourceAttributesForExport(
|
|
336
|
+
exportResourceAttributes,
|
|
337
|
+
options.redact,
|
|
338
|
+
exportResourceAttributeSanitizer,
|
|
339
|
+
);
|
|
210
340
|
} catch {
|
|
211
|
-
|
|
341
|
+
try {
|
|
342
|
+
console.warn("[apifuse] OTLP export skipped; span sanitization failed.");
|
|
343
|
+
} catch {
|
|
344
|
+
// A broken diagnostic sink must not escape this detached callback.
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// A matching trace ID cannot be replaced with non-hex text on the wire. Emit less.
|
|
349
|
+
if (redactCriticalDiagnosticText(exportTraceId, options.redact) !== exportTraceId) {
|
|
350
|
+
warnDroppedOTLPSpans("unverifiable_trace_id", pending.length);
|
|
212
351
|
return;
|
|
213
352
|
}
|
|
214
|
-
void exportSpansOTLP(spans, exportOptions,
|
|
353
|
+
void exportSpansOTLP(spans, exportOptions, resourceAttributes, exportTraceId);
|
|
215
354
|
});
|
|
216
355
|
};
|
|
217
356
|
|
|
@@ -219,11 +358,11 @@ export function createTraceContext(options: CreateTraceContextOptions = {}): Tra
|
|
|
219
358
|
async runSpan(name, fn, spanOptions = {}) {
|
|
220
359
|
const pendingSpan: PendingSpan = {
|
|
221
360
|
id: crypto.randomUUID(),
|
|
222
|
-
name,
|
|
361
|
+
name: redactCriticalDiagnosticText(name, options.redact),
|
|
223
362
|
startedAt: Date.now(),
|
|
224
363
|
parentId: activeSpanStorage.getStore()?.id,
|
|
225
364
|
sequence: sequence++,
|
|
226
|
-
attributes: normalizeAttributes(spanOptions.attributes),
|
|
365
|
+
attributes: normalizeAttributes(spanOptions.attributes, options.redact, true),
|
|
227
366
|
};
|
|
228
367
|
|
|
229
368
|
const finalize = (
|
|
@@ -235,7 +374,7 @@ export function createTraceContext(options: CreateTraceContextOptions = {}): Tra
|
|
|
235
374
|
const duration = endedAt - pendingSpan.startedAt;
|
|
236
375
|
const attributes = {
|
|
237
376
|
...pendingSpan.attributes,
|
|
238
|
-
...
|
|
377
|
+
...extraAttributes,
|
|
239
378
|
};
|
|
240
379
|
|
|
241
380
|
if (attributes.duration_ms === undefined) {
|
|
@@ -243,15 +382,18 @@ export function createTraceContext(options: CreateTraceContextOptions = {}): Tra
|
|
|
243
382
|
}
|
|
244
383
|
|
|
245
384
|
const span: Span = {
|
|
246
|
-
id: pendingSpan.id,
|
|
247
|
-
|
|
385
|
+
id: redactCriticalDiagnosticText(pendingSpan.id, options.redact),
|
|
386
|
+
// Recheck at recording: harvest and live env reads may follow span start.
|
|
387
|
+
name: redactCriticalDiagnosticText(pendingSpan.name, options.redact),
|
|
248
388
|
startedAt: pendingSpan.startedAt,
|
|
249
389
|
endedAt,
|
|
250
390
|
duration_ms: duration,
|
|
251
391
|
status,
|
|
252
|
-
attributes,
|
|
253
|
-
...(error ? { error } : {}),
|
|
254
|
-
...(pendingSpan.parentId
|
|
392
|
+
attributes: normalizeAttributes(attributes, options.redact),
|
|
393
|
+
...(error ? { error: redactDiagnosticText(error, options.redact) } : {}),
|
|
394
|
+
...(pendingSpan.parentId
|
|
395
|
+
? { parentId: redactCriticalDiagnosticText(pendingSpan.parentId, options.redact) }
|
|
396
|
+
: {}),
|
|
255
397
|
};
|
|
256
398
|
|
|
257
399
|
insertCompletedSpan(
|