Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/event-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};
Expand Down
17 changes: 15 additions & 2 deletions src/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
38 changes: 24 additions & 14 deletions tests/unit/event-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -25,7 +22,9 @@ describe("EventCaptureClient", () => {
const okResponse = { ok: true, body: {}, headers: {}, rawResponse: {} as any };

const makeFetcher = (impl?: (...args: any[]) => any): jest.MockedFunction<FetchFunction> => {
const fn = jest.fn(impl ?? (() => Promise.resolve(okResponse))) as unknown as jest.MockedFunction<FetchFunction>;
const fn = jest.fn(
impl ?? (() => Promise.resolve(okResponse)),
) as unknown as jest.MockedFunction<FetchFunction>;
return fn;
};

Expand Down Expand Up @@ -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 });
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down