diff --git a/README.md b/README.md index 4d1ac09..f3e2070 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,26 @@ agentcat.track(mcpServer, "proj_0000000", { }); ``` +For redaction decisions that need more context than a single string — such as which tool was called or what type of event is being published — use the event-level `redactEvent` hook. It receives the full event object and returns a modified event, or `null` to drop the event entirely. It may be sync or async, and can be combined with `redactSensitiveInformation`. + +```ts +agentcat.track(mcpServer, "proj_0000000", { + redactEvent: (event) => { + // Drop events from tools that handle secrets entirely + if (event.resourceName === "get_credentials") { + return null; + } + // Strip response payloads from a specific tool + if (event.resourceName === "export_report") { + return { ...event, response: undefined }; + } + return event; + }, +}); +``` + +When both hooks are configured, `redactEvent` runs first and sees the raw, unredacted values; `redactSensitiveInformation` then runs on its output as a final string-level scrub. The system-managed fields `id`, `sessionId`, `projectId`, `eventType`, and `timestamp` cannot be changed by the hook, and if the hook throws, the event is dropped. The hook also applies to `publishCustomEvent` when called with a tracked server. + ### Existing Platform Support AgentCat seamlessly integrates with your existing observability stack, providing automatic logging and tracing without the tedious setup typically required. Export telemetry data to multiple platforms simultaneously: diff --git a/package.json b/package.json index 935e592..2dffa9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcat", - "version": "1.0.0", + "version": "1.0.1", "description": "Analytics tool for MCP (Model Context Protocol) servers and AI agents - tracks tool usage patterns and provides insights", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 6e64dbc..e3d2039 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ import { initDiagnostics } from "./modules/diagnostics.js"; * @param options.customContextDescription - Custom description for the injected context parameter. Only applies when enableToolCallContext is true. Use this to provide domain-specific guidance to LLMs about what context they should provide. * @param options.identify - Async function to identify users and attach custom data to their sessions. * @param options.redactSensitiveInformation - Function to redact sensitive data before sending to AgentCat. + * @param options.redactEvent - Event-level redaction hook invoked with the full event (inspect `resourceName`, `eventType`, `parameters`, `response`, etc.) before it is published. Return a modified event, or null to drop the event entirely. May be sync or async. Runs before `redactSensitiveInformation`, so it sees raw, unredacted values; the string-level hook, sanitization, and truncation still run on its output. The system-managed fields `id`, `sessionId`, `projectId`, `eventType`, and `timestamp` cannot be changed (`id` is assigned after redaction and is empty at hook time). If the hook throws, the event is dropped and the error is logged to `~/agentcat.log`. * @param options.eventTags - Callback invoked on every auto-captured event (tool calls, tool lists, initialize) to attach string key-value tags. Tags are intended to be indexed and queryable in the AgentCat dashboard — use them for structured metadata you'll want to filter or group by (e.g., trace IDs, environments, regions). Tags are validated client-side: keys must be ≤32 chars matching `[a-zA-Z0-9$_.:\- ]`, values must be strings ≤200 chars with no newlines, max 50 entries per event. Invalid entries are silently dropped with a warning logged to `~/agentcat.log`. If the callback throws or returns null, tags are omitted. Receives the same `(request, extra)` arguments as `identify`. * @param options.eventProperties - Callback invoked on every auto-captured event to attach flexible JSON metadata (device info, feature flags, nested context). No constraints beyond standard JSON types. If the callback throws or returns null, properties are omitted. Receives the same `(request, extra)` arguments as `identify`. * @param options.apiBaseUrl - Custom API base URL for sending events. Falls back to the `AGENTCAT_API_URL` environment variable if not set (then legacy `MCPCAT_API_URL`), then to the default `https://api.agentcat.com`. @@ -116,6 +117,24 @@ import { initDiagnostics } from "./modules/diagnostics.js"; * * @example * ```typescript + * // With event-level redaction + * agentcat.track(mcpServer, "proj_abc123xyz", { + * redactEvent: (event) => { + * // Drop events from tools that handle secrets entirely + * if (event.resourceName === "get_credentials") { + * return null; + * } + * // Strip response payloads from a specific tool + * if (event.resourceName === "export_report") { + * return { ...event, response: undefined }; + * } + * return event; + * } + * }); + * ``` + * + * @example + * ```typescript * // With event tags and properties * agentcat.track(mcpServer, "proj_abc123xyz", { * eventTags: async (request, extra) => ({ @@ -231,6 +250,7 @@ function track( customContextDescription: options.customContextDescription, identify: options.identify, redactSensitiveInformation: options.redactSensitiveInformation, + redactEvent: options.redactEvent, eventTags: options.eventTags, eventProperties: options.eventProperties, }, @@ -286,6 +306,11 @@ function track( * * @returns Promise that resolves when the event is queued for publishing * + * @remarks + * When a tracked server is passed, the `redactEvent` hook configured via `track()` + * is applied to the custom event before it is published. Events published with a + * bare session ID string bypass redaction, since no tracked configuration exists. + * * @example * ```typescript * // With a tracked server @@ -415,6 +440,7 @@ export type { AgentCatData, UserIdentity, RedactFunction, + RedactEventFunction, ExporterConfig, Exporter, CustomEventData, diff --git a/src/modules/eventQueue.ts b/src/modules/eventQueue.ts index 4f8a3c4..2eac09c 100644 --- a/src/modules/eventQueue.ts +++ b/src/modules/eventQueue.ts @@ -8,7 +8,7 @@ import { Event, UnredactedEvent, MCPServerLike } from "../types.js"; import { writeToLog } from "./logging.js"; import { getServerTrackingData } from "./internal.js"; import { getSessionInfo } from "./session.js"; -import { redactEvent } from "./redaction.js"; +import { applyEventRedaction, redactEvent } from "./redaction.js"; import { sanitizeEvent } from "./sanitization.js"; import { truncateEvent } from "./truncation.js"; import KSUID from "../thirdparty/ksuid/index.js"; @@ -60,6 +60,20 @@ class EventQueue { const event = this.queue.shift(); if (!event) continue; + if (event.eventRedactionFn) { + const eventRedactionFn = event.eventRedactionFn; + event.eventRedactionFn = undefined; + try { + if (!(await applyEventRedaction(event, eventRedactionFn))) { + writeToLog("Event dropped by redactEvent hook"); + continue; + } + } catch (error) { + writeToLog(`Failed to redact event (event-level hook): ${error}`); + continue; + } + } + if (event.redactionFn) { try { const redactedEvent = await redactEvent(event, event.redactionFn); @@ -292,8 +306,9 @@ export function publishEvent( isError: eventInput.isError, error: eventInput.error, - // Preserve redaction function + // Preserve redaction functions redactionFn: eventInput.redactionFn, + eventRedactionFn: eventInput.eventRedactionFn ?? data.options.redactEvent, // Customer-defined metadata tags: eventInput.tags, diff --git a/src/modules/redaction.ts b/src/modules/redaction.ts index 4beadfc..9bc7776 100644 --- a/src/modules/redaction.ts +++ b/src/modules/redaction.ts @@ -1,4 +1,9 @@ -import { Event, RedactFunction, UnredactedEvent } from "../types.js"; +import { + Event, + RedactEventFunction, + RedactFunction, + UnredactedEvent, +} from "../types.js"; /** * Set of field names that should be protected from redaction. @@ -109,3 +114,61 @@ export async function redactEvent( ): Promise { return redactStringsInObject(event, redactFn, "", false) as Promise; } + +/** + * Set of system-managed fields that are restored after the event-level + * redaction hook runs. These are required for ingestion and session/project + * attribution, so consumer changes to them are ignored. + */ +const RESTORED_FIELDS = [ + "id", + "sessionId", + "projectId", + "eventType", + "timestamp", +] as const; + +/** + * Applies the customer's event-level redaction hook to an event, in place. + * The hook receives the full event (without internal function fields) and may + * return a modified event, or null/undefined to drop the event entirely. + * + * The event object is rewritten rather than replaced: the queue pipeline and + * its observers hold references to the same object across processing steps, + * and clearing before assigning ensures fields the hook deleted stay deleted. + * System-managed fields are restored from the original, and the string-level + * `redactionFn` is preserved so it still runs afterwards. + * + * @param event - The event to run the hook on; mutated with the hook's result + * @param eventRedactFn - The customer's event-level redaction hook + * @returns True if the event was kept, false if the hook dropped it + */ +export async function applyEventRedaction( + event: UnredactedEvent, + eventRedactFn: RedactEventFunction, +): Promise { + const { redactionFn, eventRedactionFn: _e, ...hookInput } = event; + const result = await eventRedactFn(hookInput as Event); + + if (result === null || result === undefined) { + return false; + } + + const redactedEvent: UnredactedEvent = { ...result, redactionFn }; + delete redactedEvent.eventRedactionFn; + + // System-managed fields are not consumer-settable + for (const field of RESTORED_FIELDS) { + if (event[field] === undefined) { + delete redactedEvent[field]; + } else { + (redactedEvent as any)[field] = event[field]; + } + } + + for (const key of Object.keys(event)) { + delete event[key as keyof UnredactedEvent]; + } + Object.assign(event, redactedEvent); + return true; +} diff --git a/src/tests/eventQueue.test.ts b/src/tests/eventQueue.test.ts index 8a89146..7e136bc 100644 --- a/src/tests/eventQueue.test.ts +++ b/src/tests/eventQueue.test.ts @@ -304,6 +304,122 @@ describe("EventQueue", () => { }); }); + describe("event-level redaction hook (redactEvent)", () => { + beforeEach(() => { + // Restore the prototype add() (a prior test calls destroy(), which + // permanently replaces it on the singleton) and point the queue at the + // mocked API client so sends resolve deterministically. + delete (eventQueue as any).add; + (eventQueue as any).apiClient = mockApiClient; + }); + + it("should apply the redactEvent option from tracking data to published events", async () => { + const hook = vi.fn((event: any) => ({ + ...event, + parameters: { text: "[EVENT-REDACTED]" }, + })); + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { enableTracing: true, redactEvent: hook }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + resourceName: "do_thing", + parameters: { text: "raw sensitive value" }, + timestamp: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(hook).toHaveBeenCalledTimes(1); + expect(hook.mock.calls[0][0].resourceName).toBe("do_thing"); + expect(hook.mock.calls[0][0].parameters).toEqual({ + text: "raw sensitive value", + }); + expect(mockPublishEvent).toHaveBeenCalledTimes(1); + const sent = mockPublishEvent.mock.calls[0][0].publishEventRequest; + expect(sent.parameters).toEqual({ text: "[EVENT-REDACTED]" }); + }); + + it("should drop the event when the hook returns null", async () => { + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { enableTracing: true, redactEvent: () => null }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + timestamp: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockPublishEvent).not.toHaveBeenCalled(); + expect(writeToLog).toHaveBeenCalledWith( + "Event dropped by redactEvent hook", + ); + }); + + it("should drop the event when the hook throws", async () => { + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { + enableTracing: true, + redactEvent: () => { + throw new Error("hook exploded"); + }, + }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + timestamp: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockPublishEvent).not.toHaveBeenCalled(); + expect(writeToLog).toHaveBeenCalledWith( + expect.stringContaining("Failed to redact event (event-level hook)"), + ); + }); + + it("should run the event hook before string redaction and compose both", async () => { + const seenByEventHook: string[] = []; + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { + enableTracing: true, + redactEvent: (event: any) => { + seenByEventHook.push(event.userIntent); + return { ...event, userIntent: `${event.userIntent} (reviewed)` }; + }, + }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + userIntent: "raw secret", + timestamp: new Date(), + redactionFn: async (text: string) => + text.replace("secret", "[REDACTED]"), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Event hook saw the raw value; string redaction ran on its output + expect(seenByEventHook).toEqual(["raw secret"]); + expect(mockPublishEvent).toHaveBeenCalledTimes(1); + const sent = mockPublishEvent.mock.calls[0][0].publishEventRequest; + expect(sent.userIntent).toBe("raw [REDACTED] (reviewed)"); + }); + }); + describe("Process lifecycle handling", () => { it("should handle SIGINT signal", () => { // Test that signal handlers are registered diff --git a/src/tests/redaction.test.ts b/src/tests/redaction.test.ts index 836502d..6a0f88f 100644 --- a/src/tests/redaction.test.ts +++ b/src/tests/redaction.test.ts @@ -1,12 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { redactEvent } from "../modules/redaction.js"; -import { UnredactedEvent, RedactFunction } from "../types.js"; +import { redactEvent, applyEventRedaction } from "../modules/redaction.js"; +import { + Event, + UnredactedEvent, + RedactFunction, + RedactEventFunction, +} from "../types.js"; import { setupTestServerAndClient, resetTodos, } from "./test-utils/client-server-factory.js"; -import { track } from "../index.js"; -import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types"; +import { track, publishCustomEvent } from "../index.js"; +import { + CallToolResultSchema, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types"; import { EventCapture } from "./test-utils.js"; import { PublishEventRequestEventTypeEnum } from "agentcat-api"; @@ -386,6 +394,158 @@ describe("redactEvent", () => { }); }); +describe("applyEventRedaction", () => { + const baseEvent = (): UnredactedEvent => ({ + id: "evt_original", + sessionId: "ses_123", + projectId: "proj_789", + eventType: "mcp:tools/call", + timestamp: new Date("2024-01-01"), + resourceName: "add_todo", + parameters: { text: "sensitive text" }, + response: { content: "sensitive response" }, + userIntent: "sensitive intent", + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should apply a synchronous hook in place", async () => { + const hook: RedactEventFunction = (event) => ({ + ...event, + parameters: { text: "[SCRUBBED]" }, + }); + + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); + + expect(kept).toBe(true); + expect(event.parameters).toEqual({ text: "[SCRUBBED]" }); + expect(event.response).toEqual({ content: "sensitive response" }); + }); + + it("should apply an asynchronous hook in place", async () => { + const hook: RedactEventFunction = async (event) => ({ + ...event, + userIntent: "[SCRUBBED]", + }); + + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); + + expect(kept).toBe(true); + expect(event.userIntent).toBe("[SCRUBBED]"); + }); + + it("should report a drop and leave the event untouched when the hook returns null", async () => { + const hook: RedactEventFunction = () => null; + + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); + + expect(kept).toBe(false); + expect(event).toEqual(baseEvent()); + }); + + it("should report a drop when the hook returns undefined", async () => { + const hook = (() => undefined) as unknown as RedactEventFunction; + + const kept = await applyEventRedaction(baseEvent(), hook); + + expect(kept).toBe(false); + }); + + it("should honor field deletions instead of resurrecting them", async () => { + const hook: RedactEventFunction = (event) => { + const copy = { ...event }; + delete copy.response; + delete copy.userIntent; + return copy; + }; + + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); + + expect(kept).toBe(true); + expect(event).not.toHaveProperty("response"); + expect(event).not.toHaveProperty("userIntent"); + expect(event.parameters).toEqual({ text: "sensitive text" }); + }); + + it("should restore system-managed fields if the hook changes or removes them", async () => { + const hook: RedactEventFunction = (event) => { + const copy = { ...event }; + copy.id = "evt_forged"; + copy.sessionId = "ses_forged"; + copy.projectId = "proj_forged"; + copy.eventType = "forged:event"; + delete copy.timestamp; + return copy; + }; + + const event = baseEvent(); + await applyEventRedaction(event, hook); + + expect(event.id).toBe("evt_original"); + expect(event.sessionId).toBe("ses_123"); + expect(event.projectId).toBe("proj_789"); + expect(event.eventType).toBe("mcp:tools/call"); + expect(event.timestamp).toEqual(new Date("2024-01-01")); + }); + + it("should hide internal function fields from the hook but preserve the string-redaction fn", async () => { + let sawEvent: any; + const hook: RedactEventFunction = (event) => { + sawEvent = event; + return { + ...event, + eventRedactionFn: () => null, + } as unknown as Event; + }; + + const stringRedactFn = async (text: string) => text; + const event: UnredactedEvent = { + ...baseEvent(), + redactionFn: stringRedactFn, + eventRedactionFn: hook, + }; + + const kept = await applyEventRedaction(event, hook); + + expect(kept).toBe(true); + expect(sawEvent).not.toHaveProperty("redactionFn"); + expect(sawEvent).not.toHaveProperty("eventRedactionFn"); + // The string-level hook must survive so it still runs afterwards + expect(event.redactionFn).toBe(stringRedactFn); + // The event-level hook must not, even if the hook smuggles one back + expect(event.eventRedactionFn).toBeUndefined(); + }); + + it("should keep the same object identity so pipeline observers see the changes", async () => { + const hook: RedactEventFunction = (event) => ({ + ...event, + parameters: { text: "[SCRUBBED]" }, + }); + + const event = baseEvent(); + const observer = event; // e.g. EventCapture holds the reference from add() + await applyEventRedaction(event, hook); + + expect(observer.parameters).toEqual({ text: "[SCRUBBED]" }); + }); + + it("should propagate hook errors to the caller", async () => { + const hook: RedactEventFunction = () => { + throw new Error("Hook failure"); + }; + + await expect(applyEventRedaction(baseEvent(), hook)).rejects.toThrow( + "Hook failure", + ); + }); +}); + describe("redactEvent integration tests", () => { let server: any; let client: any; @@ -645,3 +805,179 @@ describe("redactEvent integration tests", () => { await eventCapture.stop(); }); }); + +describe("redactEvent hook integration tests", () => { + let server: any; + let client: any; + let cleanup: () => Promise; + + beforeEach(async () => { + resetTodos(); + const setup = await setupTestServerAndClient(); + server = setup.server; + client = setup.client; + cleanup = setup.cleanup; + }); + + afterEach(async () => { + await cleanup(); + }); + + const callAddTodo = async (text: string) => { + await client.request( + { + method: "tools/call", + params: { + name: "add_todo", + arguments: { text, context: "Adding a todo item" }, + }, + }, + CallToolResultSchema, + ); + }; + + it("should let the hook inspect event metadata and replace event fields", async () => { + const eventCapture = new EventCapture(); + await eventCapture.start(); + + const seenResourceNames: (string | undefined)[] = []; + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + seenResourceNames.push(event.resourceName); + if (event.resourceName === "add_todo") { + return { ...event, parameters: { replaced: true } }; + } + return event; + }, + }); + + await callAddTodo("Buy milk"); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const events = eventCapture.getEvents(); + const toolCallEvent = events.find( + (e) => e.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + + expect(seenResourceNames).toContain("add_todo"); + expect(toolCallEvent).toBeDefined(); + expect(toolCallEvent?.parameters).toEqual({ replaced: true }); + + // System-managed fields are intact + expect(toolCallEvent?.sessionId).toMatch(/^ses_/); + expect(toolCallEvent?.projectId).toBe("test-project"); + expect(toolCallEvent?.eventType).toBe( + PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + + await eventCapture.stop(); + }); + + it("should drop events when the hook returns null, while other events still publish", async () => { + // Capture at the sendEvent boundary — EventCapture hooks add(), which + // runs before the queue applies the event-level hook, so drops are only + // observable at send time. + const eventQueueModule = await import("../modules/eventQueue.js"); + const eq = eventQueueModule.eventQueue as any; + const originalSendEvent = eq.sendEvent; + const sentEvents: Event[] = []; + eq.sendEvent = async (event: Event) => { + sentEvents.push(event); + }; + + try { + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + if ( + event.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall + ) { + return null; + } + return event; + }, + }); + + await callAddTodo("Buy milk"); + await client.request({ method: "tools/list" }, ListToolsResultSchema); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const toolCallEvents = sentEvents.filter( + (e) => e.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + + expect(toolCallEvents).toHaveLength(0); + expect(sentEvents.length).toBeGreaterThan(0); // other event types still arrive + } finally { + eq.sendEvent = originalSendEvent; + } + }); + + it("should run the event hook on raw values before string redaction", async () => { + const eventCapture = new EventCapture(); + await eventCapture.start(); + + const rawTexts: string[] = []; + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + const text = (event.parameters as any)?.request?.params?.arguments + ?.text; + if (typeof text === "string") { + rawTexts.push(text); + } + return event; + }, + redactSensitiveInformation: async (text) => + text.includes("secret") ? "[REDACTED-SECRET]" : text, + }); + + await callAddTodo("this contains a secret value"); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Event hook saw the raw, unredacted text + expect(rawTexts).toContain("this contains a secret value"); + + // String redaction still ran afterwards on the published event + const events = eventCapture.getEvents(); + const toolCallEvent = events.find( + (e) => e.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + const params = toolCallEvent?.parameters as any; + expect(params.request.params.arguments.text).toBe("[REDACTED-SECRET]"); + + await eventCapture.stop(); + }); + + it("should apply the hook to custom events published on a tracked server", async () => { + const eventCapture = new EventCapture(); + await eventCapture.start(); + + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + if (event.eventType === "agentcat:custom") { + return { ...event, response: { scrubbed: true } }; + } + return event; + }, + }); + + await publishCustomEvent(server, "test-project", { + resourceName: "custom_action", + response: { secret: "raw value" }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const events = eventCapture.getEvents(); + const customEvent = events.find( + (e) => (e.eventType as string) === "agentcat:custom", + ); + + expect(customEvent).toBeDefined(); + expect(customEvent?.response).toEqual({ scrubbed: true }); + + await eventCapture.stop(); + }); +}); diff --git a/src/types.ts b/src/types.ts index 9fd29b6..c91eab2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export interface AgentCatOptions { extra?: CompatibleRequestHandlerExtra, ) => Promise; redactSensitiveInformation?: RedactFunction; + redactEvent?: RedactEventFunction; exporters?: Record; apiBaseUrl?: string; disableDiagnostics?: boolean; @@ -44,6 +45,10 @@ export type RegisteredTool = { export type RedactFunction = (text: string) => Promise; +export type RedactEventFunction = ( + event: Event, +) => Event | null | Promise; + export interface ExporterConfig { type: string; [key: string]: any; @@ -105,6 +110,7 @@ export interface Event { export interface UnredactedEvent extends Partial { redactionFn?: RedactFunction; // Optional redaction function for sensitive data + eventRedactionFn?: RedactEventFunction; // Optional whole-event redaction hook } // Use our own minimal interface for what we actually need