@apifuse/provider-sdk 2.2.0-beta.59 → 2.2.0-beta.61

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 (124) hide show
  1. package/AUTHORING.md +33 -16
  2. package/CHANGELOG.md +8 -0
  3. package/bin/apifuse-dev.ts +7 -8
  4. package/bin/apifuse-pack-types.ts +22 -0
  5. package/bin/apifuse-perf.ts +45 -10
  6. package/bin/apifuse-record.ts +6 -8
  7. package/bin/apifuse-submit-check.ts +2 -2
  8. package/dist/config/loader.js +13 -12
  9. package/dist/define.d.ts +3 -2
  10. package/dist/define.js +1 -1
  11. package/dist/dev.js +3 -1
  12. package/dist/engine.d.ts +7 -6
  13. package/dist/engine.js +6 -6
  14. package/dist/error-resolution.js +21 -4
  15. package/dist/errors.d.ts +3 -0
  16. package/dist/errors.js +9 -0
  17. package/dist/handle-meta.d.ts +39 -0
  18. package/dist/handle-meta.js +29 -0
  19. package/dist/handle.d.ts +187 -0
  20. package/dist/handle.js +254 -0
  21. package/dist/i18n/catalog.js +21 -1
  22. package/dist/index.d.ts +6 -4
  23. package/dist/index.js +4 -3
  24. package/dist/lint.d.ts +3 -0
  25. package/dist/lint.js +388 -62
  26. package/dist/provider.d.ts +4 -4
  27. package/dist/provider.js +3 -3
  28. package/dist/runtime/browser.js +2 -1
  29. package/dist/runtime/cache.js +3 -2
  30. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  31. package/dist/runtime/diagnostic-env-values.js +57 -0
  32. package/dist/runtime/diagnostic-env.d.ts +11 -0
  33. package/dist/runtime/diagnostic-env.js +35 -0
  34. package/dist/runtime/diagnostic-redactor.d.ts +49 -0
  35. package/dist/runtime/diagnostic-redactor.js +569 -0
  36. package/dist/runtime/egress-lease.d.ts +2 -0
  37. package/dist/runtime/egress-lease.js +4 -1
  38. package/dist/runtime/env.js +2 -1
  39. package/dist/runtime/handle-wordlist.d.ts +43 -0
  40. package/dist/runtime/{choice-wordlist.js → handle-wordlist.js} +127 -9
  41. package/dist/runtime/handle.d.ts +71 -0
  42. package/dist/runtime/handle.js +895 -0
  43. package/dist/runtime/instrumentation.js +3 -0
  44. package/dist/runtime/native-network.d.ts +10 -1
  45. package/dist/runtime/native-network.js +230 -29
  46. package/dist/runtime/native-telemetry.d.ts +142 -0
  47. package/dist/runtime/native-telemetry.js +231 -0
  48. package/dist/runtime/ocr.js +2 -1
  49. package/dist/runtime/otlp.js +5 -4
  50. package/dist/runtime/proxy-nodemaven.js +3 -2
  51. package/dist/runtime/request-options.d.ts +7 -0
  52. package/dist/runtime/request-options.js +62 -22
  53. package/dist/runtime/resolver-vendors/browser.js +0 -1
  54. package/dist/runtime/resolver.js +3 -2
  55. package/dist/runtime/state.d.ts +4 -1
  56. package/dist/runtime/state.js +32 -17
  57. package/dist/runtime/stt.js +3 -2
  58. package/dist/runtime/trace.d.ts +5 -0
  59. package/dist/runtime/trace.js +115 -20
  60. package/dist/schema.d.ts +11 -0
  61. package/dist/schema.js +56 -2
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.js +1 -0
  64. package/dist/server/self-test-redaction.js +8 -33
  65. package/dist/server/sensitive-values.d.ts +26 -0
  66. package/dist/server/sensitive-values.js +128 -0
  67. package/dist/server/serve-implementation.d.ts +4 -11
  68. package/dist/server/serve-implementation.js +305 -96
  69. package/dist/server/trace-output.d.ts +2 -1
  70. package/dist/server/trace-output.js +46 -14
  71. package/dist/testing/run.js +10 -7
  72. package/dist/trace-sanitization.d.ts +4 -3
  73. package/dist/trace-sanitization.js +45 -22
  74. package/dist/types.d.ts +4 -107
  75. package/package.json +2 -1
  76. package/src/config/loader.ts +13 -12
  77. package/src/define.ts +4 -3
  78. package/src/dev.ts +3 -1
  79. package/src/engine.ts +8 -8
  80. package/src/error-resolution.ts +21 -4
  81. package/src/errors.ts +13 -0
  82. package/src/handle-meta.ts +58 -0
  83. package/src/handle.ts +529 -0
  84. package/src/i18n/catalog.ts +23 -1
  85. package/src/index.ts +28 -13
  86. package/src/lint.ts +456 -71
  87. package/src/provider.ts +10 -20
  88. package/src/runtime/browser.ts +2 -1
  89. package/src/runtime/cache.ts +3 -2
  90. package/src/runtime/diagnostic-env-values.ts +56 -0
  91. package/src/runtime/diagnostic-env.ts +55 -0
  92. package/src/runtime/diagnostic-redactor.ts +662 -0
  93. package/src/runtime/egress-lease.ts +6 -1
  94. package/src/runtime/env.ts +2 -1
  95. package/src/runtime/{choice-wordlist.ts → handle-wordlist.ts} +130 -9
  96. package/src/runtime/handle.ts +1162 -0
  97. package/src/runtime/instrumentation.ts +3 -0
  98. package/src/runtime/native-network.ts +281 -33
  99. package/src/runtime/native-telemetry.ts +366 -0
  100. package/src/runtime/ocr.ts +2 -1
  101. package/src/runtime/otlp.ts +5 -4
  102. package/src/runtime/proxy-nodemaven.ts +3 -2
  103. package/src/runtime/request-options.ts +76 -28
  104. package/src/runtime/resolver-vendors/browser.ts +0 -1
  105. package/src/runtime/resolver.ts +3 -2
  106. package/src/runtime/state.ts +44 -16
  107. package/src/runtime/stt.ts +3 -2
  108. package/src/runtime/trace.ts +168 -26
  109. package/src/schema.ts +63 -2
  110. package/src/server/index.ts +17 -0
  111. package/src/server/self-test-redaction.ts +10 -40
  112. package/src/server/sensitive-values.ts +197 -0
  113. package/src/server/serve-implementation.ts +388 -128
  114. package/src/server/trace-output.ts +77 -17
  115. package/src/testing/run.ts +11 -7
  116. package/src/trace-sanitization.ts +70 -23
  117. package/src/types.ts +4 -137
  118. package/dist/choice-token.d.ts +0 -24
  119. package/dist/choice-token.js +0 -74
  120. package/dist/runtime/choice-wordlist.d.ts +0 -9
  121. package/dist/runtime/choice.d.ts +0 -27
  122. package/dist/runtime/choice.js +0 -801
  123. package/src/choice-token.ts +0 -165
  124. package/src/runtime/choice.ts +0 -1197
