Make MixpanelClient durable with an on-disk event spool#43
Conversation
Today MixpanelClient fires each event over HTTP immediately and drops it when offline, unlike the Segment path which already gets durability from Segment.Analytics.CSharp. Track/exception events are now enqueued to a DiskQueue-backed spool and drained by a background flush loop (Polly retry + circuit breaker), so events survive offline stretches and process restarts and are delivered at-least-once via a stamped $insert_id. The spool is bounded (cap + drop-oldest), values are path- scrubbed before being persisted, and AllowTracking=false purges the spool immediately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four fixes found in review of the offline-durability work: 1. Captive portals: MixpanelEventSender now disables HttpClient's AllowAutoRedirect and classifies 3xx responses as RetryableFailure, so a captive portal's login-page redirect can no longer be misclassified as a delivered event. 2. Bounded shutdown: IEventSender.Send now takes a CancellationToken, threaded through HttpClient.PostAsync. MixpanelClient.BoundedDrain ties a CancellationTokenSource to its remaining deadline on every attempt (instead of only checking the deadline between attempts) and bypasses the Polly retry pipeline, so Flush()/ShutDown() return within a few seconds even against a server that accepts the connection but never responds. 3. Circuit breaker: lowered MinimumThroughput from 4 to 3 to match the 3 outcomes (1 attempt + 2 retries) a fully failing drain tick actually produces, so a sustained outage can trip the breaker within a single 10s sampling window instead of never. 4. Mixpanel's /track returns HTTP 200 with body "0" for a rejected payload; that is now classified as PoisonDrop instead of Delivered. Adds NUnit coverage for all four (WireMock-based redirect/body/ cancellation tests, and MixpanelClient tests for bounded drain against a hanging sender and for the corrected breaker threshold). Updates the existing sender fakes in MixpanelClientTests.cs for the new IEventSender signature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hahn-kev
left a comment
There was a problem hiding this comment.
I left some suggestions, I'm very interested in this change because I want to use it in FW Lite, however FW Lite uses async Tasks where possible, so I'd love to see this be async where possible. But I'd be happy to do that in a follow up PR if that's out of scope here.
| "application/x-www-form-urlencoded")) | ||
| { | ||
| response = _httpClient | ||
| .PostAsync(_baseUrl + "/track", content, cancellationToken) |
There was a problem hiding this comment.
I suggest based on the docs we should use import instead of track, see here: https://docs.mixpanel.com/reference/track-event#when-to-use-/track-vs-/import
it's designed for importing data, and supports importing old events, track will only accept events from up to 5 days ago. Track is best for live tracking, but we don't care about it in this case. Also using import would allow us to send up to 2000 events in a single http request.
There was a problem hiding this comment.
The auth model rules /import out for this library, unfortunately: per that same doc page, /import requires a Project Secret or Service Account credential ("intended for server-side integration"), while /track authenticates with just the project token ("intended for untrusted clients"). DesktopAnalytics ships inside desktop apps, so the only credential we can embed is the token — shipping a project secret in a client binary would hand project-level API access to anyone with the executable.
Two real points in here worth acting on, though:
- The 5-day limit is a genuine gap: a machine offline longer than that will replay events /track no longer accepts. That's now a documented limitation of this design rather than something we can fix client-side.
- Batching doesn't require /import — the same page says /track also accepts up to 2,000 events per request. We deliberately post one event at a time for now because the whole spool design keys off per-event outcome classification (Delivered / retryable / poison → commit or roll back exactly one spool entry), and a batched response is one verdict for the whole batch. At desktop-analytics volume (a handful of events per session, drained in 20-event batches every 30s) the per-request overhead is negligible, but batch-over-/track is a reasonable future optimization if volume ever grows.
— Claude (Fable 5), replying at John's request
| /// that is taking too long -- e.g. a server that accepts the connection but never responds | ||
| /// (a "black hole"). Implementations should treat cancellation like any other transient | ||
| /// failure and return <see cref="SendResult.RetryableFailure"/> rather than throwing.</param> | ||
| SendResult Send(AnalyticsEvent evt, CancellationToken cancellationToken = default); |
There was a problem hiding this comment.
since this is going over the network can we have an async Task version? I realize this would bubble up to other places, but that's kinda the point.
There was a problem hiding this comment.
Taking you up on the follow-up-PR offer from your review summary rather than folding it in here. The refactor is mechanical but invasive — EventSpool's lock can't span an await so it becomes a SemaphoreSlim, ProcessBatch takes an async callback, and the whole flush pipeline changes shape — with no observable behavior change, since IEventSender is internal, the public IClient surface stays sync either way, and the flush loop already runs off the UI thread (timer threads, never blocking a caller). Keeping this PR reviewable and doing async as its own focused change seems like the better split; happy to have FW Lite's needs drive that PR.
— Claude (Fable 5), replying at John's request
There was a problem hiding this comment.
Update: John asked to include it in this PR after all — done in bd49103, and it was indeed not too hard. The whole delivery pipeline is now async end-to-end: IEventSender.SendAsync, EventSpool.ProcessBatchAsync (its monitor lock became a SemaphoreSlim so it can be held across awaits — same serialization guarantee), MixpanelClient.DrainOnceAsync/BoundedDrainAsync with Polly's ExecuteAsync, and MixpanelEventSender awaiting HttpClient directly instead of the previous sync-over-async block. ConfigureAwait(false) throughout.
The public IClient surface is unchanged (sync Track/Flush/ShutDown — Flush/ShutDown block on the ~5s-bounded drain at the boundary only, which is UI-thread-safe given ConfigureAwait(false)). If FW Lite wants an async public surface too (FlushAsync/ShutDownAsync on IClient or the Analytics facade), that's now a thin addition — say the word and it can go in here or in your follow-up.
81/81 tests passing, 0 warnings; production path re-verified end-to-end via SampleApp.
— Claude (Fable 5), replying at John's request
There was a problem hiding this comment.
The public async surface is in as well (543db87): Analytics.FlushClientAsync() (static, mirrors FlushClient()) and analytics.ShutDownAsync() — an async alternative to Dispose for hosts that shut down asynchronously; a using block around the object stays correct because shutdown is idempotent and the subsequent Dispose is a no-op. On IClient, FlushAsync/ShutDownAsync are the primary implementations in MixpanelClient (the sync methods are now thin blocking wrappers); SegmentClient's complete synchronously since Segment.Analytics.CSharp's Flush() only signals its own background delivery and exposes nothing awaitable. Everything is additive — no existing signature changed.
84/84 tests passing, including bounded ShutDownAsync/FlushAsync against a black-hole sender awaited directly (no Task.Run), and a ShutDownAsync-then-Dispose idempotence test.
— Claude (Fable 5), replying at John's request
| string eventName, | ||
| JsonObject properties = null, | ||
| Func<string> insertIdFactory = null, | ||
| Func<DateTimeOffset> clock = null |
There was a problem hiding this comment.
Is there a reason the caller doesn't just provide a DateTimeOffset here? what's the benefit of using a function if we're just going to call it once and throw it away. If we do still want to do it this way then shouldn't we just use a TimeProvider?
There was a problem hiding this comment.
No benefit — the factory was invoked exactly once at the single call site, so it was indirection for nothing. Fixed in 256eebc: Create now takes plain string insertId = null, DateTimeOffset? time = null values, and Track passes _timeProvider.GetUtcNow() directly (the TimeProvider already lives in MixpanelClient; AnalyticsEvent stays a dumb data envelope that doesn't need to know about it).
— Claude (Fable 5), replying at John's request
| if (remaining <= TimeSpan.Zero) | ||
| return; | ||
|
|
||
| using (var cts = new CancellationTokenSource(remaining)) |
There was a problem hiding this comment.
we can move the cts outside of the loop and just create it once with new CancellationTokenSource(s_boundedDrainDuration), the same cts.Token can be used many times, you can even use it to check if the deadline is up without needing to do any extra date time math inside the loop.
There was a problem hiding this comment.
Done in 256eebc, with one adjustment to the suggestion: new CancellationTokenSource(delay) only knows the system clock, and BoundedDrain deliberately measures its deadline through the injected TimeProvider so tests keep deterministic control of it. Microsoft.Bcl.TimeProvider (already referenced) provides timeProvider.CreateCancellationTokenSource(delay), so the loop now creates that single CTS once outside, shares cts.Token across all attempts, and checks cts.IsCancellationRequested instead of doing date math per iteration.
— Claude (Fable 5), replying at John's request
| _tasks.Add(_client.TrackAsync(eventName, analyticsId, properties)); | ||
| try | ||
| { | ||
| if (_spool == null) |
There was a problem hiding this comment.
this is a behavior change. If Initialize was not called, previously _client would be null and calling Track would throw. Now if Initialize is not called this will silently fail, we should throw an explicit error.
There was a problem hiding this comment.
Confirmed it was an unintended behavior change — fixed in 256eebc. Track on a never-initialized client now throws InvalidOperationException (checked before the catch-all, which would otherwise swallow it), with a test pinning it.
One deliberate distinction kept: _spool == null on an initialized client means Initialize ran but the spool couldn't be created (e.g. another instance of the same app holds the cross-process lock on the spool directory). That case still degrades to a silent no-op — analytics failing to be durable must never crash the host app. So: never initialized → throw (programming error); initialized but spool unavailable → no-op (environmental failure).
— Claude (Fable 5), replying at John's request
| /// <summary> | ||
| /// Empties the spool entirely (used on consent revocation). Never throws. | ||
| /// </summary> | ||
| public void Purge() |
There was a problem hiding this comment.
Why not use _queue.HardDelete? Per https://github.com/i-e-b/DiskQueue#removing-or-resetting-queues
There was a problem hiding this comment.
Tried it empirically (DiskQueue 1.7.2): HardDelete(reset: true) recreates the files but leaves the live instance's EstimatedCountOfItemsInQueue stale, so the spool keeps reporting the purged events as present — the purge tests fail with "Expected: 0 But was: 5". That's consistent with its own doc warning ("not thread safe, multi-process safe, or safe in any other way"), so I didn't fight it.
But testing it surfaced something better than the original loop: the dequeue-and-flush approach leaves the purged events' bytes sitting in the data files (DiskQueue doesn't trim consumed entries), which isn't great for a consent purge. So 256eebc reimplements Purge as what HardDelete should have been: dispose the queue, delete the spool directory, reopen fresh — under the spool's own lock. Data is actually gone from disk (a new test scans the raw files to prove it), the count is coherent, and the spool remains usable/reopenable afterward.
— Claude (Fable 5), replying at John's request
| for (var t = 0; t < threadCount; t++) | ||
| { | ||
| var threadIndex = t; | ||
| tasks[t] = Task.Run(() => |
There was a problem hiding this comment.
Tasks are not one to one with threads. Starting a task like this will just queue it to run on the thread pool and will not guarantee that each task is running at the same time. For a test like this I would explicitly use new Thread, then start them all in a loop, and then join in a loop to ensure they're done before you resume the test.
There was a problem hiding this comment.
Fixed in 256eebc — dedicated Threads, started in a loop and joined in a loop, so the intended parallelism is explicit instead of at the mercy of thread-pool scheduling.
— Claude (Fable 5), replying at John's request
| // unreadable/incompatible schema version) by flushing (dropping) it like a poison message | ||
| // rather than wedging the spool -- see EventSpool.cs. | ||
| [TestFixture] | ||
| public class EventSpoolTests |
There was a problem hiding this comment.
There are no tests around ProcessBatch max items, only around the queue max size, probably not important but I thought I'd mention it as the tests are otherwise quite thorough
There was a problem hiding this comment.
Added in 256eebc: 5 events spooled, ProcessBatch(2, …) twice — asserts exactly 2 attempted per call, in order, with the remainder intact between calls.
— Claude (Fable 5), replying at John's request
| { | ||
| // NOTE on the "corrupt/partial spool" edge case (power loss mid-write): we do not add a test | ||
| // for this here. Simulating it deterministically would mean hand-crafting a truncated | ||
| // transaction-log file matching DiskQueue's private on-disk format, which is not part of its |
There was a problem hiding this comment.
rather than hand crafting a truncated log couldn't you just put garbage data in there to test this case?
There was a problem hiding this comment.
Tried it in 256eebc, and the experiment split into two findings, both now pinned as tests:
- Garbage appended to a data file: DiskQueue ignores bytes beyond the extents committed transactions reference — the spool reopens and every committed event survives. Test added.
- Garbage appended to the transaction log: DiskQueue refuses to open (it recovers a truncated log — the realistic torn-write shape — but treats unrecognized bytes after a committed entry as a conflict and throws, surfacing as a lock-wait timeout via
PersistentQueue.WaitFor). So "recovers gracefully" isn't testable there because it isn't the behavior; the test instead pins the fail-fast: the constructor throws rather than serving corrupt data, andMixpanelClient.Initializealready catches that and degrades to a non-durable no-op instead of crashing the host.
The fixture NOTE now documents this split instead of declining the test outright.
— Claude (Fable 5), replying at John's request
| }; | ||
| client.Track("user-1", "Exception", props); | ||
|
|
||
| // Prove it was scrubbed BEFORE hitting the disk, not just before being sent: drain |
There was a problem hiding this comment.
this doesn't actually prove that the data was scrubbed before hitting the disk because all events are round tripped through the disk before being sent.
There was a problem hiding this comment.
Correct — asserting on what the sender is handed can't distinguish scrub-at-enqueue from scrub-at-send, so it proved the pipeline output was scrubbed but not the on-disk property the comment claimed. Fixed in 256eebc: before anything drains, the test now scans the raw spool files on disk and asserts the username never appears — and, to avoid passing vacuously, that the scrubbed %USER% form IS found in the persisted bytes. (DiskQueue's exclusively-held lock file is skipped; it only ever contains a PID.)
— Claude (Fable 5), replying at John's request
SampleApp's built-in AllowTracking on/off demo calls PurgeQueuedEvents, which empties the entire on-disk spool immediately (working as designed for real consent revocation). That makes SampleApp unusable as a driver for a test that spools events in one process and expects them to survive into a later process: the purge always fires before the next scheduled flush tick, deleting any leftover events before they can drain. Add an optional c=false flag to skip that block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Track() on a never-initialized MixpanelClient now throws InvalidOperationException (restoring the pre-durability contract) instead of silently dropping events; a client whose Initialize ran but whose spool failed still degrades to a no-op by design. - Store the flush interval as a TimeSpan, clamped once in Initialize, instead of re-deriving/clamping an int at every use site. - AnalyticsEvent.Create takes plain insertId/time values instead of factory funcs that were each invoked exactly once. - BoundedDrain shares one CancellationTokenSource across attempts (created via the injected TimeProvider so tests keep deterministic control of the deadline) instead of per-attempt date math + CTS. - Corrected the _timerDraining comment: it only prevents overlapping timer ticks; Flush/ShutDown are serialized by EventSpool's lock, not by this flag. - EventSpool.Purge now actually removes event data from disk (dispose + delete directory + reopen). Tried both alternatives empirically on DiskQueue 1.7.2: a dequeue loop leaves purged bytes in the data files, and HardDelete(reset: true) leaves the live instance's count estimate stale. - Tests: concurrency test uses dedicated Threads instead of Task.Run; new ProcessBatch maxItems test; new garbage-corruption tests (data file: recovers; transaction log: documented fail-fast); scrub test now scans the raw spool files on disk so scrub-at-enqueue cannot regress to scrub-at-send; new purge-then-reuse/reopen + purged-bytes-gone test; new uninitialized-Track-throws test. Full suite: 81/81 passing. Production path re-verified by driving SampleApp end-to-end (Initialize, flush timer, consent purge, resume). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
(This reply — and the threaded replies above — are from Claude (Fable 5), acting on John's behalf.) All 14 inline comments are addressed in 256eebc; each thread has a specific reply. Summary of dispositions: Fixed as suggested: uninitialized Investigated, landed differently (details in-thread): Declined with reasoning (details in-thread): On async: taking you up on the follow-up-PR offer — Full suite after the changes: 81/81 passing; the production init/timer/purge/resume path re-verified end-to-end via SampleApp. |
…ow-up) Per hahn-kev's review (FW Lite prefers async), the whole spool-drain path is now async end-to-end instead of blocking a ThreadPool thread on sync-over-async HTTP: - IEventSender.Send -> Task<SendResult> SendAsync; MixpanelEventSender awaits HttpClient directly (no more GetAwaiter().GetResult()). - EventSpool.ProcessBatch -> ProcessBatchAsync with an async send callback; the monitor lock becomes a SemaphoreSlim so it can be held across awaits (all other spool methods take the same semaphore, so the serialization guarantee is unchanged). - MixpanelClient: DrainOnceAsync / BoundedDrainAsync, Polly via ExecuteAsync, timer ticks kick off an awaited drain (still guarded against overlap). ConfigureAwait(false) throughout. - The public IClient surface is unchanged (sync Track/Flush/ShutDown); Flush/ShutDown block on the bounded drain at the boundary only, which is UI-thread-safe given ConfigureAwait(false) and the ~5s bound. Tests updated to await the async seams (NUnit async tests); fakes implement SendAsync; the black-hole fake awaits Task.Delay under the cancellation token instead of blocking a thread. Full suite: 81/81 passing, 0 warnings. Production init/timer/purge/ resume path re-verified end-to-end via SampleApp (same statistics as the sync build). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Additive only -- the existing sync API is untouched, so current consumers (FieldWorks) are unaffected; async hosts (FW Lite) can now flush/shut down without blocking a thread: - Analytics (public facade): static FlushClientAsync() and instance ShutDownAsync(), an async alternative to Dispose. Dispose after ShutDownAsync is a harmless no-op (shutdown is idempotent). - IClient: FlushAsync/ShutDownAsync added. - MixpanelClient: the async methods are now the primary implementations (awaiting the bounded drain); sync Flush/ShutDown are thin blocking wrappers at the boundary. - SegmentClient: completes synchronously -- Segment.Analytics.CSharp's Flush() only signals its own background delivery and exposes nothing awaitable. New tests: bounded ShutDownAsync/FlushAsync against a black-hole sender (awaited directly, no Task.Run), ShutDownAsync-then-ShutDown idempotence incl. lock release, and never-fault coverage on the no-spool degraded client. Full suite: 84/84 passing, 0 warnings. SampleApp re-verified end-to-end (its Dispose path now routes through ShutDownAsync). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the PR sillsdev#43 retry-forever discussion: the one bad-event shape the poison classification cannot catch is a payload so large the HTTP request times out before Mixpanel responds -- a timeout is indistinguishable from being offline, so it classifies as RETRYABLE and would wedge the head of the queue forever. - EventSpool gains a per-event byte cap (ctor param, unbounded by default); Enqueue now returns whether the event was durably spooled and refuses anything over the cap. - MixpanelClient passes Mixpanel's documented per-event limit (1MB of uncompressed JSON) and counts a refused event in Statistics.Failed, like any other undeliverable event. Tests: spool-level refusal (oversized refused, normal accepted on the same spool) and client-level end-to-end (oversized never spooled, never reaches the sender, counted Failed; normal event still flows). Full suite: 86/86 passing, 0 warnings. SampleApp re-verified end-to- end -- Failed: 0, confirming no false positives on normal events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bandwidth courtesy for the "internet cafe" scenario (SIL's users are often on slow or per-MB links): - Per-tick byte budget (256KB): ProcessBatchAsync takes a soft maxBytes; a drain tick stops once the sent bytes reach it, so a run of large events is spread across ticks instead of saturating a slow link in one burst. Soft = checked after each event, so a single event over the whole budget still goes and can never starve the queue. Typical events (~2-3KB) never come near it. - Byte-based spool cap (20MB default) alongside the 5000-item cap, drop-oldest beyond it: bounds both the on-disk footprint and the total upload liability of a long-offline backlog (the item cap alone allowed a theoretical 5000 x 1MB = ~5GB). The spool tracks logical live-entry bytes (disk file sizes overcount consumed-but- untrimmed entries), measured once at open via a rollback session so the cap keeps working across restarts. Tests: byte-cap drop-oldest, byte accounting across dispose/reopen, per-call byte budget incl. the never-starve guarantee, and client- level two-ticks-for-600KB. Full suite: 90/90 passing, 0 warnings. SampleApp re-verified end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
MixpanelClientfired each event over HTTP immediately and silently dropped it when offline; the Segment path already had durability viaSegment.Analytics.CSharp, so this closes that gap for Mixpanel.EventSpool) and a background flush loop (Polly retry + circuit breaker) that drains it — events survive offline stretches and process restarts, delivered at-least-once via a stamped$insert_id.PathScrubber) before being persisted, normalizing user home paths so stack traces and usage events don't leak OS account names.AllowTracking→ false purges the spool immediately and pauses the flush loop; re-enabling resumes it (IClient.ResumeSending()/PurgeQueuedEvents()).Flush()/ShutDown()now trigger a bounded drain instead of being no-ops, without blocking an offline shutdown.SampleAppgains an optionalc=falseflag to skip its built-in AllowTracking on/off demo (see "Manual smoke test" below for why).Test plan
dotnet build DesktopAnalytics.sln— succeeds, 0 warnings/errors$insert_id, spool bounding, consent purge, cross-process lock) and poison-vs-retryable HTTP status classificationManual smoke test (2026-07-09)
Ran two PowerShell harnesses (in the FieldWorks repo,
scripts/Test-AnalyticsOfflineDrain.ps1andscripts/Test-AnalyticsOfflineSandbox.ps1— see that repo'sDocs/architecture/local-library-debugging.md) against this branch's build, hitting the real Mixpanel/trackendpoint with FieldWorks' own DEBUG-build project token:Test-AnalyticsOfflineDrain.ps1): blocksSampleApp.exe's outbound traffic with a scoped Windows Firewall rule, runs it once blocked, removes the rule, runs it again. Result: Phase 1 (blocked) —Submitted=3 Succeeded=0 Failed=0, confirming events accumulate in the spool rather than being dropped. Phase 2 (unblocked) —Submitted=3 Succeeded=6 Failed=0: this run's 3 new events plus all 3 leftover from Phase 1 were delivered. PASS.Test-AnalyticsOfflineSandbox.ps1): runsSampleAppinside a disposable Windows Sandbox guest with<Networking>Disable</Networking>(no virtual NIC at all, host networking untouched) — a "born offline" check complementary to the firewall test. Result:Submitted=4 Succeeded=0 Failed=0— events submitted, none falsely reported delivered. PASS.Both confirm
MixpanelClientno longer depends onNetworkInterface.GetIsNetworkAvailable()(the old gate a firewall block couldn't fool) — it now classifies offline from real HTTP send failures, so a per-process block is an accurate simulation of "network down" for delivery purposes.Found and fixed along the way (this commit):
SampleApp's built-in AllowTracking on/off demo callsPurgeQueuedEvents(), which is working as designed (consent revocation empties the spool immediately) but madeSampleAppunusable as a driver for a cross-process durability test — the purge always fires before the next scheduled flush tick, deleting Phase 1's leftover events before they could ever be observed draining. Not aMixpanelClientbug; a test-driver limitation. AddedSampleApp's newc=falseflag to skip that demo block, used by both phases above.Network-failure hardening (follow-up commit)
A review of the bad-network-condition handling found four issues, all fixed in a follow-up commit:
MixpanelEventSender'sHttpClientfollowed redirects by default, so a captive portal (hotel/coffee-shop Wi-Fi login page) intercepting the POST would get followed to the portal's login page, return a 200, and be misclassified asDeliveredeven though Mixpanel never received the event. Fixed by disablingAllowAutoRedirectand classifying any 3xx response asRetryableFailure(Mixpanel's/tracknever legitimately redirects).ShutDown()/Flush()could hang up to ~45s against a "black hole" server (one that accepts the TCP connection but never responds — captive portals and some firewalls do this), because the bounded-drain deadline was only checked between attempts, not during one. Fixed by threading aCancellationTokenthroughIEventSender.Senddown toHttpClient.PostAsync, tying aCancellationTokenSourceto the remaining deadline on every bounded-drain attempt, and bypassing the Polly retry pipeline during bounded drains (retrying during shutdown has little value and only multiplies the hang). Undelivered events remain correctly rolled back in the spool.MinimumThroughputwas 4, but a fully failing drain tick only ever produces 3 outcomes (1 attempt + 2 retries), and ticks are 30s apart — well outside the 10s sampling window — so 4 outcomes could never land in the same window during a real outage. Fixed by loweringMinimumThroughputto 3 to match./trackreturns HTTP 200 with body"0"for a rejected payload, which was previously counted asDelivered. Fixed to classify a trimmed"0"body on a 2xx asPoisonDrop(a rejected payload will never succeed on retry); a body-read failure on an otherwise-2xx response still counts asDelivered(at-least-once +$insert_iddedup makes that safe).Covered by new NUnit tests (WireMock-based redirect/body/cancellation tests in
MixpanelEventSenderTests, and bounded-drain-against-a-hanging-sender / circuit-breaker-threshold tests inMixpanelClientTests). Full suite: 76/76 passing.Review feedback (2026-07-09, commit 256eebc)
All 14 of @hahn-kev's inline comments are addressed — each thread has a specific reply (from Claude, on John's behalf), and a top-level comment summarizes the dispositions. Highlights that changed behavior:
Track()on a never-initialized client now throwsInvalidOperationException(restoring the original contract) instead of silently dropping events. An initialized client whose spool couldn't be created still degrades to a no-op by design — analytics must never crash the host.Purge()(consent revocation) now actually removes event data from disk — dispose + delete the spool directory + reopen. Empirically, DiskQueue'sHardDelete(reset: true)leaves the live instance's count estimate stale, and the previous dequeue-and-flush loop left the purged events' bytes in the data files; neither is right for a consent purge. A test scans the raw on-disk files to pin this.MixpanelClient.Initializecatches that and degrades to non-durable).TimeSpan;AnalyticsEvent.Createtakes plaininsertId/timevalues instead of factory funcs; BoundedDrain shares oneCancellationTokenSource(created via the injectedTimeProvider) across attempts;_timerDrainingcomment corrected; concurrency test uses dedicated threads; newProcessBatchmax-items test; the scrub test now proves scrub-before-disk by scanning raw spool bytes./import(requires server-side credentials a desktop binary can't ship), send-first-then-spool (the write-ahead is the durability guarantee, and "online?" is only knowable from a send outcome), and a per-event retry counter (would poison-drop good head-of-queue events during long outages).Async pipeline (2026-07-09, commit bd49103)
Per @hahn-kev's request (FW Lite prefers async), the internal delivery pipeline is now async end-to-end:
IEventSender.SendAsync,EventSpool.ProcessBatchAsync(monitor lock →SemaphoreSlim, held across awaits with the same serialization guarantee),MixpanelClient.DrainOnceAsync/BoundedDrainAsyncvia PollyExecuteAsync, andMixpanelEventSenderawaitingHttpClientdirectly (no more sync-over-async).ConfigureAwait(false)throughout. SyncFlush/ShutDownblock only at the boundary on the ~5s-bounded drain, which is UI-thread-safe givenConfigureAwait(false).Public async surface (543db87, additive — no existing signature changed):
Analytics.FlushClientAsync()andanalytics.ShutDownAsync(), an async alternative toDispose(shutdown is idempotent, so ausingblock around the object stays correct).IClientgainsFlushAsync/ShutDownAsync;MixpanelClientimplements them natively,SegmentClientcompletes synchronously (Segment.Analytics.CSharp'sFlush()exposes nothing awaitable).Per-event size cap (cad6b07, resolving the retry-forever thread): events over Mixpanel's documented 1MB-uncompressed-JSON limit are refused at enqueue time and counted in
Statistics.Failed— a rejected-but-deliverable payload was already caught as poison via the 200-with-body-"0"path, but a payload large enough to time out the request classifies as retryable (indistinguishable from being offline) and would have wedged the head of the queue forever. Now it can't enter the queue at all.Constrained bandwidth (02ea2bc): the "internet café" scenario
Many users of SIL software are on slow, shared, or per-MB metered connections. The scenario to design for: a user's machine accumulates an offline backlog, then reconnects on a slow shared link (e.g. a café in Kenya) — the library must not hog that link or silently spend meaningful metered data.
What the design already guaranteed (before this commit): sends are strictly sequential — one connection, one request in flight, never parallel; a drain tick is at most 20 events every 30s; typical events are ~2–3KB on the wire, so a normal tick is a ~50–60KB burst (<2KB/s averaged, ~5% duty cycle on a 256kbps line); the circuit breaker opens after one fully-failing tick, so a flaky link gets less traffic, not retry-hammering; there is no reconnect "dump" — the first tick after reconnect sends one batch and waits.
What this commit adds (the two dimensions that were unbounded):
Residual, accepted risks (deliberate, with reasoning):
flushAt/flushIntervalknobs andFlushClientAsync— not a default this library should assume.Statisticsstill reflects overall submitted/succeeded/failed counts.Full suite after the changes: 90/90 passing, 0 warnings. Production init/timer/purge/resume path re-verified end-to-end by driving
SampleAppagainst the live endpoint (identical statistics to the sync build).🤖 Generated with Claude Code
This change is