Skip to content

Commit 371ffda

Browse files
johnml1135claude
andcommitted
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

src/DesktopAnalytics/Analytics.cs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Reflection;
1212
using System.Runtime.InteropServices;
1313
using System.Threading;
14+
using System.Threading.Tasks;
1415
using System.Xml.Linq;
1516
using System.Xml.XPath;
1617
using JetBrains.Annotations;
@@ -738,6 +739,22 @@ public void Dispose()
738739
_client?.ShutDown();
739740
}
740741

742+
/// <summary>
743+
/// Async alternative to <see cref="Dispose"/> for hosts that shut down asynchronously:
744+
/// flushes/shuts down the underlying client without blocking a thread on any in-flight
745+
/// delivery. Bounded just like Dispose -- an offline shutdown still returns promptly, with
746+
/// undelivered events left on disk (Mixpanel) or in the transport library's own store
747+
/// (Segment) for the next launch. Calling <see cref="Dispose"/> afterwards (e.g. from a
748+
/// <c>using</c> block wrapping this object) is a harmless no-op.
749+
/// </summary>
750+
/// <param name="cancellationToken">Optionally ends the final delivery attempt even sooner
751+
/// than its own bound; undelivered events stay queued for the next launch. Cancellation
752+
/// never faults the task.</param>
753+
public Task ShutDownAsync(CancellationToken cancellationToken = default)
754+
{
755+
return _client?.ShutDownAsync(cancellationToken) ?? Task.CompletedTask;
756+
}
757+
741758
/// <summary>
742759
/// Indicates whether we are tracking or not
743760
/// </summary>
@@ -765,6 +782,34 @@ public static bool AllowTracking
765782
s_singleton.Initialize(initializationParameters);
766783
return; // Initialize sets s_allowTracking = true
767784
}
785+
786+
// Already initialized and now re-enabled: re-arm any background flush loop that a
787+
// prior PurgeQueuedEvents (consent revocation) paused. See offline-analytics.md.
788+
try
789+
{
790+
s_singleton._client?.ResumeSending();
791+
}
792+
catch (Exception e)
793+
{
794+
Debug.WriteLine("Analytics.AllowTracking: ResumeSending failed: " + e);
795+
}
796+
}
797+
798+
if (!value)
799+
{
800+
// Consent revoked: purge any durable client's on-disk spool immediately (see
801+
// offline-analytics.md, "Consent & lifecycle"). Guarded against a null singleton
802+
// (AllowTracking can technically be set before an Analytics object is
803+
// constructed) and a null client (the Segment/Mixpanel client is only assigned
804+
// inside the Analytics constructor).
805+
try
806+
{
807+
s_singleton?._client?.PurgeQueuedEvents();
808+
}
809+
catch (Exception e)
810+
{
811+
Debug.WriteLine("Analytics.AllowTracking: PurgeQueuedEvents failed: " + e);
812+
}
768813
}
769814

770815
s_allowTracking = value;
@@ -1082,7 +1127,19 @@ private static string GetUserNameForEvent()
10821127

10831128
public static void FlushClient()
10841129
{
1085-
s_singleton._client?.Flush();
1130+
s_singleton?._client?.Flush();
1131+
}
1132+
1133+
/// <summary>
1134+
/// Async counterpart of <see cref="FlushClient"/>: attempts delivery of anything pending
1135+
/// without blocking a thread on the network. Bounded -- returns promptly even while
1136+
/// offline, leaving undelivered events queued.
1137+
/// </summary>
1138+
/// <param name="cancellationToken">Optionally ends the flush even sooner than its own
1139+
/// bound; undelivered events stay queued. Cancellation never faults the task.</param>
1140+
public static Task FlushClientAsync(CancellationToken cancellationToken = default)
1141+
{
1142+
return s_singleton?._client?.FlushAsync(cancellationToken) ?? Task.CompletedTask;
10861143
}
10871144
}
10881145
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System;
2+
using System.Text;
3+
using Segment.Serialization;
4+
5+
namespace DesktopAnalytics
6+
{
7+
/// <summary>
8+
/// A single spoolable analytics event in a neutral, JSON-serializable form. This is the
9+
/// unit written to and read from the on-disk event spool (DiskQueue), independent of the
10+
/// eventual transport (Mixpanel, etc). Instances are round-tripped as UTF-8 JSON bytes so
11+
/// the spool stays portable and inspectable (see <see cref="ToBytes"/>/<see cref="FromBytes"/>).
12+
/// </summary>
13+
internal class AnalyticsEvent
14+
{
15+
public string AnalyticsId { get; set; }
16+
public string EventName { get; set; }
17+
18+
/// <summary>Event properties, e.g. Message/Stack Trace for exception reports.</summary>
19+
public JsonObject Properties { get; set; }
20+
21+
/// <summary>Mixpanel's $insert_id, stamped at enqueue time so at-least-once replay can
22+
/// be safely deduplicated by the backend.</summary>
23+
public string InsertId { get; set; }
24+
25+
/// <summary>The original event time, stamped at enqueue time so a later replay is
26+
/// back-dated correctly rather than reported at delivery time.</summary>
27+
public DateTimeOffset Time { get; set; }
28+
29+
public AnalyticsEvent()
30+
{
31+
Properties = new JsonObject();
32+
}
33+
34+
/// <summary>
35+
/// Creates a new event, stamping <see cref="InsertId"/> and <see cref="Time"/>. Both are
36+
/// overridable so callers/tests can be deterministic; production code can omit them to get
37+
/// a real GUID and the real wall-clock time.
38+
/// </summary>
39+
/// <param name="analyticsId">The stable per-user analytics id.</param>
40+
/// <param name="eventName">The event name.</param>
41+
/// <param name="properties">Event properties. May be null, in which case an empty
42+
/// property bag is used.</param>
43+
/// <param name="insertId">The $insert_id. Defaults to a fresh GUID. Never call
44+
/// Guid.NewGuid() directly elsewhere in the stamping path -- pass it here so tests are
45+
/// deterministic.</param>
46+
/// <param name="time">The event time. Defaults to <c>DateTimeOffset.UtcNow</c>. Never
47+
/// call DateTime.Now/DateTimeOffset.UtcNow directly elsewhere in the stamping path --
48+
/// pass it here (e.g. from an injected <see cref="TimeProvider"/>) so tests are
49+
/// deterministic.</param>
50+
public static AnalyticsEvent Create(
51+
string analyticsId,
52+
string eventName,
53+
JsonObject properties = null,
54+
string insertId = null,
55+
DateTimeOffset? time = null
56+
)
57+
{
58+
return new AnalyticsEvent
59+
{
60+
AnalyticsId = analyticsId,
61+
EventName = eventName,
62+
Properties = properties ?? new JsonObject(),
63+
InsertId = insertId ?? Guid.NewGuid().ToString(),
64+
Time = time ?? DateTimeOffset.UtcNow
65+
};
66+
}
67+
68+
/// <summary>Serializes this event as UTF-8 JSON bytes, suitable for enqueuing into the
69+
/// on-disk spool.</summary>
70+
public byte[] ToBytes()
71+
{
72+
var json = JsonUtility.ToJson(this, false);
73+
return Encoding.UTF8.GetBytes(json);
74+
}
75+
76+
/// <summary>Deserializes an event previously produced by <see cref="ToBytes"/>.</summary>
77+
public static AnalyticsEvent FromBytes(byte[] bytes)
78+
{
79+
var json = Encoding.UTF8.GetString(bytes);
80+
return JsonUtility.FromJson<AnalyticsEvent>(json);
81+
}
82+
}
83+
}

src/DesktopAnalytics/DesktopAnalytics.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
<AssemblyOriginatorKeyFile>..\..\DesktopAnalytics.snk</AssemblyOriginatorKeyFile>
1818
</PropertyGroup>
1919
<ItemGroup>
20+
<PackageReference Include="DiskQueue" Version="1.7.2" />
2021
<PackageReference Include="JetBrains.Annotations" Version="2023.2.0">
2122
<PrivateAssets>All</PrivateAssets>
2223
</PackageReference>
24+
<PackageReference Include="Microsoft.Bcl.TimeProvider" Version="8.0.0" />
2325
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
26+
<PackageReference Include="Polly" Version="8.4.2" />
2427
</ItemGroup>
2528
<ItemGroup>
2629
<PackageReference Include="mixpanel-csharp" Version="6.0.0" />

0 commit comments

Comments
 (0)