@@ -75,9 +75,12 @@ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
75
75
  end
76
76
 
77
77
  if current_decoded and type(current_decoded.createdAt) == "string" then
78
- local next_decoded = cjson.decode(envelope)
79
- next_decoded.createdAt = current_decoded.createdAt
80
- envelope = cjson.encode(next_decoded)
78
+ -- Preserve the original createdAt by patching the encoded envelope in place.
79
+ -- The envelope puts its metadata before "value", so the first match is the
80
+ -- envelope's own key, never a provider value; re-encoding with cjson would
81
+ -- silently turn [] inside the value into {}.
82
+ local preserved = string.gsub(current_decoded.createdAt, "%%", "%%%%")
83
+ envelope = (string.gsub(envelope, '"createdAt":"[^"]*"', '"createdAt":"' .. preserved .. '"', 1))
81
84
  end
82
85
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
83
86
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
@@ -267,7 +270,11 @@ function redisEnvelope(
267
270
  expiresAt: string,
268
271
  ): RedisStateEnvelope {
269
272
  const updatedAt = new Date().toISOString();
270
- return { value, version, expiresAt, createdAt, updatedAt };
273
+ // Metadata precedes `value` on purpose: the compare-and-set Lua script patches
274
+ // `createdAt` in the encoded string (first occurrence) instead of decoding and
275
+ // re-encoding with cjson, which would turn empty arrays inside `value` into
276
+ // objects. Keep the key order stable.
277
+ return { version, expiresAt, createdAt, updatedAt, value };
271
278
  }
