From 1561fdd08d8638b412086e25d4197cfaff03ffe1 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 28 Apr 2026 11:30:01 -0600 Subject: [PATCH 1/2] send buffered events to event capture service --- .fernignore | 2 + src/event-capture.ts | 117 ++++++++++++++++++ src/events.ts | 10 +- src/wrapper.ts | 19 ++- tests/unit/event-capture.test.ts | 206 +++++++++++++++++++++++++++++++ tests/unit/events.test.ts | 84 +++++-------- 6 files changed, 378 insertions(+), 60 deletions(-) create mode 100644 src/event-capture.ts create mode 100644 tests/unit/event-capture.test.ts diff --git a/.fernignore b/.fernignore index 6e5b41f7..5d4c624a 100644 --- a/.fernignore +++ b/.fernignore @@ -16,6 +16,7 @@ testapp/ src/cache/ src/core/fetcher/custom.ts src/datastream/ +src/event-capture.ts src/events.test.ts src/events.ts src/index.ts @@ -30,6 +31,7 @@ tests/unit/cache/local.test.ts tests/unit/datastream/datastream-client.test.ts tests/unit/datastream/merge.test.ts tests/unit/datastream/websocket-client.test.ts +tests/unit/event-capture.test.ts tests/unit/events.test.ts tests/unit/rules-engine.test.ts tests/unit/wasm-integration.test.ts diff --git a/src/event-capture.ts b/src/event-capture.ts new file mode 100644 index 00000000..ddb5a4e3 --- /dev/null +++ b/src/event-capture.ts @@ -0,0 +1,117 @@ +import { CreateEventRequestBody, EventBody, EventType } from "./api"; +import * as serializers from "./serialization"; +import type { FetchFunction } from "./core/fetcher/Fetcher"; + +export const DEFAULT_EVENT_CAPTURE_BASE_URL = "https://c.schematichq.com"; +const DEFAULT_TIMEOUT_MS = 10_000; + +export interface EventCaptureClientOptions { + apiKey: string; + /** Fetcher created by the SchematicClient — reused so that offline mode, + * default headers, and retry/logging behavior stay consistent. */ + fetcher: FetchFunction; + baseUrl?: string; + timeoutMs?: number; +} + +interface CapturePayload { + api_key: string; + type: EventType; + body?: unknown; + sent_at?: string; +} + +interface BatchPayload { + events: CapturePayload[]; +} + +const buildEndpoint = (baseUrl: string): string => { + return baseUrl.replace(/\/+$/, "") + "/batch"; +}; + +const toCapturePayload = (event: CreateEventRequestBody, apiKey: string): CapturePayload => { + const payload: CapturePayload = { + api_key: apiKey, + type: event.eventType, + }; + + if (event.body !== undefined) { + payload.body = serializers.EventBody.jsonOrThrow(event.body as EventBody, { + unrecognizedObjectKeys: "strip", + }); + } + + if (event.sentAt !== undefined) { + payload.sent_at = event.sentAt instanceof Date ? event.sentAt.toISOString() : event.sentAt; + } + + return payload; +}; + +const buildBatch = (events: CreateEventRequestBody[], apiKey: string): BatchPayload => { + return { + events: events.map((e) => toCapturePayload(e, apiKey)), + }; +}; + +const describeFetcherError = (error: unknown): string => { + if (typeof error !== "object" || error === null || !("reason" in error)) { + return "unknown error"; + } + const err = error as { reason: string; statusCode?: number; errorMessage?: string; body?: unknown }; + switch (err.reason) { + case "status-code": { + const body = typeof err.body === "string" ? err.body : JSON.stringify(err.body ?? ""); + return `HTTP ${err.statusCode}: ${body}`; + } + case "timeout": + return "request timed out"; + case "non-json": + return `non-JSON response (HTTP ${err.statusCode})`; + case "body-is-null": + return `empty response body (HTTP ${err.statusCode})`; + default: + return err.errorMessage ?? "unknown error"; + } +}; + +/** + * HTTP client for sending event batches directly to the Schematic event + * capture service (default: https://c.schematichq.com/batch). + */ +export class EventCaptureClient { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly timeoutMs: number; + private readonly fetcher: FetchFunction; + + constructor(options: EventCaptureClientOptions) { + this.apiKey = options.apiKey; + this.baseUrl = options.baseUrl ?? DEFAULT_EVENT_CAPTURE_BASE_URL; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.fetcher = options.fetcher; + } + + public async sendBatch(events: CreateEventRequestBody[]): Promise { + if (events.length === 0) { + return; + } + + const response = await this.fetcher({ + url: buildEndpoint(this.baseUrl), + method: "POST", + contentType: "application/json", + requestType: "json", + headers: { + "X-Schematic-Api-Key": this.apiKey, + }, + body: buildBatch(events, this.apiKey), + timeoutMs: this.timeoutMs, + maxRetries: 0, + }); + + if (!response.ok) { + throw new Error(`capture service returned ${describeFetcherError(response.error)}`); + } + } +} diff --git a/src/events.ts b/src/events.ts index d67d9aac..fb21dfa6 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,5 +1,5 @@ import { CreateEventRequestBody } from "./api"; -import { EventsClient } from "./api/resources/events/client/Client"; +import { EventCaptureClient } from "./event-capture"; import { ConsoleLogger, Logger } from "./logger"; const DEFAULT_FLUSH_INTERVAL = 1000; // 1 second @@ -18,7 +18,7 @@ interface EventBufferOptions { class EventBuffer { private events: CreateEventRequestBody[] = []; - private eventsApi: EventsClient; + private captureClient: EventCaptureClient; private interval: number; private intervalId: NodeJS.Timeout | null = null; private logger: Logger; @@ -30,7 +30,7 @@ class EventBuffer { private stopped: boolean = false; private flushing: boolean = false; // Add flush state tracking - constructor(eventsApi: EventsClient, opts?: EventBufferOptions) { + constructor(captureClient: EventCaptureClient, opts?: EventBufferOptions) { const { logger = new ConsoleLogger(), maxSize = DEFAULT_MAX_SIZE, @@ -39,7 +39,7 @@ class EventBuffer { maxRetries = DEFAULT_MAX_RETRIES, initialRetryDelay = DEFAULT_INITIAL_RETRY_DELAY, } = opts || {}; - this.eventsApi = eventsApi; + this.captureClient = captureClient; this.interval = interval; this.logger = logger; this.maxSize = maxSize; @@ -74,7 +74,7 @@ class EventBuffer { } // Attempt to send events - await this.eventsApi.createEventBatch({ events }); + await this.captureClient.sendBatch(events); success = true; } catch (err) { lastError = err; diff --git a/src/wrapper.ts b/src/wrapper.ts index b13aca95..b6ad05b1 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -4,6 +4,7 @@ import { SchematicClient as BaseClient } from "./Client"; import { type CacheProvider, LocalCache } from "./cache"; import { ConsoleLogger, Logger } from "./logger"; import { EventBuffer } from "./events"; +import { EventCaptureClient } from "./event-capture"; import { offlineFetcher, provideFetcher } from "./core/fetcher/custom"; import { RUNTIME } from "./core/runtime"; import { DataStreamClient, type DataStreamClientOptions } from "./datastream"; @@ -41,6 +42,8 @@ export interface SchematicOptions { }; /** If using an API key that is not environment-specific, use this option to specify the environment */ environmentId?: string; + /** Custom base URL for the event capture service (default: https://c.schematichq.com) */ + eventCaptureBaseURL?: string; /** Interval in milliseconds for flushing event buffer */ eventBufferInterval?: number; /** Default values for feature flags */ @@ -92,6 +95,7 @@ export class SchematicClient extends BaseClient { apiKey = "", basePath, eventBufferInterval, + eventCaptureBaseURL, flagDefaults = {}, logger = new ConsoleLogger(), timeoutMs, @@ -117,16 +121,27 @@ export class SchematicClient extends BaseClient { offline = true; } + // Build the fetcher once and share it with the event capture client so + // that offline mode, default headers, and retry/logging behavior stay + // consistent across API calls and event capture submissions. + const fetcher = offline ? offlineFetcher : provideFetcher(headers); + // Initialize wrapped client super({ apiKey, environment: basePath, - fetcher: offline ? offlineFetcher : provideFetcher(headers), + fetcher, timeoutInSeconds: timeoutMs !== undefined ? timeoutMs / 1000 : undefined, }); this.logger = logger; - this.eventBuffer = new EventBuffer(this.events, { + const captureClient = new EventCaptureClient({ + apiKey, + fetcher, + baseUrl: eventCaptureBaseURL, + timeoutMs, + }); + this.eventBuffer = new EventBuffer(captureClient, { interval: eventBufferInterval, logger, offline, diff --git a/tests/unit/event-capture.test.ts b/tests/unit/event-capture.test.ts new file mode 100644 index 00000000..16f2a39d --- /dev/null +++ b/tests/unit/event-capture.test.ts @@ -0,0 +1,206 @@ +/* eslint @typescript-eslint/no-explicit-any: 0 */ + +import { CreateEventRequestBody } from "../../src/api"; +import { + DEFAULT_EVENT_CAPTURE_BASE_URL, + EventCaptureClient, +} from "../../src/event-capture"; +import type { FetchFunction } from "../../src/core/fetcher/Fetcher"; + +describe("EventCaptureClient", () => { + const apiKey = "test-api-key"; + + const buildEvent = (overrides?: Partial): CreateEventRequestBody => ({ + body: { + company: { id: "company-1" }, + event: "test-event", + user: { id: "user-1" }, + quantity: 2, + }, + eventType: "track", + sentAt: new Date("2026-04-28T12:00:00.000Z"), + ...overrides, + }); + + const okResponse = { ok: true, body: {}, headers: {}, rawResponse: {} as any }; + + const makeFetcher = (impl?: (...args: any[]) => any): jest.MockedFunction => { + const fn = jest.fn(impl ?? (() => Promise.resolve(okResponse))) as unknown as jest.MockedFunction; + return fn; + }; + + it("should POST a snake_case batch payload to /batch with auth header", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher }); + + const event = buildEvent(); + await client.sendBatch([event]); + + expect(fetcher).toHaveBeenCalledTimes(1); + const args = fetcher.mock.calls[0][0]; + + expect(args.url).toBe(`${DEFAULT_EVENT_CAPTURE_BASE_URL}/batch`); + expect(args.method).toBe("POST"); + expect(args.contentType).toBe("application/json"); + expect(args.requestType).toBe("json"); + expect(args.headers).toEqual( + expect.objectContaining({ + "X-Schematic-Api-Key": apiKey, + }), + ); + // Buffer-level retries are authoritative; the fetcher should not retry. + expect(args.maxRetries).toBe(0); + + expect(args.body).toEqual({ + events: [ + { + api_key: apiKey, + type: "track", + body: { + company: { id: "company-1" }, + event: "test-event", + user: { id: "user-1" }, + quantity: 2, + }, + sent_at: "2026-04-28T12:00:00.000Z", + }, + ], + }); + }); + + it("should serialize multiple events including identify and flag_check types", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher }); + + const events: CreateEventRequestBody[] = [ + { + eventType: "identify", + body: { + keys: { id: "user-1" }, + name: "Test User", + }, + sentAt: new Date("2026-04-28T12:00:00.000Z"), + }, + { + eventType: "flag_check", + body: { + flagKey: "feature-x", + value: true, + reason: "rule match", + }, + sentAt: new Date("2026-04-28T12:00:01.000Z"), + }, + ]; + + await client.sendBatch(events); + + const args = fetcher.mock.calls[0][0]; + const body = args.body as { events: any[] }; + + expect(body.events).toHaveLength(2); + expect(body.events[0]).toMatchObject({ + api_key: apiKey, + type: "identify", + body: expect.objectContaining({ name: "Test User" }), + }); + expect(body.events[1]).toMatchObject({ + api_key: apiKey, + type: "flag_check", + // flag_check body fields use snake_case via the generated serializer + body: expect.objectContaining({ + flag_key: "feature-x", + value: true, + reason: "rule match", + }), + }); + }); + + it("should respect a custom base URL and strip trailing slashes", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ + apiKey, + fetcher, + baseUrl: "https://custom.example.com/", + }); + + await client.sendBatch([buildEvent()]); + + const args = fetcher.mock.calls[0][0]; + expect(args.url).toBe("https://custom.example.com/batch"); + }); + + it("should be a no-op when sending an empty batch", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await client.sendBatch([]); + + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("should throw when the capture service returns a non-2xx response", async () => { + const fetcher = makeFetcher(() => + Promise.resolve({ + ok: false, + error: { reason: "status-code", statusCode: 500, body: "boom" }, + rawResponse: {} as any, + }), + ); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await expect(client.sendBatch([buildEvent()])).rejects.toThrow( + "capture service returned HTTP 500: boom", + ); + }); + + it("should surface fetcher timeouts", async () => { + const fetcher = makeFetcher(() => + Promise.resolve({ + ok: false, + error: { reason: "timeout" }, + rawResponse: {} as any, + }), + ); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await expect(client.sendBatch([buildEvent()])).rejects.toThrow( + "capture service returned request timed out", + ); + }); + + it("should surface unknown fetcher errors", async () => { + const fetcher = makeFetcher(() => + Promise.resolve({ + ok: false, + error: { reason: "unknown", errorMessage: "network down" }, + rawResponse: {} as any, + }), + ); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await expect(client.sendBatch([buildEvent()])).rejects.toThrow( + "capture service returned network down", + ); + }); + + it("should omit body when the event has no body", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await client.sendBatch([ + { + eventType: "track", + sentAt: new Date("2026-04-28T12:00:00.000Z"), + } as CreateEventRequestBody, + ]); + + const args = fetcher.mock.calls[0][0]; + const batch = args.body as { events: any[] }; + expect(batch.events[0]).not.toHaveProperty("body"); + expect(batch.events[0]).toEqual({ + api_key: apiKey, + type: "track", + sent_at: "2026-04-28T12:00:00.000Z", + }); + }); +}); diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index 653d17aa..10f017ee 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -1,7 +1,7 @@ /* eslint @typescript-eslint/no-explicit-any: 0 */ import { EventBuffer } from "../../src/events"; -import { EventsClient } from "../../src/api/resources/events/client/Client"; +import { EventCaptureClient } from "../../src/event-capture"; import { CreateEventRequestBody } from "../../src/api"; import { Logger } from "../../src/logger"; @@ -10,19 +10,12 @@ process.env.NODE_ENV = "test"; jest.useFakeTimers(); describe("EventBuffer", () => { - let mockEventsApi: jest.Mocked; + let mockCaptureClient: jest.Mocked; let mockLogger: jest.Mocked; beforeEach(() => { - const mockResponse = { - data: { - events: [], - }, - params: {}, - }; - - mockEventsApi = { - createEventBatch: jest.fn().mockResolvedValue(mockResponse), + mockCaptureClient = { + sendBatch: jest.fn().mockResolvedValue(undefined), } as any; mockLogger = { @@ -53,7 +46,7 @@ describe("EventBuffer", () => { eventType: "track", sentAt: new Date(), }; - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { logger: mockLogger, maxSize: 1, // Set max size to 1 item interval: 1000, @@ -61,29 +54,25 @@ describe("EventBuffer", () => { await buffer.push(event1); - expect(mockEventsApi.createEventBatch).not.toHaveBeenCalled(); + expect(mockCaptureClient.sendBatch).not.toHaveBeenCalled(); // Force first flush by exceeding max size await buffer.push(event2); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ - events: [event1], - }); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledTimes(1); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledWith([event1]); // Wait for the next periodic flush jest.advanceTimersByTime(1001); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(2); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ - events: [event2], - }); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledTimes(2); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledWith([event2]); }); // The rest of the tests remain unchanged as they don't directly test the maxSize behavior it("should log error if flushing fails", async () => { - mockEventsApi.createEventBatch.mockRejectedValue(new Error("Flush error")); + mockCaptureClient.sendBatch.mockRejectedValue(new Error("Flush error")); - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { logger: mockLogger, interval: 1000, maxRetries: 1, @@ -113,7 +102,7 @@ describe("EventBuffer", () => { }); it("should stop accepting events after stop is called", async () => { - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { interval: 1000, logger: mockLogger, }); @@ -134,11 +123,11 @@ describe("EventBuffer", () => { await buffer.push(event); expect(mockLogger.error).toHaveBeenCalledWith("Event buffer is stopped, not accepting new events"); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledTimes(1); }); it("should periodically flush events", async () => { - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { interval: 1000, logger: mockLogger, }); @@ -156,14 +145,12 @@ describe("EventBuffer", () => { jest.advanceTimersByTime(1000); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ - events: [event], - }); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledTimes(1); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledWith([event]); }); it("should not flush events if shutdown", async () => { - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { interval: 1000, logger: mockLogger, }); @@ -183,7 +170,7 @@ describe("EventBuffer", () => { jest.advanceTimersByTime(1000); - expect(mockEventsApi.createEventBatch).not.toHaveBeenCalled(); + expect(mockCaptureClient.sendBatch).not.toHaveBeenCalled(); }); it("should handle track events with quantity", async () => { @@ -197,7 +184,7 @@ describe("EventBuffer", () => { eventType: "track", sentAt: new Date(), }; - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { logger: mockLogger, interval: 1000, }); @@ -206,17 +193,15 @@ describe("EventBuffer", () => { jest.advanceTimersByTime(1000); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(1); - expect(mockEventsApi.createEventBatch).toHaveBeenCalledWith({ - events: [event], - }); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledTimes(1); + expect(mockCaptureClient.sendBatch).toHaveBeenCalledWith([event]); - const sentEvents = mockEventsApi.createEventBatch.mock.calls[0][0].events; + const sentEvents = mockCaptureClient.sendBatch.mock.calls[0][0]; expect(sentEvents[0].body).toHaveProperty("quantity", 5); }); it("should drop events silently in offline mode", async () => { - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { logger: mockLogger, interval: 1000, offline: true, @@ -238,23 +223,16 @@ describe("EventBuffer", () => { jest.advanceTimersByTime(1000); // Events should never be sent in offline mode - expect(mockEventsApi.createEventBatch).not.toHaveBeenCalled(); + expect(mockCaptureClient.sendBatch).not.toHaveBeenCalled(); }); it("should retry and succeed after a failure", async () => { - const mockResponse = { - data: { - events: [], - }, - params: {}, - }; - // First call fails, second succeeds - mockEventsApi.createEventBatch + mockCaptureClient.sendBatch .mockRejectedValueOnce(new Error("Temporary failure")) - .mockResolvedValueOnce(mockResponse); + .mockResolvedValueOnce(undefined); - const buffer = new EventBuffer(mockEventsApi, { + const buffer = new EventBuffer(mockCaptureClient, { logger: mockLogger, interval: 1000, maxRetries: 3, @@ -276,9 +254,9 @@ describe("EventBuffer", () => { // we can just call flush directly await buffer.flush(); - // Verify that the createEventBatch was called twice (once failed, once succeeded) - expect(mockEventsApi.createEventBatch).toHaveBeenCalledTimes(2); + // Verify that sendBatch was called twice (once failed, once succeeded) + expect(mockCaptureClient.sendBatch).toHaveBeenCalledTimes(2); expect(mockLogger.info).toHaveBeenCalledWith("Event batch submission succeeded after 1 retries"); }); -}); \ No newline at end of file +}); From a5990b2c43e4acc33af44cb1d9683bf6652e6cd2 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 30 Apr 2026 12:30:14 -0600 Subject: [PATCH 2/2] add fern headers to event capture batches --- src/event-capture.ts | 7 +++++++ src/wrapper.ts | 16 +++++++++++++++ tests/unit/event-capture.test.ts | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/event-capture.ts b/src/event-capture.ts index ddb5a4e3..7b568781 100644 --- a/src/event-capture.ts +++ b/src/event-capture.ts @@ -10,6 +10,10 @@ export interface EventCaptureClientOptions { /** Fetcher created by the SchematicClient — reused so that offline mode, * default headers, and retry/logging behavior stay consistent. */ fetcher: FetchFunction; + /** Static headers to include on every request (e.g. X-Fern-SDK-Name, + * X-Fern-SDK-Version) so the capture service receives the same SDK + * identifying headers as the REST API. */ + headers?: Record; baseUrl?: string; timeoutMs?: number; } @@ -84,12 +88,14 @@ export class EventCaptureClient { private readonly baseUrl: string; private readonly timeoutMs: number; private readonly fetcher: FetchFunction; + private readonly headers: Record; constructor(options: EventCaptureClientOptions) { this.apiKey = options.apiKey; this.baseUrl = options.baseUrl ?? DEFAULT_EVENT_CAPTURE_BASE_URL; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.fetcher = options.fetcher; + this.headers = options.headers ?? {}; } public async sendBatch(events: CreateEventRequestBody[]): Promise { @@ -103,6 +109,7 @@ export class EventCaptureClient { contentType: "application/json", requestType: "json", headers: { + ...this.headers, "X-Schematic-Api-Key": this.apiKey, }, body: buildBatch(events, this.apiKey), diff --git a/src/wrapper.ts b/src/wrapper.ts index b6ad05b1..17e9022f 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -135,9 +135,25 @@ export class SchematicClient extends BaseClient { }); this.logger = logger; + + // Forward the same SDK identifying headers (X-Fern-Language, + // X-Fern-SDK-Name, X-Fern-SDK-Version, etc.) that BaseClient added to + // this._options.headers so that capture-service requests are + // attributable to the same SDK build as REST requests. + const sdkHeaders: Record = {}; + const baseClientHeaders = this._options?.headers; + if (baseClientHeaders) { + for (const [key, value] of Object.entries(baseClientHeaders)) { + if (typeof value === "string") { + sdkHeaders[key] = value; + } + } + } + const captureClient = new EventCaptureClient({ apiKey, fetcher, + headers: sdkHeaders, baseUrl: eventCaptureBaseURL, timeoutMs, }); diff --git a/tests/unit/event-capture.test.ts b/tests/unit/event-capture.test.ts index 16f2a39d..f613535d 100644 --- a/tests/unit/event-capture.test.ts +++ b/tests/unit/event-capture.test.ts @@ -115,6 +115,41 @@ describe("EventCaptureClient", () => { }); }); + it("should forward SDK identifying headers alongside the auth header", async () => { + const fetcher = makeFetcher(); + const sdkHeaders = { + "X-Fern-Language": "JavaScript", + "X-Fern-SDK-Name": "test-sdk-name", + "X-Fern-SDK-Version": "test-sdk-version", + "User-Agent": "test-user-agent", + }; + const client = new EventCaptureClient({ apiKey, fetcher, headers: sdkHeaders }); + + await client.sendBatch([buildEvent()]); + + const headers = fetcher.mock.calls[0][0].headers as Record; + for (const key of Object.keys(sdkHeaders)) { + expect(headers[key]).toBeDefined(); + expect(headers[key]).not.toBeNull(); + expect(headers[key]).not.toBe(""); + } + expect(headers["X-Schematic-Api-Key"]).toBe(apiKey); + }); + + it("should let the auth header override any caller-provided X-Schematic-Api-Key", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ + apiKey, + fetcher, + headers: { "X-Schematic-Api-Key": "wrong-key" }, + }); + + await client.sendBatch([buildEvent()]); + + const args = fetcher.mock.calls[0][0]; + expect((args.headers as Record)["X-Schematic-Api-Key"]).toBe(apiKey); + }); + it("should respect a custom base URL and strip trailing slashes", async () => { const fetcher = makeFetcher(); const client = new EventCaptureClient({