Commit 371ffda
Make MixpanelClient durable with a disk-backed event spool
MixpanelClient previously fired each event over HTTP immediately and
silently dropped it when offline. The Segment path already had durability
via Segment.Analytics.CSharp; this closes that gap for Mixpanel with a
DiskQueue-backed spool and a background flush loop, so events survive
offline stretches and process restarts and are delivered at-least-once.
Design decisions
- EventSpool wraps DiskQueue as a bounded, transport-agnostic durable
queue: events are enqueued as opaque JSON bytes immediately (the
write-ahead IS the durability guarantee -- "online?" is only ever
knowable from a send outcome, so send-first-then-spool was rejected).
Bounded by both item count (5,000) and total bytes (20MB, drop-oldest),
so an unbounded offline period can't grow the spool or its eventual
upload liability without limit.
- Delivery batches via Mixpanel's /import endpoint (strict mode) rather
than one /track POST per event: one request per drain tick, no /track
5-day age limit on a replayed backlog, and strict mode reports
per-record rejections (failed_records) so one poison event doesn't
cost the rest of a good batch. ProcessBatchAsync gathers a batch in a
single DiskQueue transaction and commits/rolls back on the batch's
verdict (Delivered/PoisonDrop commit; RetryableFailure rolls back
in order for a later retry).
- Delivery is async end-to-end (IEventSender.SendBatchAsync,
EventSpool.ProcessBatchAsync, MixpanelClient.DrainOnceAsync via a
Polly retry + circuit breaker pipeline), serialized by a SemaphoreSlim
(not a monitor lock, since the lock must be held across the network
await). Sync Flush()/ShutDown() block only at the boundary on a
bounded drain; FlushAsync()/ShutDownAsync() are the non-blocking
public alternatives (IClient, Analytics.FlushClientAsync /
analytics.ShutDownAsync).
- A drain tick is bandwidth-bounded for slow/metered connections: a
soft 256KB-per-tick byte budget (checked after each event, so one
oversized event still goes rather than starving the queue) on top of
the existing sequential-sends/one-connection design.
- Consent revocation (AllowTracking = false) purges the spool
immediately (dispose + delete the spool directory + reopen, not a
dequeue-and-flush loop, which left purged bytes sitting in DiskQueue's
data files) and cancels whatever send is currently in flight first, so
a batch that was mid-POST when consent was revoked rolls back and gets
purged instead of still reaching Mixpanel afterward.
- All spooled string values (recursively, including nested object/array
properties) are path-scrubbed before being persisted, normalizing user
home directories so stack traces and usage events can't leak OS
account names.
- The spool's byte total is persisted alongside it and restored on open
(cross-checked against DiskQueue's own item-count estimate, falling
back to an exact dequeue-and-verify measurement if the persisted value
is missing or looks stale), so a restart with a large backlog doesn't
require synchronously re-measuring the whole spool before Initialize
can return.
- Track() on a never-initialized client throws InvalidOperationException,
matching the pre-durability contract; an initialized client whose spool
couldn't be created (e.g. another process holds the cross-process lock)
degrades to a no-op instead -- analytics must never crash the host.
Hardening against bad network conditions
- Captive portals (hotel/cafe Wi-Fi login pages) can no longer silently
eat events: HttpClient no longer follows redirects, and any 3xx is
classified as retryable rather than risk following a portal to a 200
that was never actually Mixpanel.
- Flush()/ShutDown() are bounded (~5s / 20 attempts) even against a
"black hole" server that accepts the TCP connection but never
responds: a single deadline-bound CancellationTokenSource is threaded
all the way down to HttpClient, and the Polly retry pipeline is
bypassed for these bounded attempts (retrying during a bounded
shutdown/flush has little delivery value and would multiply, not
bound, the wait).
- The circuit breaker's MinimumThroughput is tuned to how many outcomes
one fully-failing drain tick actually produces (1 attempt + 2
retries = 3), so a sustained outage trips it after one bad tick
instead of never being able to open at all.
- A 200 response with body "0" (Mixpanel's rejected-payload signal) is
classified PoisonDrop instead of Delivered.
- Events over Mixpanel's documented 1MB per-event limit are refused at
enqueue time (counted Failed immediately) rather than being spooled
and later timing out as an indistinguishable-from-offline retryable
failure that would wedge the head of the queue forever.
Test coverage
- 97 NUnit tests across four fidelity layers: pure unit (PathScrubber,
AnalyticsEvent serialization), a real EventSpool against a real
DiskQueue on a temp directory, a real EventSpool driving a real
MixpanelClient against a scripted/fake IEventSender, and
WireMock.Net-backed HTTP tests of MixpanelEventSender against real
status codes and bodies.
- Coverage includes: no-loss retry and crash-window dedup via
$insert_id, spool item/byte bounding (drop-oldest), consent purge
(including that purged bytes are actually gone from disk, not just
marked consumed), cross-process exclusive lock contention, corrupt
spool recovery (garbage in the data file vs. the transaction log),
bounded-drain-against-a-hanging-sender, real circuit-breaker
threshold behavior, captive-portal/redirect/cancellation handling,
recursive PII scrubbing (including nested object/array properties),
cap-eviction accounted for in Statistics, and the persisted-byte-total
fast path (plus its fallback when that value is missing or stale).
- Manually verified end-to-end against the live Mixpanel API from both
SampleApp and cross-process offline scenarios (per-process firewall
block; a Windows Sandbox guest with no virtual NIC at all).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>1 parent c729bd8 commit 371ffda
17 files changed
Lines changed: 4129 additions & 43 deletions
File tree
- src
- DesktopAnalyticsTests
- DesktopAnalytics
- SampleApp
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
738 | 739 | | |
739 | 740 | | |
740 | 741 | | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
741 | 758 | | |
742 | 759 | | |
743 | 760 | | |
| |||
765 | 782 | | |
766 | 783 | | |
767 | 784 | | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
768 | 813 | | |
769 | 814 | | |
770 | 815 | | |
| |||
1082 | 1127 | | |
1083 | 1128 | | |
1084 | 1129 | | |
1085 | | - | |
| 1130 | + | |
| 1131 | + | |
| 1132 | + | |
| 1133 | + | |
| 1134 | + | |
| 1135 | + | |
| 1136 | + | |
| 1137 | + | |
| 1138 | + | |
| 1139 | + | |
| 1140 | + | |
| 1141 | + | |
| 1142 | + | |
1086 | 1143 | | |
1087 | 1144 | | |
1088 | 1145 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
20 | 21 | | |
21 | 22 | | |
22 | 23 | | |
| 24 | + | |
23 | 25 | | |
| 26 | + | |
24 | 27 | | |
25 | 28 | | |
26 | 29 | | |
| |||
0 commit comments