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
1 change: 1 addition & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -234,6 +246,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:
Expand Down
77 changes: 74 additions & 3 deletions src/datastream/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +46,11 @@ export function partialCompany(
): Schematic.RulesengineCompany {
const merged = deepCopyCompany(existing) as unknown as Record<string, unknown>;

// 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<string, number> | undefined;
let metricsUpdated = false;

for (const key of Object.keys(partial)) {
switch (key) {
case "id":
Expand Down Expand Up @@ -67,24 +81,81 @@ export function partialCompany(
string,
number
>;
const incomingCB = partial[key] as Record<string, number>;
const incomingCB = (partial[key] ?? {}) as Record<string, number>;
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<string, unknown>,
updatedBalances: Record<string, number> | undefined,
metricsUpdated: boolean,
): void {
const entitlements = (getProp(merged, "entitlements", "entitlements") ?? []) as Record<string, unknown>[];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do a tighter type constraint than unknown here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this derives from an issue with the types that we're caching that is being fixed in another PR. so we should fix it when that is resolved.

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<string, number>();
if (metricsUpdated) {
const mergedMetrics = (getProp(merged, "metrics", "metrics") ?? []) as Record<string, unknown>[];
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];
}

// 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";
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
Expand Down
15 changes: 15 additions & 0 deletions src/event-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
};

Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
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 { ConsoleLogger, LogLevel, type Logger } from "./logger";
export { SchematicEnvironment } from "./environments";
export { SchematicError, SchematicTimeoutError } from "./errors";
export { RulesEngineClient } from "./rules-engine";
Expand Down
52 changes: 48 additions & 4 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,71 @@
/* 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, number> = {
[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;
info(message: string, ...args: any[]): void;
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);
}
}
}

Expand Down
Loading