From 9d80134ff351053effc490aa9b3144aca524c5b1 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 08:06:21 -0600 Subject: [PATCH 1/4] handle partial messages properly --- src/datastream/merge.ts | 75 ++++++++++++++++++- tests/unit/datastream/merge.test.ts | 109 ++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/src/datastream/merge.ts b/src/datastream/merge.ts index 90599b88..66f299dc 100644 --- a/src/datastream/merge.ts +++ b/src/datastream/merge.ts @@ -25,7 +25,16 @@ export function deepCopyUser(u: Schematic.RulesengineUser): Schematic.Rulesengin /** * Merges a partial update into an existing Company. * Deep-copies the existing company, then applies only the fields - * present in the partial object. + * present in the partial object. Maps (keys, credit_balances) merge + * additively. Metrics are upserted by (eventSubtype, period, monthReset). + * All other fields replace the existing value. The original is not mutated. + * + * Partials don't carry refreshed entitlements, so when their derived fields + * change in another part of the company we sync them here to match server + * behavior: + * - credit_remaining ← credit_balances[credit_id] + * - usage ← metric value matching (event_name, metric_period, month_reset) + * Both are skipped when the partial also sends entitlements wholesale. * * Wire format uses snake_case keys. The existing company from cache * may have either camelCase or snake_case keys depending on how it @@ -37,6 +46,11 @@ export function partialCompany( ): Schematic.RulesengineCompany { const merged = deepCopyCompany(existing) as unknown as Record; + // The incoming credit balances (only the keys present in this partial), and + // whether metrics were touched. Used below to re-derive entitlement fields. + let updatedBalances: Record | undefined; + let metricsUpdated = false; + for (const key of Object.keys(partial)) { switch (key) { case "id": @@ -67,24 +81,79 @@ export function partialCompany( string, number >; - const incomingCB = partial[key] as Record; + const incomingCB = (partial[key] ?? {}) as Record; merged[key] = { ...existingCB, ...incomingCB }; + updatedBalances = incomingCB; break; } case "metrics": { const existingMetrics = ((getProp(merged, "metrics", "metrics") as unknown[]) ?? []) as Schematic.RulesengineCompanyMetric[]; - const incomingMetrics = partial[key] as Schematic.RulesengineCompanyMetric[]; + const incomingMetrics = (partial[key] ?? []) as Schematic.RulesengineCompanyMetric[]; merged[key] = upsertMetrics(existingMetrics, incomingMetrics); + metricsUpdated = true; break; } // Ignore unknown keys silently } } + if ((updatedBalances || metricsUpdated) && !("entitlements" in partial)) { + syncEntitlementDerivedFields(merged, updatedBalances, metricsUpdated); + } + return merged as unknown as Schematic.RulesengineCompany; } +/** + * Re-derives entitlement fields whose source data changed in a partial that + * did not itself carry fresh entitlements. Mutates the entitlement objects on + * the already-deep-copied `merged` company in place: + * - credit_remaining ← the incoming balance for the entitlement's credit_id + * - usage ← the merged metric value matching (event_name, metric_period, month_reset), + * defaulting metric_period to "all_time" and month_reset to "first_of_month" + */ +function syncEntitlementDerivedFields( + merged: Record, + updatedBalances: Record | undefined, + metricsUpdated: boolean, +): void { + const entitlements = (getProp(merged, "entitlements", "entitlements") ?? []) as Record[]; + if (entitlements.length === 0) { + return; + } + + // Build a value lookup from the merged metrics, keyed the same way as the + // upsert so entitlements can find their matching usage. + const metricsLookup = new Map(); + if (metricsUpdated) { + const mergedMetrics = (getProp(merged, "metrics", "metrics") ?? []) as Record[]; + for (const m of mergedMetrics) { + if (!m) continue; + metricsLookup.set(metricKeyString(getMetricKey(m)), (m.value as number) ?? 0); + } + } + + for (const ent of entitlements) { + const creditId = (ent.creditId ?? ent.credit_id) as string | undefined; + if (updatedBalances && creditId && creditId in updatedBalances) { + ent.credit_remaining = updatedBalances[creditId]; + } + + const eventName = (ent.eventName ?? ent.event_name) as string | undefined; + if (metricsLookup.size > 0 && eventName) { + const period = ((ent.metricPeriod ?? ent.metric_period) as string) || "all_time"; + const monthReset = ((ent.monthReset ?? ent.month_reset) as string) || "first_of_month"; + const matched = metricsLookup.get( + metricKeyString({ eventSubtype: eventName, period, monthReset }), + ); + if (matched !== undefined) { + ent.usage = matched; + } + } + } +} + /** * Merges a partial update into an existing User. * Deep-copies the existing user, then applies only the fields diff --git a/tests/unit/datastream/merge.test.ts b/tests/unit/datastream/merge.test.ts index 8b9216b4..caec17a4 100644 --- a/tests/unit/datastream/merge.test.ts +++ b/tests/unit/datastream/merge.test.ts @@ -161,6 +161,115 @@ describe('partialCompany', () => { expect(origMetrics[0].value).toBe(10); }); + test('credit_balances update re-derives entitlement credit_remaining', () => { + const existing = baseCompany(); + (existing as unknown as Record).entitlements = [ + { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'credit', credit_id: 'credit-1', credit_remaining: 100.0 }, + { feature_id: 'feat-2', feature_key: 'feature-two', value_type: 'credit', credit_id: 'credit-2', credit_remaining: 0 }, + { feature_id: 'feat-3', feature_key: 'feature-three', value_type: 'boolean' }, + ]; + + // Partial only updates one of the credit balances and carries no entitlements. + const partial = { id: 'co-1', credit_balances: { 'credit-1': 42.0 } }; + + const merged = partialCompany(existing, partial); + const ents = (merged as unknown as Record).entitlements as Record[]; + + // credit-1 entitlement re-derived from incoming balance + expect(ents[0].credit_remaining).toBe(42.0); + // credit-2 was not in the partial, left untouched + expect(ents[1].credit_remaining).toBe(0); + // non-credit entitlement untouched + expect(ents[2].credit_remaining).toBeUndefined(); + + // Original not mutated + const origEnts = (existing as unknown as Record).entitlements as Record[]; + expect(origEnts[0].credit_remaining).toBe(100.0); + }); + + test('metrics update re-derives entitlement usage', () => { + const existing = baseCompany(); + (existing as unknown as Record).metrics = [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'api-calls', period: 'current_month', month_reset: 'first_of_month', + value: 10, created_at: '2026-01-01T00:00:00Z', + }, + ]; + (existing as unknown as Record).entitlements = [ + { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'numeric', event_name: 'api-calls', metric_period: 'current_month', month_reset: 'first_of_month', usage: 10 }, + { feature_id: 'feat-2', feature_key: 'feature-two', value_type: 'numeric', event_name: 'other-event', metric_period: 'current_month', month_reset: 'first_of_month', usage: 3 }, + ]; + + // Partial upserts the api-calls metric and carries no entitlements. + const partial = { + id: 'co-1', + metrics: [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'api-calls', period: 'current_month', month_reset: 'first_of_month', + value: 99, created_at: '2026-01-02T00:00:00Z', + }, + ], + }; + + const merged = partialCompany(existing, partial); + const ents = (merged as unknown as Record).entitlements as Record[]; + + // matching entitlement usage synced to the merged metric value + expect(ents[0].usage).toBe(99); + // entitlement with no matching metric is unchanged + expect(ents[1].usage).toBe(3); + }); + + test('metrics update applies metric_period/month_reset defaults when entitlement omits them', () => { + const existing = baseCompany(); + (existing as unknown as Record).metrics = []; + (existing as unknown as Record).entitlements = [ + // No metric_period / month_reset → should default to all_time / first_of_month + { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'numeric', event_name: 'logins', usage: 0 }, + ]; + + const partial = { + id: 'co-1', + metrics: [ + { + account_id: 'acc-1', environment_id: 'env-1', company_id: 'co-1', + event_subtype: 'logins', period: 'all_time', month_reset: 'first_of_month', + value: 7, created_at: '2026-01-02T00:00:00Z', + }, + ], + }; + + const merged = partialCompany(existing, partial); + const ents = (merged as unknown as Record).entitlements as Record[]; + + expect(ents[0].usage).toBe(7); + }); + + test('does not re-derive when partial carries entitlements wholesale', () => { + const existing = baseCompany(); + (existing as unknown as Record).entitlements = [ + { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'credit', credit_id: 'credit-1', credit_remaining: 100.0 }, + ]; + + // Partial includes BOTH credit_balances and entitlements; the supplied + // entitlements win and the derived sync is skipped entirely. + const partial = { + id: 'co-1', + credit_balances: { 'credit-1': 42.0 }, + entitlements: [ + { feature_id: 'feat-1', feature_key: 'feature-one', value_type: 'credit', credit_id: 'credit-1', credit_remaining: 7.0 }, + ], + }; + + const merged = partialCompany(existing, partial); + const ents = (merged as unknown as Record).entitlements as Record[]; + + // Uses the entitlement value from the partial, NOT the balance-derived 42. + expect(ents[0].credit_remaining).toBe(7.0); + }); + test('empty entitlements clears existing', () => { const existing = baseCompany(); const partial = { id: 'co-1', entitlements: [] }; From ee280135fc5a9f1693fd8c1f156bccd474128118 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 08:26:23 -0600 Subject: [PATCH 2/4] add extra options to track and identify events --- README.md | 36 +++++++++++++++++ src/event-capture.ts | 15 +++++++ src/index.ts | 8 +++- src/wrapper.ts | 58 +++++++++++++++++++++++---- tests/unit/event-capture.test.ts | 43 ++++++++++++++++++++ tests/unit/wrapper.test.ts | 68 ++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cb11debf..418b9e1d 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,42 @@ client.track({ }); ``` +Both `track` and `identify` accept an optional second argument for event metadata. Supply an `idempotencyKey` to deduplicate events (duplicates with the same key, scoped to the environment, are dropped server-side for 24 hours): + +```ts +client.track( + { + event: "some-action", + company: { id: "your-company-id" }, + }, + { idempotencyKey: "your-unique-key" }, +); + +client.identify( + { + keys: { userId: "your-user-id" }, + name: "Wile E. Coyote", + }, + { idempotencyKey: "your-unique-key" }, +); +``` + +For `track`, you can also set a trusted client clock to use your own timestamp as the effective event time, and backfill historical data without affecting billing. Both require a secret API key: + +```ts +client.track( + { + event: "some-action", + company: { id: "your-company-id" }, + }, + { + sentAt: new Date("2026-01-01T00:00:00Z"), + trustedClientClock: true, + backfill: true, + }, +); +``` + ### Creating and updating companies Although it is faster to create companies and users via identify events, if you need to handle a response, you can use the companies API to upsert companies. Because you use your own identifiers to identify companies, rather than a Schematic company ID, creating and updating companies are both done via the same upsert operation: diff --git a/src/event-capture.ts b/src/event-capture.ts index 7b568781..0828a0e3 100644 --- a/src/event-capture.ts +++ b/src/event-capture.ts @@ -22,7 +22,10 @@ interface CapturePayload { api_key: string; type: EventType; body?: unknown; + idempotency_key?: string; sent_at?: string; + trusted_client_clock?: boolean; + backfill?: boolean; } interface BatchPayload { @@ -45,10 +48,22 @@ const toCapturePayload = (event: CreateEventRequestBody, apiKey: string): Captur }); } + if (event.idempotencyKey !== undefined) { + payload.idempotency_key = event.idempotencyKey; + } + if (event.sentAt !== undefined) { payload.sent_at = event.sentAt instanceof Date ? event.sentAt.toISOString() : event.sentAt; } + if (event.trustedClientClock !== undefined) { + payload.trusted_client_clock = event.trustedClientClock; + } + + if (event.backfill !== undefined) { + payload.backfill = event.backfill; + } + return payload; }; diff --git a/src/index.ts b/src/index.ts index a68aff6e..95768ea2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ export * as Schematic from "./api"; export { LocalCache } from "./cache/local"; export { RedisCacheProvider, type RedisClient } from "./cache/redis"; -export { SchematicClient, type CheckFlagWithEntitlementResponse } from "./wrapper"; +export { + SchematicClient, + type CheckFlagWithEntitlementResponse, + type CheckFlagOptions, + type TrackOptions, + type IdentifyOptions, +} from "./wrapper"; export { SchematicEnvironment } from "./environments"; export { SchematicError, SchematicTimeoutError } from "./errors"; export { RulesEngineClient } from "./rules-engine"; diff --git a/src/wrapper.ts b/src/wrapper.ts index 17e9022f..f5448e8c 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -65,6 +65,30 @@ export interface CheckFlagOptions { timeoutMs?: number; } +/** + * Optional metadata for a track event. Only fields that are explicitly set are + * sent to the capture service. + */ +export interface TrackOptions { + /** Client-supplied dedupe key. Duplicate events with the same key (scoped to the environment) are dropped server-side for 24 hours. */ + idempotencyKey?: string; + /** Timestamp the event was sent. Required when trustedClientClock is true. Defaults to the time the event is enqueued. */ + sentAt?: Date; + /** When true, use sentAt as the effective event timestamp instead of server receipt time. Requires a secret API key and sentAt. */ + trustedClientClock?: boolean; + /** Import historical data without affecting billing. Requires a secret API key and trustedClientClock. */ + backfill?: boolean; +} + +/** + * Optional metadata for an identify event. Only fields that are explicitly set + * are sent to the capture service. + */ +export interface IdentifyOptions { + /** Client-supplied dedupe key. Duplicate events with the same key (scoped to the environment) are dropped server-side for 24 hours. */ + idempotencyKey?: string; +} + export interface CheckFlagWithEntitlementResponse { companyId?: string; entitlement?: api.RulesengineFeatureEntitlement; @@ -548,14 +572,15 @@ export class SchematicClient extends BaseClient { /** * Send a non-blocking event to create or update companies and users * @param body - The identify event payload containing user properties + * @param options - Optional event metadata (e.g. idempotencyKey) * @returns Promise that resolves when the event has been enqueued * @throws Will log error if event enqueueing fails */ - async identify(body: api.EventBodyIdentify): Promise { + async identify(body: api.EventBodyIdentify, options?: IdentifyOptions): Promise { if (this.offline) return; try { - await this.enqueueEvent("identify", body); + await this.enqueueEvent("identify", body, options); } catch (err) { this.logger.error(`Error sending identify event: ${err}`); } @@ -564,14 +589,15 @@ export class SchematicClient extends BaseClient { /** * Send a non-blocking event to track usage * @param body - The track event payload containing event details + * @param options - Optional event metadata (e.g. idempotencyKey, trustedClientClock) * @returns Promise that resolves when the event has been enqueued * @throws Will log error if event enqueueing fails */ - async track(body: api.EventBodyTrack): Promise { + async track(body: api.EventBodyTrack, options?: TrackOptions): Promise { if (this.offline) return; try { - await this.enqueueEvent("track", body); + await this.enqueueEvent("track", body, options); // Update company metrics in DataStream if available and connected if (body.company && this.useDataStream() && this.datastreamClient!.isConnected()) { @@ -613,14 +639,32 @@ export class SchematicClient extends BaseClient { private async enqueueEvent( eventType: api.EventType, - body: api.EventBody + body: api.EventBody, + options?: TrackOptions | IdentifyOptions, ): Promise { try { - this.eventBuffer.push({ + const event: api.CreateEventRequestBody = { eventType, body, sentAt: new Date(), - }); + }; + + if (options) { + if (options.idempotencyKey !== undefined) { + event.idempotencyKey = options.idempotencyKey; + } + if ("sentAt" in options && options.sentAt !== undefined) { + event.sentAt = options.sentAt; + } + if ("trustedClientClock" in options && options.trustedClientClock !== undefined) { + event.trustedClientClock = options.trustedClientClock; + } + if ("backfill" in options && options.backfill !== undefined) { + event.backfill = options.backfill; + } + } + + this.eventBuffer.push(event); } catch (err) { this.logger.error(`Error enqueueing ${eventType} event: ${err}`); } diff --git a/tests/unit/event-capture.test.ts b/tests/unit/event-capture.test.ts index f613535d..ec473276 100644 --- a/tests/unit/event-capture.test.ts +++ b/tests/unit/event-capture.test.ts @@ -218,6 +218,49 @@ describe("EventCaptureClient", () => { ); }); + it("should serialize idempotency_key, trusted_client_clock, and backfill when set", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await client.sendBatch([ + buildEvent({ + idempotencyKey: "dedupe-123", + trustedClientClock: true, + backfill: true, + }), + ]); + + const args = fetcher.mock.calls[0][0]; + const batch = args.body as { events: any[] }; + expect(batch.events[0]).toEqual({ + api_key: apiKey, + type: "track", + body: { + company: { id: "company-1" }, + event: "test-event", + user: { id: "user-1" }, + quantity: 2, + }, + idempotency_key: "dedupe-123", + sent_at: "2026-04-28T12:00:00.000Z", + trusted_client_clock: true, + backfill: true, + }); + }); + + it("should omit idempotency_key, trusted_client_clock, and backfill when unset", async () => { + const fetcher = makeFetcher(); + const client = new EventCaptureClient({ apiKey, fetcher }); + + await client.sendBatch([buildEvent()]); + + const args = fetcher.mock.calls[0][0]; + const batch = args.body as { events: any[] }; + expect(batch.events[0]).not.toHaveProperty("idempotency_key"); + expect(batch.events[0]).not.toHaveProperty("trusted_client_clock"); + expect(batch.events[0]).not.toHaveProperty("backfill"); + }); + it("should omit body when the event has no body", async () => { const fetcher = makeFetcher(); const client = new EventCaptureClient({ apiKey, fetcher }); diff --git a/tests/unit/wrapper.test.ts b/tests/unit/wrapper.test.ts index 3cbc0bee..e07c5ddb 100644 --- a/tests/unit/wrapper.test.ts +++ b/tests/unit/wrapper.test.ts @@ -188,4 +188,72 @@ describe("SchematicClient wrapper - flag checking behavior", () => { await client.close(); }); }); + + describe("event options", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventBuffer } = require("../../src/events"); + + const lastPushedEvent = (): any => { + const buffer = (EventBuffer as jest.Mock).mock.results[0].value; + const pushMock = buffer.push as jest.Mock; + return pushMock.mock.calls[pushMock.mock.calls.length - 1][0]; + }; + + it("should thread track options into the buffered event", async () => { + const client = new SchematicClient({ apiKey: "test-api-key", logger: mockLogger }); + const sentAt = new Date("2026-04-28T12:00:00.000Z"); + + await client.track( + { event: "used-feature", company: { id: "comp-1" } }, + { + idempotencyKey: "dedupe-abc", + sentAt, + trustedClientClock: true, + backfill: true, + }, + ); + + expect(lastPushedEvent()).toEqual({ + eventType: "track", + body: { event: "used-feature", company: { id: "comp-1" } }, + idempotencyKey: "dedupe-abc", + sentAt, + trustedClientClock: true, + backfill: true, + }); + + await client.close(); + }); + + it("should thread identify idempotencyKey into the buffered event", async () => { + const client = new SchematicClient({ apiKey: "test-api-key", logger: mockLogger }); + + await client.identify( + { keys: { id: "user-1" }, name: "Test User" }, + { idempotencyKey: "dedupe-xyz" }, + ); + + const event = lastPushedEvent(); + expect(event.eventType).toBe("identify"); + expect(event.idempotencyKey).toBe("dedupe-xyz"); + expect(event.trustedClientClock).toBeUndefined(); + expect(event.backfill).toBeUndefined(); + + await client.close(); + }); + + it("should default sentAt and omit optional fields when no options are passed", async () => { + const client = new SchematicClient({ apiKey: "test-api-key", logger: mockLogger }); + + await client.track({ event: "used-feature", company: { id: "comp-1" } }); + + const event = lastPushedEvent(); + expect(event.sentAt).toBeInstanceOf(Date); + expect(event).not.toHaveProperty("idempotencyKey"); + expect(event).not.toHaveProperty("trustedClientClock"); + expect(event).not.toHaveProperty("backfill"); + + await client.close(); + }); + }); }); \ No newline at end of file From 689fac0ca9573b1e07490411fca36c6b48d8abec Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 08:41:56 -0600 Subject: [PATCH 3/4] logger improvements --- .fernignore | 1 + README.md | 14 ++++++- src/index.ts | 1 + src/logger.ts | 52 ++++++++++++++++++++++-- src/wrapper.ts | 13 ++++-- tests/unit/logger.test.ts | 83 ++++++++++++++++++++++++++++++++++++++ tests/unit/wrapper.test.ts | 65 +++++++++++++++++++++++++++++ 7 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 tests/unit/logger.test.ts diff --git a/.fernignore b/.fernignore index 69d2170d..a7be3e5b 100644 --- a/.fernignore +++ b/.fernignore @@ -33,6 +33,7 @@ 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/logger.test.ts tests/unit/rules-engine.test.ts tests/unit/wasm-integration.test.ts tests/unit/webhooks.test.ts diff --git a/README.md b/README.md index 418b9e1d..b3b02941 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,19 @@ const client = new SchematicClient({ client.close(); ``` -If no logger is provided, the client will use a default console logger that outputs to the standard console methods. +If no logger is provided, the client uses a default console logger. By default it only emits `warn` and `error` messages; `debug` and `info` are suppressed to keep production output quiet. Use the `logLevel` option to raise or lower the verbosity of the default logger: + +```ts +import { SchematicClient, LogLevel } from "@schematichq/schematic-typescript-node"; + +const apiKey = process.env.SCHEMATIC_API_KEY; +const client = new SchematicClient({ + apiKey, + logLevel: "debug", // or LogLevel.Debug — emit all levels (debug, info, warn, error) +}); +``` + +The `logLevel` option only affects the default console logger. When you supply your own `logger`, its level configuration is respected as-is and `logLevel` is ignored. ## Usage examples diff --git a/src/index.ts b/src/index.ts index 95768ea2..4359e33b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { type TrackOptions, type IdentifyOptions, } from "./wrapper"; +export { ConsoleLogger, LogLevel, type Logger } from "./logger"; export { SchematicEnvironment } from "./environments"; export { SchematicError, SchematicTimeoutError } from "./errors"; export { RulesEngineClient } from "./rules-engine"; diff --git a/src/logger.ts b/src/logger.ts index 507d0b47..f2466ff0 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,5 +1,27 @@ /* eslint-disable */ +export const LogLevel = { + Debug: "debug", + Info: "info", + Warn: "warn", + Error: "error", +} as const; +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +const LOG_LEVEL_PRIORITY: Record = { + [LogLevel.Debug]: 1, + [LogLevel.Info]: 2, + [LogLevel.Warn]: 3, + [LogLevel.Error]: 4, +}; + +/** + * Default level for the built-in ConsoleLogger. Messages below this level + * (i.e. debug and info) are suppressed unless the consumer opts in via the + * `logLevel` option, keeping production output quiet by default. + */ +export const DEFAULT_LOG_LEVEL: LogLevel = LogLevel.Warn; + export interface Logger { error(message: string, ...args: any[]): void; warn(message: string, ...args: any[]): void; @@ -7,21 +29,43 @@ export interface Logger { debug(message: string, ...args: any[]): void; } +/** + * Console-based Logger that filters messages by configured level. Defaults to + * `warn`, so debug/info are dropped unless a lower level is requested. + */ class ConsoleLogger implements Logger { + private readonly level: number; + + constructor(level: LogLevel = DEFAULT_LOG_LEVEL) { + this.level = LOG_LEVEL_PRIORITY[level] ?? LOG_LEVEL_PRIORITY[DEFAULT_LOG_LEVEL]; + } + + private shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= this.level; + } + error(message: string, ...args: any[]): void { - console.error(message, ...args); + if (this.shouldLog(LogLevel.Error)) { + console.error(message, ...args); + } } warn(message: string, ...args: any[]): void { - console.warn(message, ...args); + if (this.shouldLog(LogLevel.Warn)) { + console.warn(message, ...args); + } } info(message: string, ...args: any[]): void { - console.info(message, ...args); + if (this.shouldLog(LogLevel.Info)) { + console.info(message, ...args); + } } debug(message: string, ...args: any[]): void { - console.debug(message, ...args); + if (this.shouldLog(LogLevel.Debug)) { + console.debug(message, ...args); + } } } diff --git a/src/wrapper.ts b/src/wrapper.ts index f5448e8c..645d9d49 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -2,7 +2,7 @@ import * as api from "./api"; import { SchematicClient as BaseClient } from "./Client"; import { type CacheProvider, LocalCache } from "./cache"; -import { ConsoleLogger, Logger } from "./logger"; +import { ConsoleLogger, Logger, LogLevel } from "./logger"; import { EventBuffer } from "./events"; import { EventCaptureClient } from "./event-capture"; import { offlineFetcher, provideFetcher } from "./core/fetcher/custom"; @@ -50,8 +50,10 @@ export interface SchematicOptions { flagDefaults?: { [key: string]: boolean }; /** Additional HTTP headers for API requests */ headers?: Record; - /** Custom logger implementation */ + /** Custom logger implementation. When provided, its own level configuration is respected and `logLevel` is ignored. */ logger?: Logger; + /** Minimum level for the default logger (default: "warn"). Ignored when a custom `logger` is provided. */ + logLevel?: LogLevel; /** Enable offline mode to prevent network activity */ offline?: boolean; /** The default maximum time to wait for a response in milliseconds */ @@ -121,11 +123,16 @@ export class SchematicClient extends BaseClient { eventBufferInterval, eventCaptureBaseURL, flagDefaults = {}, - logger = new ConsoleLogger(), + logLevel, timeoutMs, } = opts ?? {}; let { offline = false } = opts ?? {}; + // 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"). + const logger: Logger = opts?.logger ?? new ConsoleLogger(logLevel); + // Set headers const headers: Record = {}; if (opts?.environmentId) { diff --git a/tests/unit/logger.test.ts b/tests/unit/logger.test.ts new file mode 100644 index 00000000..f73b95d0 --- /dev/null +++ b/tests/unit/logger.test.ts @@ -0,0 +1,83 @@ +import { ConsoleLogger, DEFAULT_LOG_LEVEL, LogLevel } from "../../src/logger"; + +describe("ConsoleLogger (consumer-facing)", () => { + let consoleSpy: { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + consoleSpy = { + debug: jest.spyOn(console, "debug").mockImplementation(() => {}), + info: jest.spyOn(console, "info").mockImplementation(() => {}), + warn: jest.spyOn(console, "warn").mockImplementation(() => {}), + error: jest.spyOn(console, "error").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should default to the warn level", () => { + expect(DEFAULT_LOG_LEVEL).toBe(LogLevel.Warn); + }); + + it("should suppress debug and info but emit warn and error by default", () => { + const logger = new ConsoleLogger(); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(consoleSpy.debug).not.toHaveBeenCalled(); + expect(consoleSpy.info).not.toHaveBeenCalled(); + expect(consoleSpy.warn).toHaveBeenCalledWith("warn"); + expect(consoleSpy.error).toHaveBeenCalledWith("error"); + }); + + it("should emit every level when configured to debug", () => { + const logger = new ConsoleLogger(LogLevel.Debug); + + logger.debug("debug", { a: 1 }); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(consoleSpy.debug).toHaveBeenCalledWith("debug", { a: 1 }); + expect(consoleSpy.info).toHaveBeenCalledWith("info"); + expect(consoleSpy.warn).toHaveBeenCalledWith("warn"); + expect(consoleSpy.error).toHaveBeenCalledWith("error"); + }); + + it("should only emit error when configured to error", () => { + const logger = new ConsoleLogger(LogLevel.Error); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(consoleSpy.debug).not.toHaveBeenCalled(); + expect(consoleSpy.info).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.error).toHaveBeenCalledWith("error"); + }); + + it("should emit info, warn, and error when configured to info", () => { + const logger = new ConsoleLogger(LogLevel.Info); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(consoleSpy.debug).not.toHaveBeenCalled(); + expect(consoleSpy.info).toHaveBeenCalledWith("info"); + expect(consoleSpy.warn).toHaveBeenCalledWith("warn"); + expect(consoleSpy.error).toHaveBeenCalledWith("error"); + }); +}); diff --git a/tests/unit/wrapper.test.ts b/tests/unit/wrapper.test.ts index e07c5ddb..e7033088 100644 --- a/tests/unit/wrapper.test.ts +++ b/tests/unit/wrapper.test.ts @@ -256,4 +256,69 @@ describe("SchematicClient wrapper - flag checking behavior", () => { await client.close(); }); }); +}); + +describe("SchematicClient wrapper - logger configuration", () => { + let consoleSpy: { + debug: ReturnType; + warn: ReturnType; + }; + + beforeEach(() => { + consoleSpy = { + debug: jest.spyOn(console, "debug").mockImplementation(() => {}), + warn: jest.spyOn(console, "warn").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it("should suppress debug logs from the default logger (defaults to warn)", async () => { + const client = new SchematicClient({ offline: true }); + + // Offline checkFlag logs at debug level, which the default warn logger drops. + await client.checkFlag({}, "some-flag"); + + expect(consoleSpy.debug).not.toHaveBeenCalled(); + + await client.close(); + }); + + it("should emit debug logs from the default logger when logLevel is debug", async () => { + const client = new SchematicClient({ offline: true, logLevel: "debug" }); + + await client.checkFlag({}, "some-flag"); + + expect(consoleSpy.debug).toHaveBeenCalled(); + + await client.close(); + }); + + it("should call a custom logger's methods directly, ignoring logLevel", async () => { + const customLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + // logLevel is set to warn, but the SDK must not filter a custom logger — + // the custom logger owns its own level. + const client = new SchematicClient({ + offline: true, + logLevel: "warn", + logger: customLogger, + }); + + await client.checkFlag({}, "some-flag"); + + expect(customLogger.debug).toHaveBeenCalled(); + // The built-in console must not be used when a custom logger is provided. + expect(consoleSpy.debug).not.toHaveBeenCalled(); + + await client.close(); + }); }); \ No newline at end of file From 0529f973e51bbf6cb1ba1e94f782f0a661b7a644 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Thu, 28 May 2026 09:13:28 -0600 Subject: [PATCH 4/4] fix imports --- src/datastream/merge.ts | 2 ++ src/wrapper.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/datastream/merge.ts b/src/datastream/merge.ts index 66f299dc..e579b4a9 100644 --- a/src/datastream/merge.ts +++ b/src/datastream/merge.ts @@ -140,6 +140,8 @@ function syncEntitlementDerivedFields( ent.credit_remaining = updatedBalances[creditId]; } + // Credit-attached entitlements are intentionally NOT skipped: usage here + // reflects the raw event count for the entitlement's event, not credits used. const eventName = (ent.eventName ?? ent.event_name) as string | undefined; if (metricsLookup.size > 0 && eventName) { const period = ((ent.metricPeriod ?? ent.metric_period) as string) || "all_time"; diff --git a/src/wrapper.ts b/src/wrapper.ts index 645d9d49..f15975b9 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -2,7 +2,7 @@ import * as api from "./api"; import { SchematicClient as BaseClient } from "./Client"; import { type CacheProvider, LocalCache } from "./cache"; -import { ConsoleLogger, Logger, LogLevel } from "./logger"; +import { ConsoleLogger, type Logger, type LogLevel } from "./logger"; import { EventBuffer } from "./events"; import { EventCaptureClient } from "./event-capture"; import { offlineFetcher, provideFetcher } from "./core/fetcher/custom";