272
279
 
273
280
  class RedisProviderStateNamespace implements ProviderStateNamespace {
@@ -604,7 +611,10 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
604
611
  // biome-ignore lint/suspicious/noExplicitAny: in-memory state stores heterogeneous generic values by key.
605
612
  readonly values = new Map<string, StateValue<any>>();
606
613
 
607
- constructor(private readonly options: StateNamespaceOptions) {}
614
+ constructor(
615
+ private readonly options: StateNamespaceOptions,
616
+ private readonly now: () => number = Date.now,
617
+ ) {}
608
618
 
609
619
  private enforceValueSize(value: unknown): void {
610
620
  const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
@@ -631,7 +641,7 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
631
641
  }
632
642
  }
633
643
 
634
- private pruneExpired(nowMs = Date.now()): void {
644
+ private pruneExpired(nowMs = this.now()): void {
635
645
  for (const [key, row] of this.values.entries()) {
636
646
  if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
637
647
  this.values.delete(key);
@@ -655,9 +665,12 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
655
665
  async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
656
666
  this.pruneExpired();
657
667
  this.enforceWritePolicy(key, value, options?.ttl);
658
- const now = new Date().toISOString();
668
+ const now = new Date(this.now()).toISOString();
659
669
  const current = this.values.get(key);
660
- const expiresAt = resolveMemoryStateExpiresAt(options?.ttl ?? this.options.defaultTtl);
670
+ const expiresAt = resolveMemoryStateExpiresAt(
671
+ options?.ttl ?? this.options.defaultTtl,
672
+ this.now(),
673
+ );
661
674
  const row = {
662
675
  key,
663
676
  value,
@@ -692,13 +705,14 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
692
705
  return { ok: false, current: current ?? null };
693
706
  }
694
707
  this.enforceWritePolicy(key, value, options?.ttl);
695
- const now = new Date().toISOString();
708
+ const now = new Date(this.now()).toISOString();
696
709
  const stored = {
697
710
  key,
698
711
  value,
699
712
  version: expectedVersion + 1,
700
713
  expiresAt: resolveMemoryStateExpiresAt(
701
714
  options?.ttl ?? this.options.defaultTtl,
715
+ this.now(),
702
716
  ),
703
717
  createdAt: current?.createdAt ?? now,
704
718
  updatedAt: now,
@@ -725,11 +739,16 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
725
739
 
726
740
  type MemoryProviderStateBackend = {
727
741
  readonly namespaces: Map<string, MemoryProviderStateNamespace>;
742
+ /** Clock used for expiry; tests inject a fake clock so ttl behaviour matches production. */
743
+ readonly now: () => number;
728
744
  };
729
745
 
730
746
  class MemoryProviderRuntimeState implements ProviderRuntimeState {
731
747
  constructor(
732
- private readonly backend: MemoryProviderStateBackend = { namespaces: new Map() },
748
+ private readonly backend: MemoryProviderStateBackend = {
749
+ namespaces: new Map(),
750
+ now: Date.now,
751
+ },
733
752
  private readonly scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR,
734
753
  ) {}
735
754
 
@@ -746,15 +765,18 @@ class MemoryProviderRuntimeState implements ProviderRuntimeState {
746
765
  const namespaceIdentity = `${scopeDiscriminator}\0${name}`;
747
766
  const existing = this.backend.namespaces.get(namespaceIdentity);
748
767
  if (existing) return existing;
749
- const created = new MemoryProviderStateNamespace(options);
768
+ const created = new MemoryProviderStateNamespace(options, this.backend.now);
750
769
  this.backend.namespaces.set(namespaceIdentity, created);
751
770
  return created;
752
771
  }
753
772
  }
754
773
 
755
- function resolveMemoryStateExpiresAt(ttl: StateWriteOptions["ttl"]): string {
774
+ function resolveMemoryStateExpiresAt(
775
+ ttl: StateWriteOptions["ttl"],
776
+ nowMs = Date.now(),
777
+ ): string {
756
778
  const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
757
- if (!match) return new Date(Date.now() + 3_600_000).toISOString();
779
+ if (!match) return new Date(nowMs + 3_600_000).toISOString();
758
780
  const amount = Number(match[1]);
759
781
  const unit = match[2];
760
782
  const multiplier =
@@ -767,7 +789,7 @@ function resolveMemoryStateExpiresAt(ttl: StateWriteOptions["ttl"]): string {
767
789
  : unit === "h"
768
790
  ? 3_600_000
769
791
  : 86_400_000;
770
- return new Date(Date.now() + amount * multiplier).toISOString();
792
+ return new Date(nowMs + amount * multiplier).toISOString();
771
793
  }
772
794
 
773
795
  export function createRedisProviderRuntimeState(
@@ -792,8 +814,14 @@ export function createProviderRuntimeStateFromEnv(
792
814
  return createUnsupportedProviderRuntimeState();
793
815
  }
794
816
 
795
- export function createMemoryProviderRuntimeState(): ProviderRuntimeState {
796
- return new MemoryProviderRuntimeState();
817
+ export function createMemoryProviderRuntimeState(options?: {
818
+ /** Clock for expiry and timestamps; defaults to `Date.now`. */
819
+ readonly now?: () => number;
820
+ }): ProviderRuntimeState {
821
+ return new MemoryProviderRuntimeState({
822
+ namespaces: new Map(),
823
+ now: options?.now ?? Date.now,
824
+ });
797
825
  }
798
826
 
799
827
  export function createUnsupportedProviderRuntimeState(): ProviderRuntimeState {
@@ -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(
package/src/schema.ts CHANGED
@@ -105,6 +105,8 @@ export type SensitiveFieldKind =
105
105
  export interface SensitiveFieldOptions {
106
106
  /**
107
107
  * Mark this schema as sensitive. Defaults to true for the helper presets.
108
+ * An explicit `false` emits `x-apifuse-sensitive: false` (reviewed public
109
+ * data, never redacted) and drops any sensitivity kind.
108
110
  */
109
111
  sensitive?: boolean;
110
112
  /**
@@ -208,10 +210,27 @@ export function field<TSchema extends ZodType>(
208
210
  ? schema.describe(options.description)
209
211
  : schema;
210
212
  const metadata = described.meta() ?? {};
213
+ const isSensitive = options.sensitive ?? true;
214
+ // A public declaration only rewrites the meta of the node it receives;
215
+ // z.toJSONSchema folds that over the inner leaf, so `false` on a wrapper
216
+ // around a sensitive() leaf would publish `false` next to the leaf's kind
217
+ // while collectSensitivePaths() still redacts. Refuse the contradiction.
218
+ if (!isSensitive && wrapsSensitiveDeclaration(described)) {
219
+ throw new TypeError(
220
+ "publicField()/field({ sensitive: false }) wraps a sensitive() declaration; apply it to the leaf, before .optional(), .nullable(), .default(), .pipe(), z.lazy() or similar wrappers.",
221
+ );
222
+ }
223
+ // Zod merges meta with the parent chain, so an inherited kind can only be
224
+ // cleared by writing undefined over it (dropped from JSON Schema output).
225
+ const kind = isSensitive
226
+ ? (options.kind ?? Reflect.get(metadata, APIFUSE_SENSITIVE_KIND_META_KEY))
227
+ : undefined;
211
228
  return described.meta({
212
229
  ...metadata,
213
- ...((options.sensitive ?? true) ? { [APIFUSE_SENSITIVE_META_KEY]: true } : {}),
214
- ...(options.kind ? { [APIFUSE_SENSITIVE_KIND_META_KEY]: options.kind } : {}),
230
+ [APIFUSE_SENSITIVE_META_KEY]: isSensitive,
231
+ ...(kind !== undefined || APIFUSE_SENSITIVE_KIND_META_KEY in metadata
232
+ ? { [APIFUSE_SENSITIVE_KIND_META_KEY]: kind }
233
+ : {}),
215
234
  });
216
235
  }
217
236
 
@@ -222,6 +241,18 @@ export function sensitive<TSchema extends ZodType>(
222
241
  return field(schema, { sensitive: true, kind });
223
242
  }
224
243
 
244
+ /**
245
+ * Declare a field as reviewed public data (business, facility, product, or
246
+ * place names and contacts) so a key-based privacy classifier hit counts as
247
+ * declared instead of undeclared. Emits `x-apifuse-sensitive: false` on the
248
+ * JSON Schema leaf; nothing is redacted. Use `sensitive()` for anything that
249
+ * identifies a person. Apply it to the leaf, before `.optional()` or
250
+ * `.nullable()`; wrapping a `sensitive()` declaration throws at definition time.
251
+ */
252
+ export function publicField<TSchema extends ZodType>(schema: TSchema): TSchema {
253
+ return field(schema, { sensitive: false });
254
+ }
255
+
225
256
  function sensitiveString(
226
257
  kind: SensitiveFieldKind,
227
258
  description: string,
@@ -382,6 +413,36 @@ function readZodMetadata(schema: unknown): object | undefined {
382
413
  return metadata && typeof metadata === "object" ? metadata : undefined;
383
414
  }
384
415
 
416
+ // Same-path wrappers (optional, nullable, default, catch, readonly, pipe,
417
+ // lazy, ...) share the JSON Schema leaf with the schema they wrap.
418
+ function wrapsSensitiveDeclaration(schema: unknown, seen = new Set<unknown>()): boolean {
419
+ const def = readZodDef(schema);
420
+ if (!def || seen.has(schema)) return false;
421
+ seen.add(schema);
422
+ return sameLeafChildren(def).some(
423
+ (child) => isSensitiveSchema(child) || wrapsSensitiveDeclaration(child, seen),
424
+ );
425
+ }
426
+
427
+ function sameLeafChildren(def: object): unknown[] {
428
+ switch (Reflect.get(def, "type")) {
429
+ case "pipe":
430
+ return [Reflect.get(def, "in"), Reflect.get(def, "out")];
431
+ case "lazy": {
432
+ // A forward-reference getter can throw before its target is
433
+ // initialised; treat that as no declaration rather than failing.
434
+ const getter = Reflect.get(def, "getter");
435
+ try {
436
+ return typeof getter === "function" ? [getter()] : [];
437
+ } catch {
438
+ return [];
439
+ }
440
+ }
441
+ default:
442
+ return [Reflect.get(def, "innerType")];
443
+ }
444
+ }
445
+
385
446
  function readZodDef(schema: unknown): object | undefined {
386
447
  if (!schema || typeof schema !== "object") return undefined;
387
448
  const def = Reflect.get(schema, "def") ?? Reflect.get(schema, "_def");
@@ -128,3 +128,20 @@ export {
128
128
  OperationRequestSchema,
129
129
  OperationSuccessResponseSchema,
130
130
  } from "./types.js";
131
+
132
+ export {
133
+ NativeTelemetryCollector,
134
+ type NativeAttemptSample,
135
+ type NativeConnectTelemetryEvent,
136
+ type NativeLifecycleTelemetryEvent,
137
+ type NativeTelemetryDiagnostics,
138
+ type NativeTelemetryErrorCode,
139
+ type NativeTelemetryHeaderPayload,
140
+ type NativeTelemetryKind,
141
+ type NativeTelemetryLifecycleKind,
142
+ type NativeTelemetryLogPayload,
143
+ type NativeTelemetryOutcome,
144
+ type NativeTelemetrySink,
145
+ type NativeTelemetryVendorSkipReason,
146
+ type NativeVendorSkipTelemetryEvent,
147
+ } from "../runtime/native-telemetry.js";
@@ -1,8 +1,8 @@
1
1
  import type { ProviderDefinition } from "../types.js";
2
2
  import {
3
- PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV,
4
- PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV,
5
- } from "./self-test-token.js";
3
+ collectDiagnosticSensitiveValues,
4
+ SELF_TEST_SENSITIVE_SOURCE_KINDS,
5
+ } from "./sensitive-values.js";
6
6
 
7
7
  export const SELF_TEST_REDACTED_PLACEHOLDER = "[REDACTED]";
8
8
 
@@ -31,39 +31,12 @@ export function collectSelfTestSensitiveValues(
31
31
  credentialInputs?: Readonly<Record<string, string>>;
32
32
  } = {},
33
33
  ): string[] {
34
- const env = options.env ?? process.env;
35
- const envNames = new Set<string>([
36
- PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV,
37
- PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV,
38
- ]);
39
- for (const secret of provider.secrets ?? []) {
40
- envNames.add(secret.name);
41
- }
42
- const healthProbe = provider.healthProbe ?? provider.healthMonitor;
43
- for (const name of healthProbe?.requiredSecrets ?? []) {
44
- envNames.add(name);
45
- }
46
- for (const name of Object.values(healthProbe?.credentialInputs ?? {})) {
47
- envNames.add(name);
48
- }
49
-
50
- const values = new Set<string>();
51
- for (const name of envNames) {
52
- const value = env[name];
53
- if (typeof value === "string" && value.length >= MIN_SENSITIVE_VALUE_LENGTH) {
54
- values.add(value);
55
- }
56
- }
57
- for (const value of Object.values(options.credentialInputs ?? {})) {
58
- if (value.length >= MIN_SENSITIVE_VALUE_LENGTH) {
59
- values.add(value);
60
- }
61
- }
62
- return [...values];
63
- }
64
-
65
- function escapeRegExp(value: string): string {
66
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
+ return collectDiagnosticSensitiveValues(provider, {
35
+ ...options,
36
+ sourceKinds: SELF_TEST_SENSITIVE_SOURCE_KINDS,
37
+ minimumLength: MIN_SENSITIVE_VALUE_LENGTH,
38
+ includeResolvedEnvVariants: false,
39
+ });
67
40
  }
68
41
 
69
42
  /**
@@ -79,10 +52,7 @@ export function redactSelfTestText(
79
52
  let redacted = text;
80
53
  for (const value of sensitiveValues) {
81
54
  if (value.length < MIN_SENSITIVE_VALUE_LENGTH) continue;
82
- redacted = redacted.replace(
83
- new RegExp(escapeRegExp(value), "g"),
84
- SELF_TEST_REDACTED_PLACEHOLDER,
85
- );
55
+ redacted = redacted.replaceAll(value, SELF_TEST_REDACTED_PLACEHOLDER);
86
56
  }
87
57
  for (const pattern of HEADER_SHAPED_SECRETS) {
88
58
  redacted = redacted.replace(