From acd90fac2cacc6ca8dd1de45fd1a75111051b38b Mon Sep 17 00:00:00 2001 From: Andrew Kent Date: Tue, 15 Sep 2026 13:47:46 -0600 Subject: [PATCH] wip --- .changeset/pi-tool-current-span.md | 2 +- .changeset/span-export-hooks.md | 9 + AGENTS.md | 2 + js/src/exports.ts | 6 +- js/src/instrumentation/README.md | 62 +++++ js/src/instrumentation/config.ts | 32 +++ js/src/instrumentation/index.ts | 1 + js/src/instrumentation/registry.ts | 4 + js/src/logger.ts | 39 +-- js/src/span-customizer.test.ts | 390 +++++++++++++++++++++++++++++ js/src/span-customizer.ts | 126 ++++++++++ 11 files changed, 657 insertions(+), 16 deletions(-) create mode 100644 .changeset/span-export-hooks.md create mode 100644 js/src/span-customizer.test.ts create mode 100644 js/src/span-customizer.ts diff --git a/.changeset/pi-tool-current-span.md b/.changeset/pi-tool-current-span.md index 319c8933c..33721b8e1 100644 --- a/.changeset/pi-tool-current-span.md +++ b/.changeset/pi-tool-current-span.md @@ -2,4 +2,4 @@ "braintrust": patch --- -Keep Pi Coding Agent tool spans current during execution so nested spans attach to the tool span. +fix(pi-coding-agent): keep tool spans current during execution so nested spans attach to the tool span diff --git a/.changeset/span-export-hooks.md b/.changeset/span-export-hooks.md new file mode 100644 index 000000000..cbd246537 --- /dev/null +++ b/.changeset/span-export-hooks.md @@ -0,0 +1,9 @@ +--- +"braintrust": minor +--- + +feat: add span export hooks + +Support synchronous `onSpanExport` customizers for incremental instrumentation +span records. Customizers can add, modify, delete, or replace fields before export, +with callbacks applied once per record rather than once per transport retry. diff --git a/AGENTS.md b/AGENTS.md index f474c4bcb..7229b7677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ pnpm run test # Run all workspace tests via turbo Run from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code. +Agents MUST run Prettier on every file they create or edit before handing work back, even when no commit is requested. Include Markdown, changelogs, config files, and generated files supported by Prettier—not just source code. From the repo root, run `pnpm exec prettier --write ` followed by `pnpm exec prettier --check `. If further edits are made, repeat formatting and verification after the final edit. Do not rely on tests, typechecks, CI, or the pre-commit hook to catch formatting issues. + ```bash pnpm run formatting # Check formatting (prettier) pnpm run lint # Run eslint checks diff --git a/js/src/exports.ts b/js/src/exports.ts index 683b3f868..1711f893c 100644 --- a/js/src/exports.ts +++ b/js/src/exports.ts @@ -374,6 +374,10 @@ export { braintrustFlueObserver, braintrustFlueInstrumentation, } from "./instrumentation"; -export type { InstrumentationConfig } from "./instrumentation"; +export type { + InstrumentationConfig, + SpanCustomizer, + SpanExportData, +} from "./instrumentation"; export { wrapElevenLabs } from "./wrappers/elevenlabs"; diff --git a/js/src/instrumentation/README.md b/js/src/instrumentation/README.md index 26487ec26..243ce3c57 100644 --- a/js/src/instrumentation/README.md +++ b/js/src/instrumentation/README.md @@ -180,6 +180,68 @@ termination, and async context. - Use narrow vendored provider interfaces shared by wrappers and plugins. - Keep enable, disable, subscription, and patching behavior idempotent. +## Export Customizers + +Configure `spanCustomizers` through the standalone instrumentation entrypoint +before importing the main SDK, which enables instrumentation during platform +initialization. Use a bootstrap module before any auto-instrumentation preload +that initializes the SDK. Static imports of the main SDK are hoisted; use a +dynamic import after configuration: + +```ts +import { configureInstrumentation } from "braintrust/instrumentation"; + +configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + data.tags = ["reviewed"]; + if ("output" in data) data.output = "[redacted]"; + delete data.error; + return data; + }, + }, + ], +}); + +const { initLogger } = await import("braintrust"); +initLogger({ projectName: "my-project" }); +// Import and use instrumented provider SDKs here. +``` + +`onSpanExport` receives each incremental record from an instrumentation-created +span after lazy values resolve, before attachment processing, merging, masking, +and JSON serialization. It can run before the span ends; fields may be absent. +Ordinary manually created spans, dataset rows, and feedback are not customized. + +Callbacks run synchronously in registration order. Mutate and return the record, +or return a replacement plain object for the next callback. Exceptions and invalid +return values are ignored while synchronous payload mutations remain; promises +are not awaited and their rejections are swallowed. Do not mutate the record after +returning. Export retries reuse the transformed record without invoking callbacks +again. Configuration is shared across SDK bundles. + +The SDK restores these fields after every callback, including removing injected +fields that were absent from the original record: + +- Identity: `id`, `span_id`, `root_span_id`, `span_parents`. +- Routing: `org_id`, `project_id`, `experiment_id`, `dataset_id`, + `prompt_session_id`, `log_id`, `function_data`. +- Transport controls: `_is_merge`, `_merge_paths`, `_parent_id`, `_object_delete`, + `_array_delete`, `_xact_id`. + +Payload values must remain supported by the SDK logging pipeline. They can still +include `Attachment` objects at this point; attachment processing and JSON +serialization happen after customization. + +This is an export-only hook, not a fail-closed privacy boundary. The local +experiment/scorer cache is populated before export and may retain unredacted +values. Applications requiring secrets to stay off local disk must disable the +span cache separately; export customization alone does not provide that guarantee. + +Customizers receive only the outgoing record, not a live span or provider +instrumentation context. + ## Testing Test at the narrowest useful layers: diff --git a/js/src/instrumentation/config.ts b/js/src/instrumentation/config.ts index 2f969dd58..d2696a918 100644 --- a/js/src/instrumentation/config.ts +++ b/js/src/instrumentation/config.ts @@ -1,3 +1,29 @@ +export type SpanExportData = Record; + +export interface SpanCustomizer { + /** + * Customize an outgoing span record after lazy values resolve, before JSON + * serialization. Records are incremental and may not contain every span field. + * + * Callbacks are synchronous. Add, change, or delete payload fields, then return + * the record or a replacement plain object. Payloads may still contain SDK + * Attachment objects; attachment processing and serialization happen later. + * + * The SDK restores identity and routing fields (id, span_id, root_span_id, + * span_parents, org_id, project_id, experiment_id, dataset_id, prompt_session_id, + * log_id, function_data) and transport controls (_is_merge, _merge_paths, + * _parent_id, _object_delete, _array_delete, _xact_id) after every callback. + * + * Exceptions and invalid return values are ignored; synchronous payload + * mutations remain. Promises are not awaited and their rejections are swallowed. + * Do not mutate the record after returning. + * + * This hook does not guarantee redaction of the local experiment/scorer cache, + * which is populated before export, and is not a fail-closed privacy boundary. + */ + onSpanExport?(data: SpanExportData): SpanExportData; +} + export interface InstrumentationIntegrationsConfig { openai?: boolean; anthropic?: boolean; @@ -46,6 +72,12 @@ export interface InstrumentationConfig { * Set to false to disable instrumentation for that SDK. */ integrations?: InstrumentationIntegrationsConfig; + + /** + * Instrumentation-wide customizers, in callback execution order. + * Configure before instrumentation is enabled. + */ + spanCustomizers?: readonly SpanCustomizer[]; } const envIntegrationAliases: Record< diff --git a/js/src/instrumentation/index.ts b/js/src/instrumentation/index.ts index a1dbd990c..833786cc5 100644 --- a/js/src/instrumentation/index.ts +++ b/js/src/instrumentation/index.ts @@ -45,3 +45,4 @@ export { // Configuration API export { configureInstrumentation } from "./registry"; export type { InstrumentationConfig } from "./registry"; +export type { SpanCustomizer, SpanExportData } from "./config"; diff --git a/js/src/instrumentation/registry.ts b/js/src/instrumentation/registry.ts index 0ed195ae8..1ed665a0a 100644 --- a/js/src/instrumentation/registry.ts +++ b/js/src/instrumentation/registry.ts @@ -13,6 +13,7 @@ import { type InstrumentationConfig, } from "./config"; import { GLOBAL_INSTRUMENTATION_HOOKS_PROTOCOL_VERSION } from "../global-instrumentation-hooks"; +import { setSpanCustomizers } from "../span-customizer"; export type { InstrumentationConfig } from "./config"; @@ -62,6 +63,9 @@ class PluginRegistry { return; } this.config = { ...this.config, ...config }; + if ("spanCustomizers" in config) { + setSpanCustomizers(config.spanCustomizers); + } } /** diff --git a/js/src/logger.ts b/js/src/logger.ts index e18756048..0c656cacb 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -207,6 +207,7 @@ import { mergeSpanOriginContext, type SpanOriginEnvironment, } from "./span-origin"; +import { customizeSpanExport } from "./span-customizer"; // Manual type definition for inline attachments (not in generated_types) const InlineAttachmentReferenceSchema = z.object({ @@ -8215,6 +8216,7 @@ export class SpanImpl implements Span { private isMerge: boolean; private loggedEndTime: number | undefined; + private readonly isInstrumented: boolean; private propagatedEvent: StartSpanEventArgs | undefined; // For internal use only. @@ -8255,6 +8257,8 @@ export class SpanImpl implements Span { const instrumentationName = getSpanInstrumentationName(args) ?? INSTRUMENTATION_NAMES.BRAINTRUST_JS_LOGGER; + this.isInstrumented = + instrumentationName !== INSTRUMENTATION_NAMES.BRAINTRUST_JS_LOGGER; const spanAttributes = args.spanAttributes ?? {}; const rawEvent = args.event ?? {}; @@ -8422,21 +8426,28 @@ export class SpanImpl implements Span { ); } - const computeRecord = async () => ({ - ...partialRecord, - ...Object.fromEntries( - await Promise.all( - Object.entries(lazyInternalData).map(async ([key, value]) => [ - key, - await value.get(), - ]), + const computeRecord = async () => { + const record = { + ...partialRecord, + ...Object.fromEntries( + await Promise.all( + Object.entries(lazyInternalData).map(async ([key, value]) => [ + key, + await value.get(), + ]), + ), ), - ), - ...new SpanComponentsV3({ - object_type: this.parentObjectType, - object_id: await this.parentObjectId.get(), - }).objectIdFields(), - }); + ...new SpanComponentsV3({ + object_type: this.parentObjectType, + object_id: await this.parentObjectId.get(), + }).objectIdFields(), + }; + // Customize inside the memoized lazy value, before attachment processing, + // merging, and masking. Retries reuse the already-customized record. + return this.isInstrumented + ? (customizeSpanExport(record) as BackgroundLogEvent) + : record; + }; this._state.bgLogger().log([new LazyValue(computeRecord)]); } diff --git a/js/src/span-customizer.test.ts b/js/src/span-customizer.test.ts new file mode 100644 index 000000000..4206a1ac9 --- /dev/null +++ b/js/src/span-customizer.test.ts @@ -0,0 +1,390 @@ +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + test, + vi, +} from "vitest"; +import { + _exportsForTestingOnly, + BraintrustState, + initLogger, + type TestBackgroundLogger, +} from "./logger"; +import { configureInstrumentation, registry } from "./instrumentation/registry"; +import { configureNode } from "./node/config"; +import { + INSTRUMENTATION_NAMES, + withSpanInstrumentationName, +} from "./span-origin"; +import type { SpanCustomizer, SpanExportData } from "./exports"; +import { customizeSpanExport } from "./span-customizer"; + +configureNode(); + +test("customizers expose only the outgoing-record export hook", () => { + expectTypeOf().toEqualTypeOf<{ + onSpanExport?(data: SpanExportData): SpanExportData; + }>(); +}); + +describe("onSpanExport", () => { + let memoryLogger: TestBackgroundLogger; + + beforeEach(async () => { + registry.disable(); + await _exportsForTestingOnly.simulateLoginForTests(); + memoryLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + }); + + afterEach(() => { + configureInstrumentation({ spanCustomizers: [] }); + _exportsForTestingOnly.clearTestBackgroundLogger(); + vi.unstubAllEnvs(); + }); + + function startInstrumentedSpan() { + return initLogger({ + projectName: "customizer-project", + projectId: "customizer-project", + }).startSpan( + withSpanInstrumentationName( + { name: "provider.call" }, + INSTRUMENTATION_NAMES.OPENAI, + ), + ); + } + + test("adds a field to outgoing span records", async () => { + configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + data.custom_field = "added"; + return data; + }, + }, + ], + }); + + const span = startInstrumentedSpan(); + span.log({ output: "result" }); + span.end(); + + const events = await memoryLogger.drain(); + expect(events).toEqual([ + expect.objectContaining({ + id: span.id, + project_id: "customizer-project", + output: "result", + custom_field: "added", + }), + ]); + }); + + test("alters an existing field in outgoing span records", async () => { + configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + if ("output" in data) data.output = "[redacted]"; + return data; + }, + }, + ], + }); + + const span = startInstrumentedSpan(); + span.log({ output: "sensitive response" }); + span.end(); + + expect(await memoryLogger.drain()).toEqual([ + expect.objectContaining({ id: span.id, output: "[redacted]" }), + ]); + }); + + test("deletes a field from outgoing span records", async () => { + configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + delete data.error; + return data; + }, + }, + ], + }); + + const span = startInstrumentedSpan(); + span.log({ error: "sensitive error", output: "safe response" }); + span.end(); + + const events = await memoryLogger.drain(); + expect(events).toEqual([ + expect.objectContaining({ id: span.id, output: "safe response" }), + ]); + expect(events[0]).not.toHaveProperty("error"); + }); + + test("passes replacement records through later customizers despite errors", async () => { + configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + return "output" in data ? { ...data, output: "replacement" } : data; + }, + }, + { + onSpanExport() { + throw new Error("customizer failed"); + }, + }, + { + onSpanExport(data) { + if (typeof data.output === "string") { + data.output = data.output.toUpperCase(); + } + return data; + }, + }, + ], + }); + + const span = startInstrumentedSpan(); + span.log({ output: "original" }); + span.end(); + + expect(await memoryLogger.drain()).toEqual([ + expect.objectContaining({ + id: span.id, + output: "REPLACEMENT", + metrics: expect.objectContaining({ end: expect.any(Number) }), + }), + ]); + }); + + test("does not customize manually created spans", async () => { + configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + data.tags = ["customized"]; + return data; + }, + }, + ], + }); + + const instrumented = startInstrumentedSpan(); + const manual = instrumented.startSpan({ name: "manual child" }); + manual.log({ output: "manual result" }); + manual.end(); + instrumented.end(); + + const events = await memoryLogger.drain(); + expect(events.find((event) => event.id === instrumented.id)).toMatchObject({ + tags: ["customized"], + }); + const manualEvent = events.find((event) => event.id === manual.id); + expect(manualEvent).toMatchObject({ output: "manual result" }); + expect(manualEvent).not.toHaveProperty("tags"); + }); + + test.each([ + ["missing return", () => undefined], + ["null", () => null], + ["array", () => []], + ["scalar", () => "invalid"], + ["non-record object", () => new Date(0)], + ])("ignores %s without losing either span", async (_name, invalidResult) => { + configureInstrumentation({ + spanCustomizers: [ + { + // @ts-expect-error Exercise invalid callback results from JavaScript. + onSpanExport(data) { + if ("output" in data) data.output = "redacted"; + return invalidResult(); + }, + }, + { + onSpanExport(data) { + if ("output" in data) data.output = `${data.output}:processed`; + return data; + }, + }, + ], + }); + const span = startInstrumentedSpan(); + const manual = span.startSpan({ name: "manual" }); + span.log({ input: "input", output: "private" }); + manual.log({ output: "unrelated" }); + manual.end(); + span.end(); + + const events = await memoryLogger.drain(); + expect(events).toHaveLength(2); + expect(events.find((event) => event.id === span.id)).toMatchObject({ + input: "input", + output: "redacted:processed", + metrics: { end: expect.any(Number) }, + }); + expect(events.find((event) => event.id === manual.id)).toMatchObject({ + output: "unrelated", + metrics: { end: expect.any(Number) }, + }); + }); + + test("restores mutable protocol fields between callbacks, even after a throw", () => { + configureInstrumentation({ + spanCustomizers: [ + { + onSpanExport(data) { + if (Array.isArray(data.span_parents)) + data.span_parents.push("wrong"); + if (Array.isArray(data._merge_paths)) + data._merge_paths[0].push("wrong"); + delete data.id; + delete data.project_id; + data._is_merge = false; + data.dataset_id = "wrong"; + data._object_delete = true; + throw new Error("bad customizer"); + }, + }, + { + onSpanExport(data) { + // These fields feed the next callback, not just the final exporter. + data.output = { + id: data.id, + parents: data.span_parents, + paths: data._merge_paths, + merge: data._is_merge, + }; + return Object.freeze(data); + }, + }, + ], + }); + const result = customizeSpanExport({ + id: "original", + span_id: "span", + root_span_id: "root", + span_parents: ["parent"], + project_id: "project", + log_id: "g", + _is_merge: true, + _merge_paths: [["metadata"]], + }); + expect(result).toEqual({ + id: "original", + span_id: "span", + root_span_id: "root", + span_parents: ["parent"], + project_id: "project", + log_id: "g", + _is_merge: true, + _merge_paths: [["metadata"]], + output: { + id: "original", + parents: ["parent"], + paths: [["metadata"]], + merge: true, + }, + }); + }); + + test("exports a mixed HTTP batch despite async hooks and payload-only replacements", async () => { + configureInstrumentation({ + spanCustomizers: [ + { + // @ts-expect-error Async customizers are unsupported, but must be contained. + async onSpanExport(data) { + delete data.id; + delete data._is_merge; + if ("output" in data) data.output = "redacted"; + throw new Error("async customizer failed"); + }, + }, + { + onSpanExport(data) { + const payload = Object.fromEntries( + Object.entries(data).filter(([key]) => + ["input", "output", "metrics", "span_attributes"].includes(key), + ), + ); + return Object.freeze(payload); + }, + }, + ], + }); + + // Keep both spans in the same flush chunk, rather than auto-flushing the + // manual span's initial row before the instrumented child is created. + vi.stubEnv("BRAINTRUST_SYNC_FLUSH", "1"); + const rows: Record[] = []; + const state = new BraintrustState({ noExitFlush: true }); + const logger = initLogger({ + state, + projectName: "customizer-project", + projectId: "customizer-project", + appUrl: "https://customizer.test", + apiKey: "test-key", + orgName: "test-org", + asyncFlush: false, + fetch: async (url, options) => { + const pathname = new URL(String(url)).pathname; + if (pathname === "/api/apikey/login") { + return Response.json({ + org_info: [ + { + id: "test-org", + name: "test-org", + api_url: "https://customizer.test", + }, + ], + }); + } + if (pathname === "/version") return Response.json({}); + if (pathname === "/logs3") { + rows.push(...JSON.parse(String(options?.body)).rows); + return Response.json({}); + } + throw new Error(`Unexpected test request: ${pathname}`); + }, + }); + const manual = logger.startSpan({ + name: "manual", + event: { input: "manual input" }, + }); + const span = manual.startSpan( + withSpanInstrumentationName( + { name: "provider", event: { input: "provider input" } }, + INSTRUMENTATION_NAMES.OPENAI, + ), + ); + span.log({ output: "private" }); + span.end(); + manual.log({ output: "manual output" }); + manual.end(); + await logger.flush(); + + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.id === span.id)).toMatchObject({ + span_id: span.spanId, + root_span_id: manual.rootSpanId, + span_parents: [manual.spanId], + project_id: "customizer-project", + log_id: "g", + input: "provider input", + output: "redacted", + metrics: { start: expect.any(Number), end: expect.any(Number) }, + }); + expect(rows.find((row) => row.id === manual.id)).toMatchObject({ + input: "manual input", + output: "manual output", + metrics: { start: expect.any(Number), end: expect.any(Number) }, + }); + }); +}); diff --git a/js/src/span-customizer.ts b/js/src/span-customizer.ts new file mode 100644 index 000000000..5990d077b --- /dev/null +++ b/js/src/span-customizer.ts @@ -0,0 +1,126 @@ +import { + ARRAY_DELETE_FIELD, + ID_FIELD, + IS_MERGE_FIELD, + MERGE_PATHS_FIELD, + OBJECT_DELETE_FIELD, + OBJECT_ID_KEYS, + PARENT_ID_FIELD, + TRANSACTION_ID_FIELD, +} from "../util/db_fields"; +import { isPromiseLike } from "../util/type_util"; +import type { SpanCustomizer, SpanExportData } from "./instrumentation/config"; + +// Configuration can precede platform initialization and must be shared across +// SDK bundles without importing the provider plugin registry into the logger. +const SPAN_CUSTOMIZERS_KEY = Symbol.for("braintrust.spanCustomizers"); +const shared: typeof globalThis & { + [SPAN_CUSTOMIZERS_KEY]?: readonly SpanCustomizer[]; +} = globalThis; + +const PROTECTED_FIELDS = new Set([ + ID_FIELD, + "span_id", + "root_span_id", + "span_parents", + "org_id", + ...OBJECT_ID_KEYS, + IS_MERGE_FIELD, + MERGE_PATHS_FIELD, + PARENT_ID_FIELD, + OBJECT_DELETE_FIELD, + ARRAY_DELETE_FIELD, + TRANSACTION_ID_FIELD, +]); + +function isPlainRecord(value: unknown): value is SpanExportData { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +// Only protocol values are copied deeply; payloads may contain SDK objects such +// as Attachments that must retain their identity and serialization behavior. +function copyProtocolValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(copyProtocolValue); + if (isPlainRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + copyProtocolValue(item), + ]), + ); + } + return value; +} + +function restoreProtocolFields( + data: SpanExportData, + protectedFields: SpanExportData, +): SpanExportData { + const restored: SpanExportData = {}; + for (const key of Object.keys(data)) { + if (!PROTECTED_FIELDS.has(key)) { + Object.defineProperty(restored, key, { + value: data[key], + enumerable: true, + configurable: true, + writable: true, + }); + } + } + for (const key of Object.keys(protectedFields)) { + // Give each callback its own protocol arrays/objects, never the snapshot. + restored[key] = copyProtocolValue(protectedFields[key]); + } + return restored; +} + +export function setSpanCustomizers( + customizers: readonly SpanCustomizer[] | undefined, +): void { + shared[SPAN_CUSTOMIZERS_KEY] = customizers; +} + +export function customizeSpanExport(data: SpanExportData): SpanExportData { + const customizers = shared[SPAN_CUSTOMIZERS_KEY]; + if (!customizers?.length) return data; + + let protectedFields: SpanExportData | undefined; + + for (const customizer of customizers) { + let candidate = data; + try { + if (!customizer.onSpanExport) continue; + if (!protectedFields) { + protectedFields = {}; + for (const key of PROTECTED_FIELDS) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + protectedFields[key] = copyProtocolValue(data[key]); + } + } + } + + const result: unknown = customizer.onSpanExport(data); + if (isPromiseLike(result)) { + // Hooks are synchronous, but accidental async hooks must not leak an + // unhandled rejection or replace the record with a promise. + void Promise.resolve(result).catch(() => {}); + } else if (isPlainRecord(result)) { + candidate = result; + } + } catch { + // Customization must not prevent export or later customizers from running. + } + + if (protectedFields) { + try { + // Always copy: hooks may freeze their input or return a frozen record. + data = restoreProtocolFields(candidate, protectedFields); + } catch { + data = restoreProtocolFields(data, protectedFields); + } + } + } + return data; +}