Skip to content

Redesign SDK: scoped Effect core, non-blocking track, Standard Schema support#20

Merged
deepso7 merged 6 commits into
mainfrom
sdk-redesign
Jul 3, 2026
Merged

Redesign SDK: scoped Effect core, non-blocking track, Standard Schema support#20
deepso7 merged 6 commits into
mainfrom
sdk-redesign

Conversation

@deepso7

@deepso7 deepso7 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Ground-up redesign of both entry points for better DX, performance, and universal runtime support (Node.js + browsers). Breaking changes throughout.

Core fix: non-blocking track

Previously track drained the queue inline when the batch threshold was hit, so a "fire and forget" call could wait on HTTP delivery and retries. Delivery now runs on a dedicated background fiber (Latch-signaled) — track only validates, stamps, and enqueues.

trashlytics/effect

  • Tracker.make(options) is a scoped constructor (Effect<Tracker, never, Scope | R>); closing the scope interrupts the worker and flushes remaining events. Replaces the old plain-function constructor (which ran Effect.runSync internally) and the shutdown() method.
  • Unified tagged errors: EventValidationError (normalized issues), UnknownEventError, TrackerClosedError, QueueFullError, SinkError.
  • New: tracker.size, retry.jitter, tracker-level context merged into every event's meta.

trashlytics (root entry)

  • track() is sync fire-and-forget and never throws; failures route to onError.
  • close() replaces shutdown(), is idempotent, and trackers support await using via Symbol.asyncDispose.
  • Standard Schema v1 support: event("signup", z.object({...})) works with zod/valibot/arktype — no Effect Schema import needed. Payload-less events: event("page.viewed").
  • Browser lifecycle: auto-flush on visibilitychange/pagehide (flushOnHide, default on).
  • Sinks may return void, Promise, or Effect, so built-in sinks are shared between both entries.

Sinks

  • httpSink defaults to keepalive: true (survives page unloads) and accepts a custom fetch.
  • New beaconSink using navigator.sendBeacon.

Breaking renames

Old New
createTracker (effect entry) make
shutdown() close() / scope close
retries retry
bufferSize maxQueueSize
SinkDeliveryError SinkError

Testing

  • 18 unit tests pass (vitest), covering typed batches, payload-less events, standard schemas, context merging, background batch delivery, retries, close-flush, and error paths
  • tsc --noEmit and ultracite check clean
  • Smoke-tested built ESM and CJS artifacts end-to-end, including httpSink against a real local HTTP server

🤖 Generated with Claude Code


Summary by cubic

Redesigned the SDK to make tracking non-blocking, add Standard Schema v1 support, and scope the tracker lifecycle across Node and browsers. flush()/close() now wait for in-flight track() calls; close() is a hard barrier. Delivery is backgrounded and uninterruptible so in-flight batches finish before shutdown.

  • New Features

    • Non-blocking track: events enqueue instantly; delivery runs in the background on batch size, flush interval, flush, and close.
    • trashlytics/effect: scoped Tracker.make(...); closing the scope stops the worker and flushes. New typed errors, size, tracker-level context, retry.jitter, and deliveryTimeout (bounds each sink call; default 30s).
    • trashlytics: sync fire-and-forget track() that never throws; close() with await using via Symbol.asyncDispose; auto-flush on visibilitychange/pagehide (on by default); sinks may return void/Promise/Effect; onError receives the failed batch, observes trackNow() failures, and also sees validation failures; after close() starts, track() reports TrackerClosedError via onError and trackNow() rejects; flush()/close() await in-flight track().
    • Standard Schema v1 support (zod/valibot/arktype), Schema.Struct fields, and payload-less events: event("page.viewed").
    • Sinks: httpSink defaults to keepalive: true and accepts a custom fetch; new beaconSink for browsers; delivery attempts are bounded by deliveryTimeout; background drain loop is uninterruptible to avoid in-flight batch loss on shutdown.
  • Migration

    • trashlytics/effect: createTracker -> make (scoped). Replace shutdown() with scope close; flush is now an Effect value (yield* tracker.flush).
    • Options: retries -> retry (adds jitter), bufferSize -> maxQueueSize. New: deliveryTimeout (default 30s).
    • Errors: SinkDeliveryError -> SinkError, TrackerShutdownError -> TrackerClosedError, BufferFullError -> QueueFullError.

