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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

TypeScript client library for the [Iterable API](https://api.iterable.com/api/docs).

**Important:** This library is maintained for use in the [Iterable MCP server](https://github.com/Iterable/mcp-server) and internal Iterable projects. The API may change at any time. If using this library directly in your own projects, be prepared to adapt to breaking changes.
This library is currently in active development. While it is used in production by the [Iterable MCP server](https://github.com/Iterable/mcp-server), it is still considered experimental. We are rapidly iterating on features and improvements, so you may encounter breaking changes or incomplete type definitions.

Pull requests for bugfixes and improvements are always welcome and appreciated.
We welcome early adopters and feedback! If you're building with it, please stay in touch via issues or pull requests.

## Installation

Expand Down Expand Up @@ -48,17 +48,19 @@ const client = new IterableClient({
apiKey: 'your-api-key',
baseUrl: 'https://api.iterable.com', // optional
timeout: 30000, // optional
debug: false // optional
debug: true, // log requests/responses (headers/params redacted)
debugVerbose: false // set true to log response bodies (CAUTION: may contain PII)
});
```

### Environment Variables

```bash
ITERABLE_API_KEY=your-api-key # Required
ITERABLE_DEBUG=true # Optional: Enable debug logging
LOG_LEVEL=info # Optional: Log level
LOG_FILE=./logs/iterable.log # Optional: Log to file
ITERABLE_DEBUG=true # Enable basic debug logging
ITERABLE_DEBUG_VERBOSE=true # Enable full body logging (includes PII)
LOG_LEVEL=info # Log level
LOG_FILE=./logs/iterable.log # Log to file
```

## Development
Expand Down
21 changes: 17 additions & 4 deletions src/client/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,32 @@ export class BaseIterableClient {
);

// Add debug logging interceptors (only when debug is enabled)
// WARNING: Never enable debug mode in production as it may log sensitive information
if (clientConfig.debug) {
const sanitizeHeaders = (headers: any) => {
if (!headers) return undefined;
const sensitive = ["api-key", "authorization", "cookie", "set-cookie"];
const sanitized = { ...headers };
Object.keys(sanitized).forEach((key) => {
if (sensitive.includes(key.toLowerCase())) {
sanitized[key] = "[REDACTED]";
}
});
return sanitized;
};

this.client.interceptors.request.use((request) => {
logger.debug("API request", {
method: request.method?.toUpperCase(),
url: request.url,
headers: sanitizeHeaders(request.headers),
});
return request;
});

// Helper function to create log data from response/error
const createResponseLogData = (response: any, includeData = false) => ({
status: response.status,
url: response.config?.url || response.config.url,
url: response.config?.url,
...(includeData && { data: response.data }),
});

Expand All @@ -106,14 +119,14 @@ export class BaseIterableClient {
},
(error) => {
if (error.response) {
// CRITICAL: Only log response data if verbose debug is enabled to prevent PII leaks
logger.error(
"API error",
createResponseLogData(error.response, true)
createResponseLogData(error.response, clientConfig.debugVerbose)
);
} else {
logger.error("Network error", { message: error.message });
}
// Error is already converted by the error handling interceptor above
return Promise.reject(error);
}
);
Expand Down
173 changes: 173 additions & 0 deletions tests/unit/sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import axios from "axios";

// Automock axios
jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>;

import { BaseIterableClient } from "../../src/client/base.js";
// Import the real logger to spy on it
import { logger } from "../../src/logger.js";

describe("Debug Logging Sanitization", () => {
let mockClientInstance: any;
let requestInterceptor: any;
let responseInterceptorError: any;

let debugSpy: any;
let errorSpy: any;

beforeEach(() => {
jest.clearAllMocks();

// Spy on logger methods
// We use mockImplementation to silence the console output during tests
debugSpy = jest.spyOn(logger, "debug").mockImplementation(() => logger);
errorSpy = jest.spyOn(logger, "error").mockImplementation(() => logger);

requestInterceptor = undefined;
responseInterceptorError = undefined;

mockClientInstance = {
interceptors: {
request: {
use: jest.fn((callback) => {
requestInterceptor = callback;
return 0;
}),
},
response: {
use: jest.fn((success, error) => {
responseInterceptorError = error;
return 0;
}),
},
},
get: jest.fn(),
defaults: { headers: {} },
};

if (jest.isMockFunction(mockedAxios.create)) {
mockedAxios.create.mockReturnValue(mockClientInstance);
} else {
(mockedAxios as any).create = jest.fn().mockReturnValue(mockClientInstance);
}
});

it("should call axios.create and register interceptors", () => {
new BaseIterableClient({
apiKey: "test-api-key",
debug: true,
});

expect(mockedAxios.create).toHaveBeenCalled();
expect(mockClientInstance.interceptors.request.use).toHaveBeenCalled();
expect(requestInterceptor).toBeDefined();
});

it("should redact sensitive headers in debug logs", () => {
new BaseIterableClient({
apiKey: "test-api-key",
debug: true,
});

if (!requestInterceptor) throw new Error("Request interceptor missing");

const requestConfig = {
method: "get",
url: "/test",
headers: {
Authorization: "Bearer secret-token",
"Cookie": "session=secret",
"X-Custom": "safe",
"Api-Key": "real-api-key",
},
};

requestInterceptor(requestConfig);

expect(debugSpy).toHaveBeenCalledWith(
"API request",
expect.objectContaining({
headers: expect.objectContaining({
"Api-Key": "[REDACTED]",
Authorization: "[REDACTED]",
Cookie: "[REDACTED]",
"X-Custom": "safe",
}),
})
);
});

it("should NOT log error response data by default (debugVerbose=false)", async () => {
new BaseIterableClient({
apiKey: "test-api-key",
debug: true,
debugVerbose: false,
});

if (!responseInterceptorError) throw new Error("Response interceptor missing");

const sensitiveError = { message: "User email@example.com not found" };
const errorResponse = {
response: {
status: 404,
config: { url: "/error" },
data: sensitiveError,
},
};

try {
await responseInterceptorError(errorResponse);
} catch {
// Expected
}

expect(errorSpy).toHaveBeenCalledWith(
"API error",
expect.objectContaining({
status: 404,
})
);

const errorLog = errorSpy.mock.calls.find(
(call: any) => call[0] === "API error"
);
const errorData = errorLog?.[1] as any;

expect(errorData.data).toBeUndefined();
});

it("should log error response data when debugVerbose is true", async () => {
new BaseIterableClient({
apiKey: "test-api-key",
debug: true,
debugVerbose: true,
});

if (!responseInterceptorError) throw new Error("Response interceptor missing");

const errorBody = { error: "details" };
const errorResponse = {
response: {
status: 400,
config: { url: "/error" },
data: errorBody,
},
};

try {
await responseInterceptorError(errorResponse);
} catch {
// Expected
}

expect(errorSpy).toHaveBeenCalledWith(
"API error",
expect.objectContaining({
status: 400,
data: errorBody,
})
);
});
});