@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.
Files changed (72) hide show
  1. package/AUTHORING.md +16 -0
  2. package/CHANGELOG.md +4 -0
  3. package/bin/apifuse-dev.ts +2 -1
  4. package/bin/apifuse-perf.ts +41 -4
  5. package/bin/apifuse-record.ts +3 -2
  6. package/dist/config/loader.js +13 -12
  7. package/dist/engine.js +3 -2
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lint.js +36 -15
  11. package/dist/provider.d.ts +1 -1
  12. package/dist/provider.js +1 -1
  13. package/dist/runtime/browser.js +2 -1
  14. package/dist/runtime/cache.js +3 -2
  15. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  16. package/dist/runtime/diagnostic-env-values.js +57 -0
  17. package/dist/runtime/diagnostic-env.d.ts +11 -0
  18. package/dist/runtime/diagnostic-env.js +35 -0
  19. package/dist/runtime/diagnostic-redactor.d.ts +49 -0
  20. package/dist/runtime/diagnostic-redactor.js +569 -0
  21. package/dist/runtime/egress-lease.d.ts +2 -0
  22. package/dist/runtime/egress-lease.js +4 -1
  23. package/dist/runtime/env.js +2 -1
  24. package/dist/runtime/instrumentation.js +3 -0
  25. package/dist/runtime/ocr.js +2 -1
  26. package/dist/runtime/otlp.js +5 -4
  27. package/dist/runtime/proxy-nodemaven.js +3 -2
  28. package/dist/runtime/request-options.d.ts +7 -0
  29. package/dist/runtime/request-options.js +62 -22
  30. package/dist/runtime/resolver-vendors/browser.js +0 -1
  31. package/dist/runtime/resolver.js +3 -2
  32. package/dist/runtime/stt.js +3 -2
  33. package/dist/runtime/trace.d.ts +5 -0
  34. package/dist/runtime/trace.js +115 -20
  35. package/dist/schema.d.ts +11 -0
  36. package/dist/schema.js +56 -2
  37. package/dist/server/self-test-redaction.js +8 -33
  38. package/dist/server/sensitive-values.d.ts +26 -0
  39. package/dist/server/sensitive-values.js +131 -0
  40. package/dist/server/serve-implementation.js +219 -78
  41. package/dist/server/trace-output.d.ts +2 -1
  42. package/dist/server/trace-output.js +46 -14
  43. package/dist/trace-sanitization.d.ts +4 -3
  44. package/dist/trace-sanitization.js +45 -22
  45. package/package.json +2 -1
  46. package/src/config/loader.ts +13 -12
  47. package/src/engine.ts +3 -2
  48. package/src/index.ts +2 -0
  49. package/src/lint.ts +42 -16
  50. package/src/provider.ts +1 -0
  51. package/src/runtime/browser.ts +2 -1
  52. package/src/runtime/cache.ts +3 -2
  53. package/src/runtime/diagnostic-env-values.ts +56 -0
  54. package/src/runtime/diagnostic-env.ts +55 -0
  55. package/src/runtime/diagnostic-redactor.ts +662 -0
  56. package/src/runtime/egress-lease.ts +6 -1
  57. package/src/runtime/env.ts +2 -1
  58. package/src/runtime/instrumentation.ts +3 -0
  59. package/src/runtime/ocr.ts +2 -1
  60. package/src/runtime/otlp.ts +5 -4
  61. package/src/runtime/proxy-nodemaven.ts +3 -2
  62. package/src/runtime/request-options.ts +76 -28
  63. package/src/runtime/resolver-vendors/browser.ts +0 -1
  64. package/src/runtime/resolver.ts +3 -2
  65. package/src/runtime/stt.ts +3 -2
  66. package/src/runtime/trace.ts +168 -26
  67. package/src/schema.ts +63 -2
  68. package/src/server/self-test-redaction.ts +10 -40
  69. package/src/server/sensitive-values.ts +200 -0
  70. package/src/server/serve-implementation.ts +301 -96
  71. package/src/server/trace-output.ts +77 -17
  72. package/src/trace-sanitization.ts +70 -23
@@ -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 process.env[key];
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()`
@@ -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[key]?.trim();
100
+ const value = readDiagnosticEnv(key, env)?.trim();
100
101
  return value ? value : undefined;
101
102
  }
102
103
 
@@ -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: env[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT],
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: env[OTEL_EXPORTER_OTLP_ENDPOINT],
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[OTEL_EXPORTER_OTLP_TRACES_HEADERS]);
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[OTEL_EXPORTER_OTLP_HEADERS]),
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 process.env[NODEMAVEN_USERNAME_ENV]?.trim() || undefined;
47
+ return readDiagnosticEnv(NODEMAVEN_USERNAME_ENV)?.trim() || undefined;
47
48
  }
48
49
 
49
50
  function readNodemavenPassword(): string | undefined {
50
- return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
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 = 4;
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
- return [...new Set([value, componentEncoded, formEncoded].map(normalizePercentEscapes))];
183
- }
184
-
185
- type SensitiveValueVariant = { value: string; requiresTokenBoundary: boolean };
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(sensitiveValues: readonly string[]): SensitiveValueVariant[] {
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 pattern = variant.value.replace(/%([\da-f]{2})|[.*+?^${}()|[\]\\]/gi, (match, hex) => {
213
- if (hex) {
214
- return `%${[...String(hex)]
215
- .map((character) =>
216
- /[a-f]/i.test(character)
217
- ? `[${character.toLowerCase()}${character.toUpperCase()}]`
218
- : character,
219
- )
220
- .join("")}`;
221
- }
222
- return `\\${match}`;
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(source[offset - 1]) || isTokenCharacter(source[end]))
229
- ) {
230
- return match;
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. */
@@ -173,7 +173,6 @@ async function runBoundedCleanup(
173
173
  onError(error) {
174
174
  return {
175
175
  error_message: error instanceof Error ? error.message : String(error),
176
- ...(error instanceof Error && error.stack ? { error_stack: error.stack } : {}),
177
176
  };
178
177
  },
179
178
  })
@@ -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[key]?.trim();
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[name]?.trim();
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`);
@@ -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[key]?.trim();
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.APIFUSE__STT__CLOUDFLARE_API_TOKEN?.trim();
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}`,
@@ -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(value: unknown): SpanAttributeValue | undefined {
113
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
114
- return value;
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
- ): Record<string, SpanAttributeValue> {
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]) => [key, normalizeAttributeValue(value)] as const)
133
- .filter((entry): entry is readonly [string, SpanAttributeValue] => entry[1] !== undefined);
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
- return Object.fromEntries(normalizedEntries);
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 = { ...span, attributes: { ...span.attributes } };
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 exportResourceAttributes = options.resourceAttributes
184
- ? { ...options.resourceAttributes }
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
- console.warn("[apifuse] OTLP export skipped; span sanitization failed.");
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, exportResourceAttributes, exportTraceId);
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
- ...normalizeAttributes(extraAttributes),
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
- name: pendingSpan.name,
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 ? { parentId: 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(