Written for commit c12d010. Summary will update on new commits.

Review in cubic

deepso7 and others added 3 commits July 3, 2026 16:39
… support

Breaking rewrite of both entry points:

- Delivery moved to a background fiber (Latch + Queue + forkScoped);
  track() now only validates and enqueues, never waits on the sink
- trashlytics/effect: Tracker.make is a scoped constructor; closing the
  scope stops the worker and flushes remaining events (replaces shutdown)
- Unified tagged errors: EventValidationError, UnknownEventError,
  TrackerClosedError, QueueFullError, SinkError
- event() accepts Effect schemas, Schema.Struct fields, any Standard
  Schema v1 validator (zod/valibot/arktype), or no schema (payload-less)
- Root entry: close() + Symbol.asyncDispose (await using), auto-flush on
  page hide/unload, sinks may return void/Promise/Effect
- httpSink defaults to keepalive: true; new beaconSink for browsers
- New options: context (meta enrichment), retry.jitter, maxQueueSize
  (renamed from bufferSize); retries renamed to retry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
track() runs its effect without awaiting, so close() or flush() could
close the scope and run the final flush before a pending track() (e.g.
one awaiting async Standard Schema validation) enqueued its event,
silently dropping it. The wrapper now tracks in-flight track() promises
and settles them before flushing or closing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/effect.ts">

<violation number="1" location="src/effect.ts:704">
P2: `Effect.promise` assumes the promise never rejects. If a Standard Schema validator's async `validate` throws or rejects (e.g., due to a network-dependent validator or buggy implementation), this becomes an unhandled defect that escapes the `EventValidationError` error channel. Using `Effect.tryPromise` with a `catch` clause would convert rejections into proper `EventValidationError` values, keeping the typed error contract intact.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/effect.ts
catch: (cause) => new SinkDeliveryError({ cause }),
});
return result instanceof Promise
? Effect.promise(() => result).pipe(Effect.flatMap(toEffect))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Effect.promise assumes the promise never rejects. If a Standard Schema validator's async validate throws or rejects (e.g., due to a network-dependent validator or buggy implementation), this becomes an unhandled defect that escapes the EventValidationError error channel. Using Effect.tryPromise with a catch clause would convert rejections into proper EventValidationError values, keeping the typed error contract intact.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/effect.ts, line 704:

<comment>`Effect.promise` assumes the promise never rejects. If a Standard Schema validator's async `validate` throws or rejects (e.g., due to a network-dependent validator or buggy implementation), this becomes an unhandled defect that escapes the `EventValidationError` error channel. Using `Effect.tryPromise` with a `catch` clause would convert rejections into proper `EventValidationError` values, keeping the typed error contract intact.</comment>

<file context>
@@ -157,377 +439,322 @@ export interface TrackOptions {
-      catch: (cause) => new SinkDeliveryError({ cause }),
-    });
+    return result instanceof Promise
+      ? Effect.promise(() => result).pipe(Effect.flatMap(toEffect))
+      : toEffect(result);
+  });
</file context>
Suggested change
? Effect.promise(() => result).pipe(Effect.flatMap(toEffect))
? Effect.tryPromise({
try: () => result,
catch: (cause) =>
new EventValidationError({
key,
issues: [{ message: String(cause) }],
cause,
}),
}).pipe(Effect.flatMap(toEffect))

