Redesign SDK: scoped Effect core, non-blocking track, Standard Schema support#20
Conversation
… 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>
There was a problem hiding this comment.
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
| catch: (cause) => new SinkDeliveryError({ cause }), | ||
| }); | ||
| return result instanceof Promise | ||
| ? Effect.promise(() => result).pipe(Effect.flatMap(toEffect)) |
There was a problem hiding this comment.
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>
| ? 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>
There was a problem hiding this comment.
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
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| await settleInFlight(); |
There was a problem hiding this comment.
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>
|
@cubic-dev-ai rereview |
@deepso7 I have started the AI code review. It will take a few minutes to complete. |
…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>
There was a problem hiding this comment.
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
- 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>
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
trackPreviously
trackdrained 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) —trackonly validates, stamps, and enqueues.trashlytics/effectTracker.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 ranEffect.runSyncinternally) and theshutdown()method.EventValidationError(normalizedissues),UnknownEventError,TrackerClosedError,QueueFullError,SinkError.tracker.size,retry.jitter, tracker-levelcontextmerged into every event'smeta.trashlytics(root entry)track()is sync fire-and-forget and never throws; failures route toonError.close()replacesshutdown(), is idempotent, and trackers supportawait usingviaSymbol.asyncDispose.event("signup", z.object({...}))works with zod/valibot/arktype — no EffectSchemaimport needed. Payload-less events:event("page.viewed").visibilitychange/pagehide(flushOnHide, default on).void,Promise, orEffect, so built-in sinks are shared between both entries.Sinks
httpSinkdefaults tokeepalive: true(survives page unloads) and accepts a customfetch.beaconSinkusingnavigator.sendBeacon.Breaking renames
createTracker(effect entry)makeshutdown()close()/ scope closeretriesretrybufferSizemaxQueueSizeSinkDeliveryErrorSinkErrorTesting
vitest), covering typed batches, payload-less events, standard schemas, context merging, background batch delivery, retries, close-flush, and error pathstsc --noEmitandultracite checkcleanhttpSinkagainst 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-flighttrack()calls;close()is a hard barrier. Delivery is backgrounded and uninterruptible so in-flight batches finish before shutdown.New Features
flush, andclose.trashlytics/effect: scopedTracker.make(...); closing the scope stops the worker and flushes. New typed errors,size, tracker-levelcontext,retry.jitter, anddeliveryTimeout(bounds each sink call; default 30s).trashlytics: sync fire-and-forgettrack()that never throws;close()withawait usingviaSymbol.asyncDispose; auto-flush onvisibilitychange/pagehide(on by default); sinks may returnvoid/Promise/Effect;onErrorreceives the failed batch, observestrackNow()failures, and also sees validation failures; afterclose()starts,track()reportsTrackerClosedErrorviaonErrorandtrackNow()rejects;flush()/close()await in-flighttrack().Schema.Structfields, and payload-less events:event("page.viewed").httpSinkdefaults tokeepalive: trueand accepts a customfetch; newbeaconSinkfor browsers; delivery attempts are bounded bydeliveryTimeout; background drain loop is uninterruptible to avoid in-flight batch loss on shutdown.Migration
trashlytics/effect:createTracker->make(scoped). Replaceshutdown()with scope close;flushis now an Effect value (yield* tracker.flush).retries->retry(addsjitter),bufferSize->maxQueueSize. New:deliveryTimeout(default 30s).SinkDeliveryError->SinkError,TrackerShutdownError->TrackerClosedError,BufferFullError->QueueFullError.Written for commit c12d010. Summary will update on new commits.