diff --git a/src/event-capture.ts b/src/event-capture.ts index 0828a0e3..2c384b5a 100644 --- a/src/event-capture.ts +++ b/src/event-capture.ts @@ -107,7 +107,11 @@ export class EventCaptureClient { constructor(options: EventCaptureClientOptions) { this.apiKey = options.apiKey; - this.baseUrl = options.baseUrl ?? DEFAULT_EVENT_CAPTURE_BASE_URL; + // Treat a blank baseUrl like an unset one: `?? default` only rescues + // null/undefined, so an empty string would slip through and make the + // endpoint `"" + "/batch"` = "/batch", silently dropping every batch. + this.baseUrl = + options.baseUrl && options.baseUrl.trim() !== "" ? options.baseUrl : DEFAULT_EVENT_CAPTURE_BASE_URL; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.fetcher = options.fetcher; this.headers = options.headers ?? {}; diff --git a/src/wrapper.ts b/src/wrapper.ts index 6ba90dda..e06b0429 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -187,15 +187,28 @@ export class SchematicClient extends BaseClient { constructor(opts?: SchematicOptions) { const { apiKey = "", - basePath, + basePath: basePathOpt, eventBufferInterval, - eventCaptureBaseURL, + eventCaptureBaseURL: eventCaptureBaseURLOpt, flagDefaults = {}, logLevel, timeoutMs, } = opts ?? {}; let { offline = false } = opts ?? {}; + // Treat a blank URL option the same as an unset one. Consumers routinely + // forward an env var straight through (`basePath: process.env.X`), which + // is `""` when that var is present-but-empty — an extremely common shape + // (a blank `SCHEMATIC_*_URL=` line copied from a .env template). An empty + // string would otherwise survive the downstream `?? default` (which only + // rescues null/undefined) and produce a broken base URL — e.g. event + // capture's `"" + "/batch"` = "/batch" — silently dropping every event + // batch, including the lease-tagged Track events that settle credit + // usage. Normalizing to undefined lets the built-in prod defaults apply. + const basePath = basePathOpt && basePathOpt.trim() !== "" ? basePathOpt : undefined; + const eventCaptureBaseURL = + eventCaptureBaseURLOpt && eventCaptureBaseURLOpt.trim() !== "" ? eventCaptureBaseURLOpt : undefined; + // A consumer-provided logger owns its own level configuration, so we use // it as-is and ignore logLevel. Otherwise build the default // ConsoleLogger at the requested level (defaulting to "warn"). diff --git a/tests/unit/event-capture.test.ts b/tests/unit/event-capture.test.ts index ec473276..894ba876 100644 --- a/tests/unit/event-capture.test.ts +++ b/tests/unit/event-capture.test.ts @@ -1,10 +1,7 @@ /* eslint @typescript-eslint/no-explicit-any: 0 */ import { CreateEventRequestBody } from "../../src/api"; -import { - DEFAULT_EVENT_CAPTURE_BASE_URL, - EventCaptureClient, -} from "../../src/event-capture"; +import { DEFAULT_EVENT_CAPTURE_BASE_URL, EventCaptureClient } from "../../src/event-capture"; import type { FetchFunction } from "../../src/core/fetcher/Fetcher"; describe("EventCaptureClient", () => { @@ -25,7 +22,9 @@ describe("EventCaptureClient", () => { 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; + const fn = jest.fn( + impl ?? (() => Promise.resolve(okResponse)), + ) as unknown as jest.MockedFunction; return fn; }; @@ -164,6 +163,23 @@ describe("EventCaptureClient", () => { expect(args.url).toBe("https://custom.example.com/batch"); }); + // A consumer that forwards `baseUrl: process.env.X` passes "" when the env + // var is present-but-empty. It must fall back to the default, not build a + // broken "/batch" endpoint that silently drops every batch. + it.each([ + undefined, + "", + " ", + ])("should fall back to the default base URL when baseUrl is blank (%p)", async (baseUrl) => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher, baseUrl }); + + await client.sendBatch([buildEvent()]); + + const args = fetcher.mock.calls[0][0]; + expect(args.url).toBe(`${DEFAULT_EVENT_CAPTURE_BASE_URL}/batch`); + }); + it("should be a no-op when sending an empty batch", async () => { const fetcher = makeFetcher(); const client = new EventCaptureClient({ apiKey, fetcher }); @@ -183,9 +199,7 @@ describe("EventCaptureClient", () => { ); const client = new EventCaptureClient({ apiKey, fetcher }); - await expect(client.sendBatch([buildEvent()])).rejects.toThrow( - "capture service returned HTTP 500: boom", - ); + await expect(client.sendBatch([buildEvent()])).rejects.toThrow("capture service returned HTTP 500: boom"); }); it("should surface fetcher timeouts", async () => { @@ -198,9 +212,7 @@ describe("EventCaptureClient", () => { ); const client = new EventCaptureClient({ apiKey, fetcher }); - await expect(client.sendBatch([buildEvent()])).rejects.toThrow( - "capture service returned request timed out", - ); + await expect(client.sendBatch([buildEvent()])).rejects.toThrow("capture service returned request timed out"); }); it("should surface unknown fetcher errors", async () => { @@ -213,9 +225,7 @@ describe("EventCaptureClient", () => { ); const client = new EventCaptureClient({ apiKey, fetcher }); - await expect(client.sendBatch([buildEvent()])).rejects.toThrow( - "capture service returned network down", - ); + await expect(client.sendBatch([buildEvent()])).rejects.toThrow("capture service returned network down"); }); it("should serialize idempotency_key, trusted_client_clock, and backfill when set", async () => {