track() and trackNow() now check the closing flag before starting: once
close() has been called, track() deterministically reports
TrackerClosedError through onError and trackNow() rejects, instead of
racing the final flush. This also guarantees the in-flight settling loop
in close() terminates, since no new in-flight work can appear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/index.ts">

<violation number="1" location="src/index.ts:128">
P2: Tracking immediately after `close()` is invoked can still be accepted because `close` yields on `settleInFlight()` before the Effect scope marks the tracker closed. Consider marking the wrapper as closing synchronously and having `track` route those calls to `onError` with `TrackerClosedError`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/index.ts
} catch (error) {
options.onError?.(error);
throw error;
await settleInFlight();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Tracking immediately after close() is invoked can still be accepted because close yields on settleInFlight() before the Effect scope marks the tracker closed. Consider marking the wrapper as closing synchronously and having track route those calls to onError with TrackerClosedError.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/index.ts, line 128:

<comment>Tracking immediately after `close()` is invoked can still be accepted because `close` yields on `settleInFlight()` before the Effect scope marks the tracker closed. Consider marking the wrapper as closing synchronously and having `track` route those calls to `onError` with `TrackerClosedError`.</comment>

<file context>
@@ -114,7 +114,20 @@ export function createTracker<const Events extends EventsMap>(
+  };
+
+  const flush = async () => {
+    await settleInFlight();
+    await Effect.runPromise(tracker.flush);
+  };
</file context>

@deepso7

deepso7 commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

@cubic-dev-ai rereview

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 3, 2026

Copy link
Copy Markdown

@cubic-dev-ai rereview

@deepso7 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 7 files

Re-trigger cubic

…consistency

- Make the drain loop uninterruptible (restoring the guarantee the old
  drainQueue had): closing the scope can no longer interrupt the worker
  between dequeuing a batch and delivering it, so in-flight batches
  always complete (or exhaust retries) before shutdown
- Report trackNow delivery failures through onError like every other
  delivery path (callers still get the rejection)
- Replace the Symbol.asyncDispose global mutation with a pure module
  constant so "sideEffects": false remains truthful for bundlers
- Fix README Layer example: ServiceMap does not exist in effect
  4.0.0-beta.93; use Context.Service (example verified against tsc)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/effect.ts">

<violation number="1" location="src/effect.ts:704">
P2: `Effect.promise` assumes the promise never rejects. If a Standard Schema validator's async `validate` throws or rejects (e.g., due to a network-dependent validator or buggy implementation), this becomes an unhandled defect that escapes the `EventValidationError` error channel. Using `Effect.tryPromise` with a `catch` clause would convert rejections into proper `EventValidationError` values, keeping the typed error contract intact.</violation>
</file>

<file name="src/index.ts">

<violation number="1" location="src/index.ts:128">
P2: Tracking immediately after `close()` is invoked can still be accepted because `close` yields on `settleInFlight()` before the Effect scope marks the tracker closed. Consider marking the wrapper as closing synchronously and having `track` route those calls to `onError` with `TrackerClosedError`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/effect.ts
Comment thread src/effect.ts
- Add deliveryTimeout (default 30s): each sink call is bounded and a
  timed-out attempt fails with SinkError, subject to the retry policy,
  so flush() and close() can no longer hang on a sink that never
  settles. The timeout window is explicitly interruptible inside the
  otherwise-uninterruptible drain loop; if an interrupt lands there the
  dequeued batch is re-offered so it is not lost.
- Reorder shutdown finalizers to mark closed, drain remaining events
  (waiting on the delivery lock for any in-flight batch), and only then
  interrupt the now-idle worker — shutdown never cancels a delivery
  mid-flight and no longer contends with an uninterruptible worker.
- Guard the onError observer with try/catch so a throwing callback can
  no longer defect the delivery path out of trackNow/flush.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@deepso7 deepso7 merged commit ae8ca8e into main Jul 3, 2026
2 checks passed
@deepso7 deepso7 deleted the sdk-redesign branch July 3, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant