Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -231,6 +250,7 @@ function track(
customContextDescription: options.customContextDescription,
identify: options.identify,
redactSensitiveInformation: options.redactSensitiveInformation,
redactEvent: options.redactEvent,
eventTags: options.eventTags,
eventProperties: options.eventProperties,
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -415,6 +440,7 @@ export type {
AgentCatData,
UserIdentity,
RedactFunction,
RedactEventFunction,
ExporterConfig,
Exporter,
CustomEventData,
Expand Down
19 changes: 17 additions & 2 deletions src/modules/eventQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 64 additions & 1 deletion src/modules/redaction.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -109,3 +114,61 @@ export async function redactEvent(
): Promise<Event> {
return redactStringsInObject(event, redactFn, "", false) as Promise<Event>;
}

/**
* 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<boolean> {
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;
}
116 changes: 116 additions & 0 deletions src/tests/eventQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading