@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerDiagnosticSensitiveValues } from "./diagnostic-redactor.js";
|
|
1
2
|
import { readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
2
3
|
import { parseHttpRequestInvocation, isSensitiveKey, redactSensitiveError, redactSensitiveText, redactUrlQueryParams, requestOptionsFromHttpInvocation, serializeRequestUrl, } from "./request-options.js";
|
|
3
4
|
import { createTraceContext, getTraceRecorder, } from "./trace.js";
|
|
@@ -381,6 +382,7 @@ function wrapStealthRedirects(redirects, trace) {
|
|
|
381
382
|
run(...args) {
|
|
382
383
|
const diagnosticArgs = [args[0].url, args[0]];
|
|
383
384
|
const diagnostics = snapshotRequestDiagnostics("stealth", "fetch", diagnosticArgs);
|
|
385
|
+
registerDiagnosticSensitiveValues(trace, diagnostics.sensitiveValues);
|
|
384
386
|
let result;
|
|
385
387
|
try {
|
|
386
388
|
result = redirects.run(...args);
|
|
@@ -511,6 +513,7 @@ function wrapNamespace(namespace, target, trace, shouldInstrument) {
|
|
|
511
513
|
return Reflect.apply(value, namespaceTarget, args);
|
|
512
514
|
}
|
|
513
515
|
const requestDiagnostics = snapshotRequestDiagnostics(namespace, methodName, args);
|
|
516
|
+
registerDiagnosticSensitiveValues(trace, requestDiagnostics.sensitiveValues);
|
|
514
517
|
// Invoke first and decide by the RETURN VALUE. `runSpan` always
|
|
515
518
|
// returns a Promise, so unconditionally span-wrapping every member
|
|
516
519
|
// silently rewrote synchronous contracts: `ctx.state.namespace()`
|
package/dist/runtime/ocr.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { ProviderError, TransportError } from "../errors.js";
|
|
2
3
|
import { CLOUDFLARE_ACCOUNT_ID_ENV } from "./stt.js";
|
|
3
4
|
import { createTimeoutController, isTimeoutLikeError } from "./timeout.js";
|
|
@@ -55,7 +56,7 @@ export function createUnsupportedOcrClient(reason) {
|
|
|
55
56
|
});
|
|
56
57
|
}
|
|
57
58
|
function normalizedEnvValue(env, key) {
|
|
58
|
-
const value = env
|
|
59
|
+
const value = readDiagnosticEnv(key, env)?.trim();
|
|
59
60
|
return value ? value : undefined;
|
|
60
61
|
}
|
|
61
62
|
export function createOcrClientFromEnv(config, env = process.env) {
|
package/dist/runtime/otlp.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { AsyncResource } from "node:async_hooks";
|
|
2
3
|
export const OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT";
|
|
3
4
|
export const OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT";
|
|
@@ -118,12 +119,12 @@ export function resolveOTLPExportOptions(explicit, env = process.env) {
|
|
|
118
119
|
{ source: EXPLICIT_ENDPOINT_SOURCE, value: explicit.endpoint, appendPath: false },
|
|
119
120
|
{
|
|
120
121
|
source: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
|
|
121
|
-
value:
|
|
122
|
+
value: readDiagnosticEnv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env),
|
|
122
123
|
appendPath: false,
|
|
123
124
|
},
|
|
124
125
|
{
|
|
125
126
|
source: OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
126
|
-
value:
|
|
127
|
+
value: readDiagnosticEnv(OTEL_EXPORTER_OTLP_ENDPOINT, env),
|
|
127
128
|
appendPath: true,
|
|
128
129
|
},
|
|
129
130
|
];
|
|
@@ -137,9 +138,9 @@ export function resolveOTLPExportOptions(explicit, env = process.env) {
|
|
|
137
138
|
return { status: "invalid", source: candidate.source, reason: parsed.reason };
|
|
138
139
|
}
|
|
139
140
|
const endpoint = candidate.appendPath ? appendTracesPath(parsed.url) : candidate.value;
|
|
140
|
-
const tracesHeaders = presentValue(env
|
|
141
|
+
const tracesHeaders = presentValue(readDiagnosticEnv(OTEL_EXPORTER_OTLP_TRACES_HEADERS, env));
|
|
141
142
|
const headersSource = tracesHeaders !== undefined ? OTEL_EXPORTER_OTLP_TRACES_HEADERS : OTEL_EXPORTER_OTLP_HEADERS;
|
|
142
|
-
const envHeaders = parseHeaderList(tracesHeaders ?? presentValue(env
|
|
143
|
+
const envHeaders = parseHeaderList(tracesHeaders ?? presentValue(readDiagnosticEnv(OTEL_EXPORTER_OTLP_HEADERS, env)));
|
|
143
144
|
if (!headersAreSendable(envHeaders)) {
|
|
144
145
|
return { status: "invalid", source: headersSource, reason: INVALID_HEADERS };
|
|
145
146
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
export const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
3
4
|
export const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
@@ -25,10 +26,10 @@ export function hasNodemavenCredentials() {
|
|
|
25
26
|
return Boolean(readNodemavenUsername() && readNodemavenPassword());
|
|
26
27
|
}
|
|
27
28
|
function readNodemavenUsername() {
|
|
28
|
-
return
|
|
29
|
+
return readDiagnosticEnv(NODEMAVEN_USERNAME_ENV)?.trim() || undefined;
|
|
29
30
|
}
|
|
30
31
|
function readNodemavenPassword() {
|
|
31
|
-
return
|
|
32
|
+
return readDiagnosticEnv(NODEMAVEN_PASSWORD_ENV)?.trim() || undefined;
|
|
32
33
|
}
|
|
33
34
|
function resolveNodemavenFilter(value) {
|
|
34
35
|
const raw = value?.trim().toLowerCase();
|
|
@@ -51,6 +51,13 @@ export declare function redactUrlQueryParams(url: string, sensitiveParamNames: r
|
|
|
51
51
|
* separate, structurally redacted URL for diagnostics and observability.
|
|
52
52
|
*/
|
|
53
53
|
export declare function serializeRequestUrl(url: string, params?: RequestParams, sensitiveParams?: Record<string, string>): SerializedRequestUrl;
|
|
54
|
+
export declare function normalizePercentEscapes(value: string): string;
|
|
55
|
+
export type SensitiveValueVariant = {
|
|
56
|
+
value: string;
|
|
57
|
+
requiresTokenBoundary: boolean;
|
|
58
|
+
caseInsensitive: boolean;
|
|
59
|
+
};
|
|
60
|
+
export declare function allSensitiveValueVariants(sensitiveValues: readonly string[]): SensitiveValueVariant[];
|
|
54
61
|
/** Scrubs raw and URL-encoded secret values from diagnostic text. */
|
|
55
62
|
export declare function redactSensitiveText(text: string, sensitiveValues: readonly string[], requestUrl?: string, redactedUrl?: string): string;
|
|
56
63
|
/**
|
|
@@ -108,13 +108,33 @@ export function serializeRequestUrl(url, params, sensitiveParams) {
|
|
|
108
108
|
sensitiveValues: [...sensitiveValues],
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
|
-
const MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH =
|
|
112
|
-
function normalizePercentEscapes(value) {
|
|
111
|
+
const MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH = 8;
|
|
112
|
+
export function normalizePercentEscapes(value) {
|
|
113
113
|
return value.replace(/%[\da-f]{2}/gi, (percentEscape) => percentEscape.toUpperCase());
|
|
114
114
|
}
|
|
115
115
|
function sensitiveValueVariants(value) {
|
|
116
116
|
if (!value || value === REDACTED_QUERY_VALUE)
|
|
117
117
|
return [];
|
|
118
|
+
// Owner-visible policy: short credentials are literal, case-sensitive tokens only.
|
|
119
|
+
// Encoded short needles collide with benign cursors and ordinary diagnostic text.
|
|
120
|
+
if (value.length < 8)
|
|
121
|
+
return [value];
|
|
122
|
+
// These ASCII characters are identical under NFC, JSON, URI-component and
|
|
123
|
+
// form encoding. Avoid constructing those identical variants for common tokens.
|
|
124
|
+
if (/^[A-Za-z0-9._*-]+$/.test(value)) {
|
|
125
|
+
const base64 = Buffer.from(value, "utf8").toString("base64");
|
|
126
|
+
const base64Url = base64.replaceAll("+", "-").replaceAll("/", "_");
|
|
127
|
+
return [
|
|
128
|
+
...new Set([
|
|
129
|
+
value,
|
|
130
|
+
base64,
|
|
131
|
+
base64.replace(/=+$/, ""),
|
|
132
|
+
base64Url,
|
|
133
|
+
base64Url.replace(/=+$/, ""),
|
|
134
|
+
]),
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
const normalized = value.normalize("NFC");
|
|
118
138
|
const formEncoded = new URLSearchParams({ value }).toString().slice("value=".length);
|
|
119
139
|
let componentEncoded;
|
|
120
140
|
try {
|
|
@@ -125,12 +145,31 @@ function sensitiveValueVariants(value) {
|
|
|
125
145
|
// still safe to scrub, and URLSearchParams supplies its well-formed form.
|
|
126
146
|
componentEncoded = value;
|
|
127
147
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
const base64 = Buffer.from(value, "utf8").toString("base64");
|
|
149
|
+
const base64Url = base64.replaceAll("+", "-").replaceAll("/", "_");
|
|
150
|
+
const jsonEscaped = JSON.stringify(value).slice(1, -1);
|
|
151
|
+
return [
|
|
152
|
+
...new Set([
|
|
153
|
+
value,
|
|
154
|
+
normalized,
|
|
155
|
+
componentEncoded,
|
|
156
|
+
formEncoded,
|
|
157
|
+
jsonEscaped,
|
|
158
|
+
base64,
|
|
159
|
+
base64.replace(/=+$/, ""),
|
|
160
|
+
base64Url,
|
|
161
|
+
base64Url.replace(/=+$/, ""),
|
|
162
|
+
].map(normalizePercentEscapes)),
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
export function allSensitiveValueVariants(sensitiveValues) {
|
|
131
166
|
const variants = new Map();
|
|
132
167
|
for (const sensitiveValue of sensitiveValues) {
|
|
133
168
|
const requiresTokenBoundary = sensitiveValue.length < MIN_UNSCOPED_SENSITIVE_VALUE_LENGTH;
|
|
169
|
+
// Long values are unlikely to be ordinary prose, so case folding is worth
|
|
170
|
+
// the extra over-redaction protection. Values of 4-7 characters remain
|
|
171
|
+
// case-sensitive because they commonly occur as words and identifiers.
|
|
172
|
+
const caseInsensitive = sensitiveValue.length >= 8;
|
|
134
173
|
for (const value of sensitiveValueVariants(sensitiveValue)) {
|
|
135
174
|
const existing = variants.get(value);
|
|
136
175
|
variants.set(value, {
|
|
@@ -138,6 +177,7 @@ function allSensitiveValueVariants(sensitiveValues) {
|
|
|
138
177
|
requiresTokenBoundary: existing
|
|
139
178
|
? existing.requiresTokenBoundary && requiresTokenBoundary
|
|
140
179
|
: requiresTokenBoundary,
|
|
180
|
+
caseInsensitive: existing ? existing.caseInsensitive || caseInsensitive : caseInsensitive,
|
|
141
181
|
});
|
|
142
182
|
}
|
|
143
183
|
}
|
|
@@ -147,24 +187,24 @@ function isTokenCharacter(value) {
|
|
|
147
187
|
return value !== undefined && /[\p{L}\p{N}]/u.test(value);
|
|
148
188
|
}
|
|
149
189
|
function replaceSensitiveVariant(text, variant) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
return `\\${match}`;
|
|
159
|
-
});
|
|
160
|
-
return text.replace(new RegExp(pattern, "gu"), (match, offset, source) => {
|
|
161
|
-
const end = offset + match.length;
|
|
190
|
+
const normalized = normalizePercentEscapes(text);
|
|
191
|
+
const comparable = variant.caseInsensitive ? normalized.toLowerCase() : text;
|
|
192
|
+
const needle = variant.caseInsensitive ? variant.value.toLowerCase() : variant.value;
|
|
193
|
+
let cursor = 0;
|
|
194
|
+
let output = "";
|
|
195
|
+
for (let index = comparable.indexOf(needle); index !== -1; index = comparable.indexOf(needle, index + 1)) {
|
|
196
|
+
const end = index + needle.length;
|
|
162
197
|
if (variant.requiresTokenBoundary &&
|
|
163
|
-
(isTokenCharacter(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
198
|
+
(isTokenCharacter(comparable[index - 1]) || isTokenCharacter(comparable[end])))
|
|
199
|
+
continue;
|
|
200
|
+
// Unicode folding can change offsets; suppress the field instead of slicing a secret.
|
|
201
|
+
if (comparable.length !== text.length)
|
|
202
|
+
return REDACTED_QUERY_VALUE;
|
|
203
|
+
output += text.slice(cursor, index) + REDACTED_QUERY_VALUE;
|
|
204
|
+
cursor = end;
|
|
205
|
+
index = end - 1;
|
|
206
|
+
}
|
|
207
|
+
return cursor ? output + text.slice(cursor) : text;
|
|
168
208
|
}
|
|
169
209
|
/** Scrubs raw and URL-encoded secret values from diagnostic text. */
|
|
170
210
|
export function redactSensitiveText(text, sensitiveValues, requestUrl, redactedUrl) {
|
|
@@ -111,7 +111,6 @@ async function runBoundedCleanup(cleanup, timeoutMs, challengeKind, operation, t
|
|
|
111
111
|
onError(error) {
|
|
112
112
|
return {
|
|
113
113
|
error_message: error instanceof Error ? error.message : String(error),
|
|
114
|
-
...(error instanceof Error && error.stack ? { error_stack: error.stack } : {}),
|
|
115
114
|
};
|
|
116
115
|
},
|
|
117
116
|
})
|
package/dist/runtime/resolver.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { resolveProxyConfigAsync, } from "../config/loader.js";
|
|
3
4
|
import { ProviderError } from "../errors.js";
|
|
@@ -130,11 +131,11 @@ const KNOWN_UNIMPLEMENTED_RESOLVER_VENDORS = new Set([
|
|
|
130
131
|
"custom",
|
|
131
132
|
]);
|
|
132
133
|
function normalizedEnvValue(env, key) {
|
|
133
|
-
const value = env
|
|
134
|
+
const value = readDiagnosticEnv(key, env)?.trim();
|
|
134
135
|
return value ? value : undefined;
|
|
135
136
|
}
|
|
136
137
|
function readPositiveIntegerEnv(env, name) {
|
|
137
|
-
const raw = env
|
|
138
|
+
const raw = readDiagnosticEnv(name, env)?.trim();
|
|
138
139
|
if (!raw)
|
|
139
140
|
return undefined;
|
|
140
141
|
if (!/^[1-9]\d*$/.test(raw)) {
|
package/dist/runtime/stt.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDiagnosticEnv } from "./diagnostic-env.js";
|
|
1
2
|
import { ProviderError, TransportError, ValidationError } from "../errors.js";
|
|
2
3
|
import { createTimeoutController, isTimeoutLikeError } from "./timeout.js";
|
|
3
4
|
export const APIFUSE__STT__BACKEND_ENV = "APIFUSE__STT__BACKEND";
|
|
@@ -32,7 +33,7 @@ export function createUnsupportedSttClient(reason) {
|
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
function normalizedEnvValue(env, key) {
|
|
35
|
-
const value = env
|
|
36
|
+
const value = readDiagnosticEnv(key, env)?.trim();
|
|
36
37
|
return value ? value : undefined;
|
|
37
38
|
}
|
|
38
39
|
export function createSttClientFromEnv(config, env = process.env) {
|
|
@@ -53,7 +54,7 @@ export function createSttClientFromEnv(config, env = process.env) {
|
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
const accountId = env.APIFUSE__CLOUDFLARE__ACCOUNT_ID?.trim();
|
|
56
|
-
const apiToken = env
|
|
57
|
+
const apiToken = readDiagnosticEnv(APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV, env)?.trim();
|
|
57
58
|
if (!accountId || !apiToken) {
|
|
58
59
|
return createUnsupportedSttClient(`STT backend ${backend} requires ${CLOUDFLARE_ACCOUNT_ID_ENV} and ${APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV}`);
|
|
59
60
|
}
|
package/dist/runtime/trace.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TraceContext as BaseTraceContext, TraceAttributeValue, TraceConfig, TraceSpan } from "../types.js";
|
|
2
|
+
import { type DiagnosticRedactor } from "./diagnostic-redactor.js";
|
|
2
3
|
import { type OTLPExportOptions } from "./otlp.js";
|
|
3
4
|
export type SpanAttributeValue = TraceAttributeValue;
|
|
4
5
|
export type Span = TraceSpan;
|
|
@@ -8,6 +9,8 @@ export interface TraceContext extends BaseTraceContext {
|
|
|
8
9
|
export interface CreateTraceContextOptions {
|
|
9
10
|
maxSpans?: number;
|
|
10
11
|
onSpan?: (span: Span) => void;
|
|
12
|
+
/** Redacts diagnostic attribute values and errors before recording or calling onSpan. */
|
|
13
|
+
redact?: DiagnosticRedactor;
|
|
11
14
|
exportOptions?: OTLPExportOptions;
|
|
12
15
|
resourceAttributes?: Record<string, string>;
|
|
13
16
|
/** W3C-compatible 32-character lowercase hexadecimal trace id used for export. */
|
|
@@ -34,5 +37,7 @@ export declare function updateTraceContextExportMetadata(trace: BaseTraceContext
|
|
|
34
37
|
}): void;
|
|
35
38
|
export declare function resolveTraceContextOptions(config?: TraceConfig): CreateTraceContextOptions;
|
|
36
39
|
export declare function getTraceRecorder(trace: BaseTraceContext): TraceRecorder | null;
|
|
40
|
+
/** Internal, read-only process counters; no reset can re-arm a warning. */
|
|
41
|
+
export declare function getDroppedOTLPSpanCount(reason: string): number;
|
|
37
42
|
export declare function createTraceContext(options?: CreateTraceContextOptions): TraceContext;
|
|
38
43
|
export {};
|
package/dist/runtime/trace.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { sanitizeSpanNameForOutput } from "../trace-sanitization.js";
|
|
3
|
+
import { diagnosticStructuredRedactor, redactDiagnosticAttributeKey, copyDiagnosticAttributeTypes, redactedKeyAllocator, isRedactedKey, redactCriticalDiagnosticText, diagnosticAttributeRedactor, REDACTION_FAILED, redactDiagnosticText, } from "./diagnostic-redactor.js";
|
|
2
4
|
import { exportSpansOTLP } from "./otlp.js";
|
|
5
|
+
import { REDACTED_QUERY_VALUE } from "./request-options.js";
|
|
6
|
+
const TRACE_RESOURCE_ATTRIBUTE_SANITIZER = Symbol.for("@apifuse/provider-sdk/runtime/trace-resource-attribute-sanitizer");
|
|
3
7
|
export const TRACE_RECORDER = Symbol.for("@apifuse/provider-sdk/runtime/trace-recorder");
|
|
4
8
|
const TRACE_EXPORT_METADATA = Symbol.for("@apifuse/provider-sdk/runtime/trace-export-metadata");
|
|
5
9
|
function assertValidTraceId(traceId) {
|
|
@@ -32,23 +36,76 @@ export function resolveTraceContextOptions(config) {
|
|
|
32
36
|
exportOptions: buildOTLPExportOptions(config),
|
|
33
37
|
};
|
|
34
38
|
}
|
|
35
|
-
function normalizeAttributeValue(value) {
|
|
36
|
-
if (typeof value === "
|
|
37
|
-
|
|
39
|
+
function normalizeAttributeValue(value, redact, retainBigints = false) {
|
|
40
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
41
|
+
// Typed columns are contracts: unlike string tokens, number/boolean/bigint
|
|
42
|
+
// values only consult the registry at >=8 characters in string form.
|
|
43
|
+
const text = String(value);
|
|
44
|
+
if (text.length < 8)
|
|
45
|
+
return value;
|
|
46
|
+
const redacted = redactDiagnosticText(text, diagnosticStructuredRedactor(redact));
|
|
47
|
+
return redacted === text ? value : redacted;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value === "bigint") {
|
|
50
|
+
// Bigints are not valid OTLP attribute primitives, so retain their prior
|
|
51
|
+
// string normalization while still checking their diagnostic form.
|
|
52
|
+
const text = String(value);
|
|
53
|
+
if (text.length < 8)
|
|
54
|
+
return retainBigints ? value : text;
|
|
55
|
+
const redacted = redactDiagnosticText(text, diagnosticStructuredRedactor(redact));
|
|
56
|
+
return retainBigints && redacted === text ? value : redacted;
|
|
38
57
|
}
|
|
39
58
|
if (value === null || value === undefined) {
|
|
40
59
|
return undefined;
|
|
41
60
|
}
|
|
42
|
-
return String(value);
|
|
61
|
+
return redactDiagnosticText(String(value), redact);
|
|
43
62
|
}
|
|
44
|
-
function normalizeAttributes(attributes) {
|
|
63
|
+
function normalizeAttributes(attributes, redact, retainBigints = false) {
|
|
45
64
|
if (!attributes) {
|
|
46
65
|
return {};
|
|
47
66
|
}
|
|
67
|
+
const nextKey = redactedKeyAllocator(Object.keys(attributes));
|
|
48
68
|
const normalizedEntries = Object.entries(attributes)
|
|
49
|
-
.map(([key, value]) =>
|
|
69
|
+
.map(([key, value]) => {
|
|
70
|
+
const redactedKey = isRedactedKey(key) ? key : redactDiagnosticAttributeKey(key, redact);
|
|
71
|
+
const keyChanged = redactedKey !== key;
|
|
72
|
+
const normalizedKey = keyChanged ? nextKey() : key;
|
|
73
|
+
// Once a key changes, its original sensitivity classification is no
|
|
74
|
+
// longer available to later output sanitizers. Suppress the value here.
|
|
75
|
+
const normalizedValue = keyChanged
|
|
76
|
+
? redactedKey === REDACTION_FAILED
|
|
77
|
+
? REDACTION_FAILED
|
|
78
|
+
: REDACTED_QUERY_VALUE
|
|
79
|
+
: normalizeAttributeValue(value, diagnosticAttributeRedactor(key, redact), retainBigints);
|
|
80
|
+
return [normalizedKey, normalizedValue];
|
|
81
|
+
})
|
|
50
82
|
.filter((entry) => entry[1] !== undefined);
|
|
51
|
-
return Object.fromEntries(normalizedEntries);
|
|
83
|
+
return copyDiagnosticAttributeTypes(attributes, Object.fromEntries(normalizedEntries));
|
|
84
|
+
}
|
|
85
|
+
function prepareResourceAttributesForExport(attributes, redact, sanitize) {
|
|
86
|
+
if (!attributes)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (sanitize) {
|
|
89
|
+
const sanitized = sanitize({ ...attributes });
|
|
90
|
+
if (!sanitized || typeof sanitized !== "object") {
|
|
91
|
+
throw new Error("resource attribute sanitizer returned no attributes");
|
|
92
|
+
}
|
|
93
|
+
return sanitized;
|
|
94
|
+
}
|
|
95
|
+
const nextKey = redactedKeyAllocator(Object.keys(attributes));
|
|
96
|
+
return Object.fromEntries(Object.entries(attributes).map(([key, value]) => {
|
|
97
|
+
const redactedKey = isRedactedKey(key) ? key : redactDiagnosticAttributeKey(key, redact);
|
|
98
|
+
const keyChanged = redactedKey !== key;
|
|
99
|
+
const sanitizedKey = keyChanged ? nextKey() : sanitizeSpanNameForOutput(key);
|
|
100
|
+
return [
|
|
101
|
+
sanitizedKey,
|
|
102
|
+
keyChanged
|
|
103
|
+
? redactedKey === REDACTION_FAILED
|
|
104
|
+
? REDACTION_FAILED
|
|
105
|
+
: REDACTED_QUERY_VALUE
|
|
106
|
+
: sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact)),
|
|
107
|
+
];
|
|
108
|
+
}));
|
|
52
109
|
}
|
|
53
110
|
function insertCompletedSpan(completed, entry, maxSpans) {
|
|
54
111
|
const insertAt = completed.findIndex((existingEntry) => existingEntry.sequence > entry.sequence);
|
|
@@ -64,7 +121,10 @@ function insertCompletedSpan(completed, entry, maxSpans) {
|
|
|
64
121
|
}
|
|
65
122
|
/** Hands the hook a copy whose attributes are detached from the recorded span; a hook that returns nothing fails closed. */
|
|
66
123
|
function prepareSpanForExport(span, sanitize) {
|
|
67
|
-
const copy = {
|
|
124
|
+
const copy = {
|
|
125
|
+
...span,
|
|
126
|
+
attributes: copyDiagnosticAttributeTypes(span.attributes, { ...span.attributes }),
|
|
127
|
+
};
|
|
68
128
|
if (!sanitize)
|
|
69
129
|
return copy;
|
|
70
130
|
const sanitized = sanitize(copy);
|
|
@@ -76,6 +136,24 @@ function prepareSpanForExport(span, sanitize) {
|
|
|
76
136
|
export function getTraceRecorder(trace) {
|
|
77
137
|
return trace[TRACE_RECORDER] ?? null;
|
|
78
138
|
}
|
|
139
|
+
const droppedOTLPSpans = new Map();
|
|
140
|
+
/** Internal, read-only process counters; no reset can re-arm a warning. */
|
|
141
|
+
export function getDroppedOTLPSpanCount(reason) {
|
|
142
|
+
return droppedOTLPSpans.get(reason) ?? 0;
|
|
143
|
+
}
|
|
144
|
+
function warnDroppedOTLPSpans(reason, count) {
|
|
145
|
+
const previous = getDroppedOTLPSpanCount(reason);
|
|
146
|
+
const total = previous + count;
|
|
147
|
+
droppedOTLPSpans.set(reason, total);
|
|
148
|
+
if (previous !== 0)
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
console.warn(`[apifuse] OTLP export skipped; reason=${reason}; dropped_spans=${total}`);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Warning sinks are best-effort. The counter/latch survives a failed sink.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
79
157
|
export function createTraceContext(options = {}) {
|
|
80
158
|
if (options.traceId !== undefined)
|
|
81
159
|
assertValidTraceId(options.traceId);
|
|
@@ -86,9 +164,11 @@ export function createTraceContext(options = {}) {
|
|
|
86
164
|
// Export configuration (which can carry collector credentials) stays in this closure;
|
|
87
165
|
// the context object handed to provider code never exposes it.
|
|
88
166
|
const exportOptions = options.exportOptions;
|
|
89
|
-
const
|
|
90
|
-
|
|
167
|
+
const resourceAttributeOptions = options.resourceAttributes;
|
|
168
|
+
const exportResourceAttributes = resourceAttributeOptions
|
|
169
|
+
? { ...resourceAttributeOptions }
|
|
91
170
|
: undefined;
|
|
171
|
+
const exportResourceAttributeSanitizer = resourceAttributeOptions?.[TRACE_RESOURCE_ATTRIBUTE_SANITIZER];
|
|
92
172
|
// One trace id per context so every export batch of this request shares it and
|
|
93
173
|
// two processes can never mint the same id.
|
|
94
174
|
let exportTraceId = options.traceId ?? crypto.randomUUID().replace(/-/g, "");
|
|
@@ -109,46 +189,61 @@ export function createTraceContext(options = {}) {
|
|
|
109
189
|
return;
|
|
110
190
|
// Sanitization runs off the request path; a faulty sanitizer drops the batch, never the request.
|
|
111
191
|
let spans;
|
|
192
|
+
let resourceAttributes;
|
|
112
193
|
try {
|
|
113
194
|
spans = pending.map((entry) => prepareSpanForExport(entry.span, options.sanitizeSpanForExport));
|
|
195
|
+
resourceAttributes = prepareResourceAttributesForExport(exportResourceAttributes, options.redact, exportResourceAttributeSanitizer);
|
|
114
196
|
}
|
|
115
197
|
catch {
|
|
116
|
-
|
|
198
|
+
try {
|
|
199
|
+
console.warn("[apifuse] OTLP export skipped; span sanitization failed.");
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// A broken diagnostic sink must not escape this detached callback.
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// A matching trace ID cannot be replaced with non-hex text on the wire. Emit less.
|
|
207
|
+
if (redactCriticalDiagnosticText(exportTraceId, options.redact) !== exportTraceId) {
|
|
208
|
+
warnDroppedOTLPSpans("unverifiable_trace_id", pending.length);
|
|
117
209
|
return;
|
|
118
210
|
}
|
|
119
|
-
void exportSpansOTLP(spans, exportOptions,
|
|
211
|
+
void exportSpansOTLP(spans, exportOptions, resourceAttributes, exportTraceId);
|
|
120
212
|
});
|
|
121
213
|
};
|
|
122
214
|
const recorder = {
|
|
123
215
|
async runSpan(name, fn, spanOptions = {}) {
|
|
124
216
|
const pendingSpan = {
|
|
125
217
|
id: crypto.randomUUID(),
|
|
126
|
-
name,
|
|
218
|
+
name: redactCriticalDiagnosticText(name, options.redact),
|
|
127
219
|
startedAt: Date.now(),
|
|
128
220
|
parentId: activeSpanStorage.getStore()?.id,
|
|
129
221
|
sequence: sequence++,
|
|
130
|
-
attributes: normalizeAttributes(spanOptions.attributes),
|
|
222
|
+
attributes: normalizeAttributes(spanOptions.attributes, options.redact, true),
|
|
131
223
|
};
|
|
132
224
|
const finalize = (status, extraAttributes, error) => {
|
|
133
225
|
const endedAt = Date.now();
|
|
134
226
|
const duration = endedAt - pendingSpan.startedAt;
|
|
135
227
|
const attributes = {
|
|
136
228
|
...pendingSpan.attributes,
|
|
137
|
-
...
|
|
229
|
+
...extraAttributes,
|
|
138
230
|
};
|
|
139
231
|
if (attributes.duration_ms === undefined) {
|
|
140
232
|
attributes.duration_ms = duration;
|
|
141
233
|
}
|
|
142
234
|
const span = {
|
|
143
|
-
id: pendingSpan.id,
|
|
144
|
-
|
|
235
|
+
id: redactCriticalDiagnosticText(pendingSpan.id, options.redact),
|
|
236
|
+
// Recheck at recording: harvest and live env reads may follow span start.
|
|
237
|
+
name: redactCriticalDiagnosticText(pendingSpan.name, options.redact),
|
|
145
238
|
startedAt: pendingSpan.startedAt,
|
|
146
239
|
endedAt,
|
|
147
240
|
duration_ms: duration,
|
|
148
241
|
status,
|
|
149
|
-
attributes,
|
|
150
|
-
...(error ? { error } : {}),
|
|
151
|
-
...(pendingSpan.parentId
|
|
242
|
+
attributes: normalizeAttributes(attributes, options.redact),
|
|
243
|
+
...(error ? { error: redactDiagnosticText(error, options.redact) } : {}),
|
|
244
|
+
...(pendingSpan.parentId
|
|
245
|
+
? { parentId: redactCriticalDiagnosticText(pendingSpan.parentId, options.redact) }
|
|
246
|
+
: {}),
|
|
152
247
|
};
|
|
153
248
|
insertCompletedSpan(completed, { sequence: pendingSpan.sequence, span, exported: false }, maxSpans);
|
|
154
249
|
options.onSpan?.(span);
|
package/dist/schema.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type SensitiveFieldKind = "api_key" | "authorization" | "cookie" | "crede
|
|
|
22
22
|
export interface SensitiveFieldOptions {
|
|
23
23
|
/**
|
|
24
24
|
* Mark this schema as sensitive. Defaults to true for the helper presets.
|
|
25
|
+
* An explicit `false` emits `x-apifuse-sensitive: false` (reviewed public
|
|
26
|
+
* data, never redacted) and drops any sensitivity kind.
|
|
25
27
|
*/
|
|
26
28
|
sensitive?: boolean;
|
|
27
29
|
/**
|
|
@@ -48,6 +50,15 @@ declare module "zod" {
|
|
|
48
50
|
}
|
|
49
51
|
export declare function field<TSchema extends ZodType>(schema: TSchema, options?: SensitiveFieldOptions): TSchema;
|
|
50
52
|
export declare function sensitive<TSchema extends ZodType>(schema: TSchema, kind?: SensitiveFieldKind): TSchema;
|
|
53
|
+
/**
|
|
54
|
+
* Declare a field as reviewed public data (business, facility, product, or
|
|
55
|
+
* place names and contacts) so a key-based privacy classifier hit counts as
|
|
56
|
+
* declared instead of undeclared. Emits `x-apifuse-sensitive: false` on the
|
|
57
|
+
* JSON Schema leaf; nothing is redacted. Use `sensitive()` for anything that
|
|
58
|
+
* identifies a person. Apply it to the leaf, before `.optional()` or
|
|
59
|
+
* `.nullable()`; wrapping a `sensitive()` declaration throws at definition time.
|
|
60
|
+
*/
|
|
61
|
+
export declare function publicField<TSchema extends ZodType>(schema: TSchema): TSchema;
|
|
51
62
|
export declare const fields: {
|
|
52
63
|
readonly apiKey: (options?: {
|
|
53
64
|
description?: string;
|
package/dist/schema.js
CHANGED
|
@@ -113,15 +113,41 @@ export function field(schema, options = {}) {
|
|
|
113
113
|
? schema.describe(options.description)
|
|
114
114
|
: schema;
|
|
115
115
|
const metadata = described.meta() ?? {};
|
|
116
|
+
const isSensitive = options.sensitive ?? true;
|
|
117
|
+
// A public declaration only rewrites the meta of the node it receives;
|
|
118
|
+
// z.toJSONSchema folds that over the inner leaf, so `false` on a wrapper
|
|
119
|
+
// around a sensitive() leaf would publish `false` next to the leaf's kind
|
|
120
|
+
// while collectSensitivePaths() still redacts. Refuse the contradiction.
|
|
121
|
+
if (!isSensitive && wrapsSensitiveDeclaration(described)) {
|
|
122
|
+
throw new TypeError("publicField()/field({ sensitive: false }) wraps a sensitive() declaration; apply it to the leaf, before .optional(), .nullable(), .default(), .pipe(), z.lazy() or similar wrappers.");
|
|
123
|
+
}
|
|
124
|
+
// Zod merges meta with the parent chain, so an inherited kind can only be
|
|
125
|
+
// cleared by writing undefined over it (dropped from JSON Schema output).
|
|
126
|
+
const kind = isSensitive
|
|
127
|
+
? (options.kind ?? Reflect.get(metadata, APIFUSE_SENSITIVE_KIND_META_KEY))
|
|
128
|
+
: undefined;
|
|
116
129
|
return described.meta({
|
|
117
130
|
...metadata,
|
|
118
|
-
|
|
119
|
-
...(
|
|
131
|
+
[APIFUSE_SENSITIVE_META_KEY]: isSensitive,
|
|
132
|
+
...(kind !== undefined || APIFUSE_SENSITIVE_KIND_META_KEY in metadata
|
|
133
|
+
? { [APIFUSE_SENSITIVE_KIND_META_KEY]: kind }
|
|
134
|
+
: {}),
|
|
120
135
|
});
|
|
121
136
|
}
|
|
122
137
|
export function sensitive(schema, kind) {
|
|
123
138
|
return field(schema, { sensitive: true, kind });
|
|
124
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Declare a field as reviewed public data (business, facility, product, or
|
|
142
|
+
* place names and contacts) so a key-based privacy classifier hit counts as
|
|
143
|
+
* declared instead of undeclared. Emits `x-apifuse-sensitive: false` on the
|
|
144
|
+
* JSON Schema leaf; nothing is redacted. Use `sensitive()` for anything that
|
|
145
|
+
* identifies a person. Apply it to the leaf, before `.optional()` or
|
|
146
|
+
* `.nullable()`; wrapping a `sensitive()` declaration throws at definition time.
|
|
147
|
+
*/
|
|
148
|
+
export function publicField(schema) {
|
|
149
|
+
return field(schema, { sensitive: false });
|
|
150
|
+
}
|
|
125
151
|
function sensitiveString(kind, description, options = {}) {
|
|
126
152
|
const schema = options.minLength === undefined ? z.string() : z.string().min(options.minLength);
|
|
127
153
|
return field(schema, {
|
|
@@ -238,6 +264,34 @@ function readZodMetadata(schema) {
|
|
|
238
264
|
const metadata = maybeMeta.call(schema);
|
|
239
265
|
return metadata && typeof metadata === "object" ? metadata : undefined;
|
|
240
266
|
}
|
|
267
|
+
// Same-path wrappers (optional, nullable, default, catch, readonly, pipe,
|
|
268
|
+
// lazy, ...) share the JSON Schema leaf with the schema they wrap.
|
|
269
|
+
function wrapsSensitiveDeclaration(schema, seen = new Set()) {
|
|
270
|
+
const def = readZodDef(schema);
|
|
271
|
+
if (!def || seen.has(schema))
|
|
272
|
+
return false;
|
|
273
|
+
seen.add(schema);
|
|
274
|
+
return sameLeafChildren(def).some((child) => isSensitiveSchema(child) || wrapsSensitiveDeclaration(child, seen));
|
|
275
|
+
}
|
|
276
|
+
function sameLeafChildren(def) {
|
|
277
|
+
switch (Reflect.get(def, "type")) {
|
|
278
|
+
case "pipe":
|
|
279
|
+
return [Reflect.get(def, "in"), Reflect.get(def, "out")];
|
|
280
|
+
case "lazy": {
|
|
281
|
+
// A forward-reference getter can throw before its target is
|
|
282
|
+
// initialised; treat that as no declaration rather than failing.
|
|
283
|
+
const getter = Reflect.get(def, "getter");
|
|
284
|
+
try {
|
|
285
|
+
return typeof getter === "function" ? [getter()] : [];
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
default:
|
|
292
|
+
return [Reflect.get(def, "innerType")];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
241
295
|
function readZodDef(schema) {
|
|
242
296
|
if (!schema || typeof schema !== "object")
|
|
243
297
|
return undefined;
|