diff --git a/Build/Manage-LocalLibraries.ps1 b/Build/Manage-LocalLibraries.ps1
index 2f5e7eb1e5..1b951d2756 100644
--- a/Build/Manage-LocalLibraries.ps1
+++ b/Build/Manage-LocalLibraries.ps1
@@ -6,7 +6,8 @@
Two modes of operation:
Pack mode (one or more source paths provided):
- Packs local checkouts of liblcm, libpalaso, chorus, and/or machine into the
+ Packs local checkouts of liblcm, libpalaso, chorus, machine, L10NSharp,
+ and/or DesktopAnalytics.net into the
local NuGet feed using each library's own version. Detects the version
from produced packages, updates SilVersions.props to match, copies
PDBs, and clears stale cached packages.
@@ -58,9 +59,17 @@
Path to a local L10NSharp checkout. Overrides L10NSHARP_PATH env var.
Only used when -L10nSharp is specified.
+.PARAMETER DesktopAnalytics
+ Switch: include DesktopAnalytics.net (SIL.DesktopAnalytics) in the pack
+ operation.
+
+.PARAMETER DesktopAnalyticsPath
+ Path to a local DesktopAnalytics.net checkout. Overrides
+ DESKTOPANALYTICS_PATH env var. Only used when -DesktopAnalytics is specified.
+
.PARAMETER Library
Which library to set a version for (SetVersion mode only):
- lcm, palaso, chorus, machine, or l10nsharp.
+ lcm, palaso, chorus, machine, l10nsharp, or desktopanalytics.
.PARAMETER Version
Sets the version in SilVersions.props (SetVersion mode). Use to revert
@@ -95,7 +104,10 @@ param(
[switch]$L10nSharp,
[string]$L10nSharpPath,
- [ValidateSet('palaso', 'lcm', 'chorus', 'machine', 'l10nsharp')]
+ [switch]$DesktopAnalytics,
+ [string]$DesktopAnalyticsPath,
+
+ [ValidateSet('palaso', 'lcm', 'chorus', 'machine', 'l10nsharp', 'desktopanalytics')]
[string]$Library,
[string]$Version
@@ -147,10 +159,18 @@ $LibraryConfig = @{
CachePrefixes = @('l10nsharp')
EnvVar = 'L10NSHARP_PATH'
}
+ desktopanalytics = @{
+ VersionProperty = 'SilDesktopAnalyticsVersion'
+ PdbRelativeDir = 'src/DesktopAnalytics/bin/Debug/net462'
+ CachePrefixes = @('sil.desktopanalytics')
+ EnvVar = 'DESKTOPANALYTICS_PATH'
+ # Pack only the library (repo also contains sample apps and tests)
+ PackProjects = @('src/DesktopAnalytics/DesktopAnalytics.csproj')
+ }
}
# Pack order: libpalaso first (other libraries may depend on it)
-$PackOrder = @('palaso', 'l10nsharp', 'lcm', 'chorus', 'machine')
+$PackOrder = @('palaso', 'l10nsharp', 'lcm', 'chorus', 'machine', 'desktopanalytics')
# ---------------------------------------------------------------------------
# Read SilVersions.props
@@ -364,6 +384,7 @@ $switchMap = @{
chorus = @{ Enabled = [bool]$Chorus; ExplicitPath = $ChorusPath }
machine = @{ Enabled = [bool]$Machine; ExplicitPath = $MachinePath }
l10nsharp = @{ Enabled = [bool]$L10nSharp; ExplicitPath = $L10nSharpPath }
+ desktopanalytics = @{ Enabled = [bool]$DesktopAnalytics; ExplicitPath = $DesktopAnalyticsPath }
}
# Resolve source paths: explicit path > env var (only when switch is set)
@@ -450,5 +471,5 @@ elseif ($Library -and $Version) {
Write-Host "Run .\build.ps1 to restore and build with the new version." -ForegroundColor Cyan
}
else {
- throw "Nothing to do. Use -Palaso/-Lcm/-Chorus/-Machine/-L10nSharp switches to pack, or -Library and -Version to set a version.`nExamples:`n .\Build\Manage-LocalLibraries.ps1 -Palaso -PalasoPath C:\Repos\libpalaso`n .\Build\Manage-LocalLibraries.ps1 -Palaso -Chorus`n .\Build\Manage-LocalLibraries.ps1 -Machine -MachinePath C:\Repos\machine`n .\Build\Manage-LocalLibraries.ps1 -Library l10nsharp -Version 10.0.0`n .\Build\Manage-LocalLibraries.ps1 -Library palaso -Version 17.0.0"
+ throw "Nothing to do. Use -Palaso/-Lcm/-Chorus/-Machine/-L10nSharp/-DesktopAnalytics switches to pack, or -Library and -Version to set a version.`nExamples:`n .\Build\Manage-LocalLibraries.ps1 -Palaso -PalasoPath C:\Repos\libpalaso`n .\Build\Manage-LocalLibraries.ps1 -Palaso -Chorus`n .\Build\Manage-LocalLibraries.ps1 -Machine -MachinePath C:\Repos\machine`n .\Build\Manage-LocalLibraries.ps1 -DesktopAnalytics -DesktopAnalyticsPath C:\Repos\DesktopAnalytics.net`n .\Build\Manage-LocalLibraries.ps1 -Library l10nsharp -Version 10.0.0`n .\Build\Manage-LocalLibraries.ps1 -Library palaso -Version 17.0.0"
}
diff --git a/Build/SilVersions.props b/Build/SilVersions.props
index b90cd986fc..8096ca65c7 100644
--- a/Build/SilVersions.props
+++ b/Build/SilVersions.props
@@ -18,6 +18,7 @@
6.0.0-beta0065
3.0.1
3.8.2
+ 4.0.0
1.1.1-beta0001
10.0.0-beta0004
0.9.8
diff --git a/CONTEXT.md b/CONTEXT.md
index 7a51bcd3a9..daa5221abe 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -89,8 +89,20 @@ It is intentionally not a full architecture manual. It should stay biased toward
- **Ambiguous feedback**: A reviewer request that lacks enough context, conflicts with another requirement, or would require a product or architecture decision. Ask the user before changing code.
- **Reply**: A response in the review conversation explaining a fix, asking a question, or giving technical reasoning for no code change.
- **Resolve**: Marking a review thread addressed in GitHub. Do this only after the code or reply fully answers the thread.
+## Analytics and Telemetry (WinForms app)
+- **Analytics (DesktopAnalytics)**: The `SIL.DesktopAnalytics` package (v4.0.0, Mixpanel client) FieldWorks wraps for usage/crash telemetry. Constructed once per process in `Src/Common/FieldWorks/FieldWorks.cs` (`new Analytics(...)`) inside a `using` block spanning the whole app lifetime; not re-initializable without a restart.
+- **`OkToPingBasicUsageData`**: The user consent flag gatekeeping all analytics. Type `SIL.Reporting.ReportingSettings.OkToPingBasicUsageData` (external type from SIL.Core, not defined in this repo), persisted as a .NET user-scoped setting (not the registry), surfaced in Tools > Options > Privacy. A toggle only takes effect after restart, since the `Analytics` singleton is constructed once at startup.
+- **Application property** (package term, sometimes called a "super property" elsewhere): `DesktopAnalytics.Analytics.SetApplicationProperty(key, value)` — attaches a key/value to every subsequent `Track`/`ReportException` call for the process's lifetime. Already exists in the package; FieldWorks calls it nowhere today. This is the seam for any new cross-cutting telemetry dimension (e.g. a UI-architecture marker), not a mechanism that needs to be built.
+- **Track / event**: `Analytics.Track(eventName, propertiesDict)`. Existing FieldWorks events: `SwitchToTool` (`area`+`tool` only, throttled to once per tool per day, in `AreaListener.cs`), `TrackingHelper`'s `TrackImport`/`TrackExport`/`TrackHelpRequest` (~10 call sites), and a few standalone `Track` calls (`ExportConcordanceResults`, `ConfigureInterlinear`, `CreateFromSRRepo`).
+- **Exception report**: `Analytics.ReportException(exception, moreProperties)`, wired to both `Application.ThreadException` and `AppDomain.UnhandledException` in `FieldWorks.cs`. Capped at 10 reports per process run by the package itself.
+- **Fire-and-forget delivery**: DesktopAnalytics has no local persistence, queue, or retry. A `Track`/`ReportException` call that can't reach Mixpanel (no network) is silently dropped (`Debug.WriteLine` only) — there is no "offline batching" anywhere in this package or repo today, despite that having been assumed in earlier planning conversation. This is load-bearing for any plan that wants event *counts* to mean something for a userbase that is frequently offline.
+- **Mixpanel key**: Currently a hardcoded literal in `FieldWorks.cs` (`#if DEBUG` dev key vs. release key) — not sourced from config, environment, or build-time substitution.
+- **UI mode / Avalonia surfaces**: Do not exist on `origin/main`. All `UIMode`/`LexicalEditSurfaceResolver`/Avalonia code lives on other branches (e.g. `phase1-base`) and is not present here. Telemetry work done against `origin/main` that anticipates a future Legacy/Avalonia split must be forward-compatible scaffolding (e.g. a constant-valued property today), not code that reads a UIMode value that doesn't exist yet.
## Remaining Open Question
- Should this root file stay mixed product-plus-architecture, or should lower-level developer terms move into narrower subtree contexts later?
## ADR Candidates
- No repository-wide ADR location is established yet.
- If a terminology decision becomes hard to reverse or affects naming across many files, record it here first and promote it to a formal ADR only if the repo adopts a dedicated ADR convention.
+- **Analytics offline durability — DECIDED (2026-07-08)**: offline durability for `Analytics.Track`/`ReportException` is owned by the `SIL.DesktopAnalytics` library itself (durable `MixpanelClient`: on-disk DiskQueue spool + Polly retry/backoff + a real per-event delivery ack (confirmed HTTP 2xx) + `$insert_id` dedup + consent-revocation purge), **not** by any FieldWorks-side queue. FieldWorks calls the analytics API directly and inherits durability from a library version bump; there is no FieldWorks-side queue. See `openspec/changes/telemetry-migration-baseline/design.md` D4 and DesktopAnalytics.net PR #43.
+- **Forward-compatible UI-architecture marker — DECIDED (2026-07-02)**: add `Analytics.SetApplicationProperty("UiFramework", "WinForms")` now, constant-valued, via the existing unused seam. The eventual Avalonia branch flips the value per-surface; no code here depends on UIMode existing.
+- **Hardcoded Mixpanel keys — DECIDED (2026-07-02)**: out of scope for this change. Pre-existing secrets-hygiene issue, unrelated to telemetry enrichment; noted as a discovered finding only.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0f8810af03..041f21c8a1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -109,7 +109,7 @@
-
+
diff --git a/Docs/architecture/local-library-debugging.md b/Docs/architecture/local-library-debugging.md
index 4baee6e7de..82eff839d5 100644
--- a/Docs/architecture/local-library-debugging.md
+++ b/Docs/architecture/local-library-debugging.md
@@ -1,6 +1,6 @@
# Local Library Debugging
-This document describes how to debug locally-modified versions of **liblcm**, **libpalaso**, **chorus**, or **machine** (SIL.Machine) in FieldWorks using a local NuGet feed.
+This document describes how to debug locally-modified versions of **liblcm**, **libpalaso**, **chorus**, **machine** (SIL.Machine), or **DesktopAnalytics.net** (SIL.DesktopAnalytics) in FieldWorks using a local NuGet feed.
## Overview
@@ -45,6 +45,7 @@ git clone https://github.com/sillsdev/liblcm.git
git clone https://github.com/sillsdev/libpalaso.git
git clone https://github.com/sillsdev/chorus.git
git clone https://github.com/sillsdev/machine.git
+git clone https://github.com/sillsdev/DesktopAnalytics.net.git
```
## Pack a local library
@@ -60,10 +61,11 @@ git clone https://github.com/sillsdev/machine.git
Or set environment variables so you can omit the paths:
```powershell
-$env:LIBPALASO_PATH = "C:\Repos\libpalaso"
-$env:LIBLCM_PATH = "C:\Repos\liblcm"
-$env:LIBCHORUS_PATH = "C:\Repos\chorus"
-$env:SILMACHINE_PATH = "C:\Repos\machine"
+$env:LIBPALASO_PATH = "C:\Repos\libpalaso"
+$env:LIBLCM_PATH = "C:\Repos\liblcm"
+$env:LIBCHORUS_PATH = "C:\Repos\chorus"
+$env:SILMACHINE_PATH = "C:\Repos\machine"
+$env:DESKTOPANALYTICS_PATH = "C:\Repos\DesktopAnalytics.net"
# Switches still required — env vars only provide the path
.\Build\Manage-LocalLibraries.ps1 -Palaso -Chorus
@@ -142,6 +144,33 @@ dotnet nuget remove source local
| libpalaso | `-Palaso` | `-PalasoPath` | `SilLibPalasoVersion` | `LIBPALASO_PATH` |
| chorus | `-Chorus` | `-ChorusPath` | `SilChorusVersion` | `LIBCHORUS_PATH` |
| machine | `-Machine` | `-MachinePath` | `SilMachineVersion` | `SILMACHINE_PATH` |
+| DesktopAnalytics.net | `-DesktopAnalytics` | `-DesktopAnalyticsPath` | `SilDesktopAnalyticsVersion` | `DESKTOPANALYTICS_PATH` |
+
+## Smoke-testing offline durability (DesktopAnalytics.net)
+
+Two scripts validate a local DesktopAnalytics.net build's offline/online
+behavior directly (via its `SampleApp`), without needing a full FieldWorks
+launch. Both are per-process or guest-scoped and never touch the host
+machine's own network:
+
+- `scripts/Test-AnalyticsOfflineDrain.ps1` — blocks `SampleApp.exe`'s
+ outbound traffic with a scoped Windows Firewall rule (requires an elevated
+ shell), confirms events accumulate undelivered in the on-disk spool, then
+ removes the block and confirms they drain. This is the accumulate-then-
+ reconnect transition test.
+- `scripts/Test-AnalyticsOfflineSandbox.ps1` — runs `SampleApp` inside a
+ disposable Windows Sandbox guest with `Disable`
+ (no virtual NIC at all), confirming events are submitted but never
+ falsely reported delivered when there is truly no route out. This is a
+ "born offline" defense-in-depth check, complementary to the drain test
+ above (see script header for why a sandbox can't test the transition).
+
+Neither needs a separately-provisioned token: both read FieldWorks' own
+DEBUG-build Mixpanel key directly out of `FieldWorks.cs` (`analyticsKey`
+under `#if DEBUG`) at run time if `-ApiSecret` isn't supplied — the
+separate dev/test project every Debug FieldWorks build already reports to.
+Override with `-ApiSecret` or `$env:DESKTOPANALYTICS_TEST_API_SECRET` to
+point elsewhere; never pass the `#else` (RELEASE) key.
## See Also
diff --git a/Src/Common/FieldWorks/FieldWorks.cs b/Src/Common/FieldWorks/FieldWorks.cs
index 1947cc444c..58868ffb48 100644
--- a/Src/Common/FieldWorks/FieldWorks.cs
+++ b/Src/Common/FieldWorks/FieldWorks.cs
@@ -123,6 +123,14 @@ private enum StartupStatus
// supports usage reporting has been run.
private static bool s_noPreviousReportingSettings;
private static ILcmUI s_ui;
+ // Telemetry session baseline (openspec/changes/telemetry-migration-baseline): identifies
+ // this process's session for correlating its SessionStart/SessionEnd events, and guards
+ // against both the clean-shutdown path (Main's finally) and the crash paths
+ // (HandleUnhandledException/HandleTopLevelError) reporting a session-end for the same
+ // session - whichever runs first wins.
+ private static string s_sessionId;
+ private static DateTime s_sessionStartUtc;
+ private static int s_sessionEndReported;
private static FwApplicationSettings s_appSettings;
#endregion
@@ -233,6 +241,17 @@ static int Main(string[] rgArgs)
#endif
using (new Analytics(analyticsKey, new UserInfo(), sendFeedback, clientType: DesktopAnalytics.ClientType.Mixpanel))
{
+ // Forward-compatible marker (openspec/changes/telemetry-migration-baseline):
+ // constant today since this codebase has only one UI framework; a future
+ // Avalonia surface sets a different value on the same application property so
+ // all events/exceptions become segmentable by UI framework without any change
+ // to how they're tracked.
+ Analytics.SetApplicationProperty("UiFramework", "WinForms");
+
+ s_sessionId = Guid.NewGuid().ToString();
+ s_sessionStartUtc = DateTime.UtcNow;
+ s_sessionEndReported = 0;
+ Analytics.Track("SessionStart", new Dictionary { { "SessionId", s_sessionId } });
Logger.WriteEvent("Starting app");
SetGlobalExceptionHandler();
@@ -386,16 +405,25 @@ static int Main(string[] rgArgs)
}
catch (ApplicationException ex)
{
+ TryReportSessionEnd("crashed");
MessageBox.Show(ex.Message, FwUtils.ksSuiteName);
return 2;
}
catch (Exception ex)
{
+ TryReportSessionEnd("crashed");
SafelyReportException(ex, s_activeMainWnd, true);
return 2;
}
finally
{
+ // Covers the normal exit path (including early "return 0"s inside the try block,
+ // which still run through here) exactly once, regardless of how many return
+ // points exist - unlike trying to place this at "the end of" the using block,
+ // which any early return would skip past. The atomic guard in
+ // TryReportSessionEnd means this is a no-op if a crash path already reported the
+ // session's end first (openspec/changes/telemetry-migration-baseline).
+ TryReportSessionEnd("clean");
StaticDispose();
if (Xpcom.IsInitialized)
{
@@ -1087,6 +1115,31 @@ private static void HandleUnhandledException(object sender, UnhandledExceptionEv
DisplayError(new ApplicationException(string.Format("Got unknown exception: {0}",
e.ExceptionObject)), false);
}
+
+ // AppDomain.UnhandledException is (on the classic .NET Framework desktop CLR) always
+ // terminating in practice; e.IsTerminating is trusted here rather than assumed.
+ if (e.IsTerminating)
+ TryReportSessionEnd("crashed");
+ }
+
+ ///
+ /// Reports the SessionEnd telemetry event exactly once per process, regardless of which
+ /// of the (potentially concurrent) shutdown/crash paths reaches it first.
+ /// See openspec/changes/telemetry-migration-baseline/design.md D2.
+ ///
+ /// "clean" or "crashed"
+ private static void TryReportSessionEnd(string outcome)
+ {
+ if (Interlocked.CompareExchange(ref s_sessionEndReported, 1, 0) != 0)
+ return; // already reported by a different path
+
+ var durationSeconds = (DateTime.UtcNow - s_sessionStartUtc).TotalSeconds;
+ Analytics.Track("SessionEnd", new Dictionary
+ {
+ { "SessionId", s_sessionId },
+ { "outcome", outcome },
+ { "duration", durationSeconds.ToString("F1", CultureInfo.InvariantCulture) }
+ });
}
///
@@ -1142,6 +1195,11 @@ private static void HandleTopLevelError(object sender, ThreadExceptionEventArgs
if (DisplayError(eventArgs.Exception, false))
{
FwApp.InCrashedState = true;
+ // Unlike HandleUnhandledException, Application.ThreadException doesn't always
+ // terminate the app (see the comment on SetGlobalExceptionHandler) - this branch
+ // is specifically the "we've decided to exit" case, so it's the correct place to
+ // report a crashed session-end, not the top of this method.
+ TryReportSessionEnd("crashed");
Application.Exit();
// just to be sure
diff --git a/Src/LexText/LexTextDll/AreaListener.cs b/Src/LexText/LexTextDll/AreaListener.cs
index 1fd563f85f..c15ed01ce0 100644
--- a/Src/LexText/LexTextDll/AreaListener.cs
+++ b/Src/LexText/LexTextDll/AreaListener.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.Linq;
using SIL.FieldWorks.Common.FwUtils;
using static SIL.FieldWorks.Common.FwUtils.FwUtils;
@@ -127,6 +128,22 @@ protected virtual void Dispose(bool disposing)
if (disposing)
{
+ // Final dwell-time record for whatever tool was active when the app is closing,
+ // so it isn't silently lost because no further tool switch occurs (bypasses the
+ // once-per-day throttle deliberately - this is a distinct, one-time-per-session
+ // closing signal, not a repeated "switched to" event).
+ if (!string.IsNullOrEmpty(m_lastActivatedToolName) && m_lastToolActivationUtc != DateTime.MinValue)
+ {
+ var finalDwellSeconds = (DateTime.UtcNow - m_lastToolActivationUtc).TotalSeconds;
+ DesktopAnalytics.Analytics.Track("SwitchToTool", new Dictionary
+ {
+ {"area", m_lastActivatedAreaName ?? string.Empty},
+ {"tool", m_lastActivatedToolName},
+ {"duration", finalDwellSeconds.ToString("F1", CultureInfo.InvariantCulture)},
+ {"final", "true"}
+ });
+ }
+
Subscriber.Unsubscribe(EventConstants.SetToolFromName, SetToolFromName);
Subscriber.Unsubscribe(EventConstants.ReloadAreaTools, ReloadAreaTools);
Subscriber.Unsubscribe(EventConstants.GetContentControlParameters, GetContentControlParameters);
@@ -164,6 +181,15 @@ public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configu
private DateTime m_lastToolChange = DateTime.MinValue;
HashSet m_toolsReportedToday = new HashSet();
+ ///
+ /// Updated on every tool activation regardless of 's
+ /// once-per-day throttle, so a throttled-out revisit doesn't lose the timestamp needed to
+ /// compute dwell time for whichever activation next actually fires a SwitchToTool event.
+ ///
+ private DateTime m_lastToolActivationUtc = DateTime.MinValue;
+ private string m_lastActivatedToolName;
+ private string m_lastActivatedAreaName;
+
public void OnPropertyChanged(string name)
{
CheckDisposed();
@@ -191,15 +217,26 @@ public void OnPropertyChanged(string name)
m_lastToolChange = DateTime.Now;
}
string areaNameForReport = m_propertyTable.GetStringProperty("areaChoice", null);
+ // Dwell time since the tool was last activated, regardless of whether that
+ // prior activation actually fired a (throttled) event of its own.
+ double? dwellSeconds = m_lastToolActivationUtc == DateTime.MinValue
+ ? (double?)null
+ : (DateTime.UtcNow - m_lastToolActivationUtc).TotalSeconds;
if (!string.IsNullOrWhiteSpace(areaNameForReport) && !m_toolsReportedToday.Contains(toolName))
{
m_toolsReportedToday.Add(toolName);
- DesktopAnalytics.Analytics.Track("SwitchToTool", new Dictionary
+ var eventProps = new Dictionary
{
{"area", areaNameForReport},
{"tool", toolName}
- });
+ };
+ if (dwellSeconds.HasValue)
+ eventProps["duration"] = dwellSeconds.Value.ToString("F1", CultureInfo.InvariantCulture);
+ DesktopAnalytics.Analytics.Track("SwitchToTool", eventProps);
}
+ m_lastToolActivationUtc = DateTime.UtcNow;
+ m_lastActivatedToolName = toolName;
+ m_lastActivatedAreaName = areaNameForReport;
break;
case "areaChoice":
diff --git a/openspec/changes/telemetry-migration-baseline/.openspec.yaml b/openspec/changes/telemetry-migration-baseline/.openspec.yaml
new file mode 100644
index 0000000000..8e26fbece7
--- /dev/null
+++ b/openspec/changes/telemetry-migration-baseline/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-02
diff --git a/openspec/changes/telemetry-migration-baseline/design.md b/openspec/changes/telemetry-migration-baseline/design.md
new file mode 100644
index 0000000000..a5ce20142f
--- /dev/null
+++ b/openspec/changes/telemetry-migration-baseline/design.md
@@ -0,0 +1,40 @@
+## Context
+
+FieldWorks wraps `SIL.DesktopAnalytics` (Mixpanel client) with a single `Analytics` instance constructed once at startup (`Src/Common/FieldWorks/FieldWorks.cs`) inside a `using` block spanning the app's lifetime, gated by `FwApplicationSettings.Reporting.OkToPingBasicUsageData`. Several files call `Analytics.Track`/`Analytics.ReportException` directly today: `FieldWorks.cs` (exceptions), `AreaListener.cs` (`SwitchToTool`), `TrackingHelper.cs` (import/export/help), `ConcordanceContainer.cs`, `ConfigureInterlinDialog.cs`, and `ObtainProjectMethod.cs`. `DesktopAnalytics` also already exposes an unused `Analytics.SetApplicationProperty(key, value)` mechanism (an "application property" attached to every subsequent event) and an internal per-process cap of 10 exception reports (`MAX_EXCEPTION_REPORTS_PER_RUN`).
+
+Offline durability — delivering events generated while Mixpanel is unreachable — is provided by the `SIL.DesktopAnalytics` library itself, not by FieldWorks (see D4). This change therefore adds no FieldWorks-side persistence, queue, or retry: it instruments session and usage baseline data on top of the existing direct `Analytics` calls.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Add session-level baseline metrics: duration and crash-vs-clean outcome (`telemetry-session-baseline`).
+- Add a forward-compatible, constant-valued `UiFramework` property and per-tool dwell time (`telemetry-usage-enrichment`).
+- Keep the existing consent flag (`OkToPingBasicUsageData`) as the sole authority over whether any of this data is ever sent.
+
+**Non-Goals:**
+- Any FieldWorks-side offline queue/retry/persistence for analytics — that lives in the `SIL.DesktopAnalytics` library, not here (D4).
+- Any code that reads, branches on, or depends on `UIMode`/Avalonia concepts, none of which exist on this branch.
+- Changing the Mixpanel API key handling (pre-existing, out of scope per proposal).
+
+## Decisions
+
+### D1 — Forward-compatible `UiFramework` application property, set once at startup
+`Analytics.SetApplicationProperty("UiFramework", "WinForms")` is called once, right after the `Analytics` instance is constructed, so every subsequently tracked event and exception report is segmentable by UI framework. The value is a constant today — this codebase has only one UI framework — and a future Avalonia surface sets a different value on the same property without changing how anything is tracked. No code reads or depends on a `UIMode` value (none exists on this branch).
+
+### D2 — Session identity, correlation, and the single-terminal-event guard
+A `SessionId` (`Guid.NewGuid()`) is generated once at startup and stored for the process's lifetime. Session-start is tracked right after the `Analytics` instance is constructed. Session-end is tracked from exactly one of two places — the normal fall-through in `Main`'s `finally` (outcome `clean`), or `HandleUnhandledException`/`HandleTopLevelError` (outcome `crashed`) — guarded by `Interlocked.CompareExchange` on a static flag so whichever path runs first wins and the other is suppressed. Duration is `DateTime.UtcNow` at the reporting point minus the recorded start time. The `finally` placement covers every normal exit (including early `return`s inside the `try`), which placing the call "at the end of" the `using` block would skip.
+
+### D3 — SwitchToTool dwell time via a stored "last activation" timestamp
+`AreaListener` already fires `SwitchToTool` on tool changes (throttled to once per tool per day). A new field records `DateTime.UtcNow` every time the active tool changes, regardless of whether the throttle allows an event to fire. When the (already-throttled) `SwitchToTool` event does fire, it includes a `duration` property computed from that stored timestamp — i.e., the dwell time since the tool was last activated, not a running total across a whole day (a deliberate simplification consistent with the existing throttle, not a bug — see Risks). At application shutdown, if a tool is currently active, one additional dwell record is tracked for that final stretch so it isn't silently lost.
+
+### D4 — Offline durability is the `SIL.DesktopAnalytics` library's responsibility, not FieldWorks'
+FieldWorks calls `Analytics.Track`/`Analytics.ReportException` directly. Making those calls durable when Mixpanel is unreachable — an on-disk spool, retry/backoff, a real per-event delivery ack (confirmed HTTP 2xx, not merely "didn't throw"), `$insert_id` dedup so at-least-once replay is deduplicated, and consent-revocation purge — is implemented once, in the library's durable `MixpanelClient`, so every consumer benefits and FieldWorks carries no bespoke queueing code. Until a FieldWorks build references that durable library version, offline-generated events are delivered best-effort exactly as they are today; that is an accepted, temporary gap, not something this change works around locally. Local iteration against an unreleased library build is supported via `Build/Manage-LocalLibraries.ps1 -DesktopAnalytics` (see `Docs/architecture/local-library-debugging.md`), and the two `scripts/Test-AnalyticsOffline*.ps1` smoke tests validate that library's durable spool directly, without a FieldWorks launch.
+
+## Risks / Trade-offs
+
+- **[Risk] `SwitchToTool` dwell time reflects only the most recent stretch in a tool, not cumulative time across a whole day** (a consequence of preserving the existing once-per-day throttle) → **Mitigation**: documented here and in the spec; if cumulative dwell time is later wanted, that's a throttle-semantics change outside this change's scope.
+- **[Risk] Offline-generated events can still be lost until FieldWorks references the durable `SIL.DesktopAnalytics` build** → **Mitigation**: accepted, temporary; durability is delivered by a library version bump (D4), not FieldWorks-side code, and is no worse than today's behavior in the meantime.
+
+## Open Questions
+
+- Whether `SessionId` should additionally ride along as `Analytics.SetApplicationProperty("SessionId", ...)` so every other event (not just session-start/end) is automatically correlated to a session — not required by the spec. **Decision for this implementation pass: not added**, to keep the enrichment scoped exactly to what the spec requires.
diff --git a/openspec/changes/telemetry-migration-baseline/proposal.md b/openspec/changes/telemetry-migration-baseline/proposal.md
new file mode 100644
index 0000000000..f719a74457
--- /dev/null
+++ b/openspec/changes/telemetry-migration-baseline/proposal.md
@@ -0,0 +1,35 @@
+## Why
+
+A future Avalonia UI rewrite (in progress on a separate, unmerged branch) will let users opt into a new UI per-surface, and that effort will need a "Legacy baseline" of usage/crash/session data to judge whether the new UI is healthy relative to today's WinForms app. FieldWorks already ships `SIL.DesktopAnalytics` (Mixpanel-backed), gated by the `OkToPingBasicUsageData` consent flag, but it tracks only sparse per-tool events and has no session concept — so the baseline that later work will need to compare against does not exist yet, and every day this ships without it is baseline data that can never be recovered. This change adds that baseline instrumentation entirely within the current WinForms app, with no dependency on the unmerged Avalonia work.
+
+Offline durability of delivery (events generated while Mixpanel is unreachable) is deliberately **not** solved here. It is owned by the `SIL.DesktopAnalytics` library itself (a durable `MixpanelClient` with an on-disk spool and a real per-event delivery ack). FieldWorks calls `Analytics.Track`/`Analytics.ReportException` directly and inherits durability from the library once a build references the durable version — there is no FieldWorks-side queue.
+
+## What Changes
+
+- Call the existing (currently unused) `Analytics.SetApplicationProperty("UiFramework", "WinForms")` once at startup, so every event and exception report is segmentable by UI framework the moment a future second value exists. The value is a constant today; no code here reads or depends on any UIMode concept, since none exists in this codebase yet.
+- Add session-level tracking: a session-start event at launch and a session-end event at shutdown, sufficient to compute session duration and a crash-free-session rate (a session ending via the unhandled-exception path must be distinguishable from a clean shutdown).
+- Enrich the existing `SwitchToTool` event with per-tool dwell time (time spent in a tool before switching away or app exit), without changing its existing `area`/`tool` properties or its once-per-tool-per-day throttle.
+
+## Non-Goals
+
+- Not building any FieldWorks-side offline queue, retry, or persistence layer for analytics — offline durability is the `SIL.DesktopAnalytics` library's responsibility, not this change's.
+- Not changing the hardcoded Mixpanel API keys in `FieldWorks.cs` — a pre-existing, unrelated secrets-hygiene issue noted but explicitly out of scope.
+- Not adding, reading, or depending on `UIMode`, `LexicalEditSurfaceResolver`, or any Avalonia-branch-only concept. No such code exists on this branch; any anticipated future segmentation is represented as a constant value today, not a live branch.
+- Not defining the Avalonia-side "bake metric" thresholds, rollout ladder, or beta-cohort mechanics — this change only produces the baseline data those future decisions would consume.
+- Not changing what the consent flag means or how it's surfaced in the UI; it remains the sole authority for whether any telemetry (new or existing) is sent.
+
+## Capabilities
+
+### New Capabilities
+- `telemetry-session-baseline`: session-start/session-end tracking with clean-vs-crash termination classification, enabling session duration and crash-free-session rate.
+- `telemetry-usage-enrichment`: the `UiFramework` application property and `SwitchToTool` dwell-time enrichment.
+
+### Modified Capabilities
+(none — no existing `openspec/specs/` capability covers analytics/telemetry today)
+
+## Impact
+
+- **Managed C# only** (`Src/Common/FieldWorks/`, `Src/LexText/LexTextDll/`); no native (C++) changes.
+- Touches the `Analytics` construction/shutdown path in `Src/Common/FieldWorks/FieldWorks.cs` and the `SwitchToTool` tracking in `Src/LexText/LexTextDll/AreaListener.cs`.
+- Depends on the existing `SIL.DesktopAnalytics` package and the existing `OkToPingBasicUsageData` consent flag; introduces no new external dependencies and no new on-disk state.
+- Testable via `.\test.ps1`; no changes to `build.ps1` phase ordering expected.
diff --git a/openspec/changes/telemetry-migration-baseline/specs/telemetry-session-baseline/spec.md b/openspec/changes/telemetry-migration-baseline/specs/telemetry-session-baseline/spec.md
new file mode 100644
index 0000000000..a2f6fcfb5d
--- /dev/null
+++ b/openspec/changes/telemetry-migration-baseline/specs/telemetry-session-baseline/spec.md
@@ -0,0 +1,43 @@
+## ADDED Requirements
+
+### Requirement: Session-start event
+The application SHALL track a session-start event once per process launch, gated by the existing `OkToPingBasicUsageData` consent flag, carrying a session identifier unique to that launch.
+
+#### Scenario: App launches with consent enabled
+- **WHEN** the application finishes startup and `OkToPingBasicUsageData` is `true`
+- **THEN** a session-start event is tracked, carrying a session identifier not reused by any other launch
+
+### Requirement: Session-end event on clean shutdown
+The application SHALL track a session-end event with a `clean` outcome when it exits through its normal shutdown path.
+
+#### Scenario: User closes the app normally
+- **WHEN** the application shuts down through its normal exit path with no unhandled exception
+- **THEN** a session-end event is tracked with outcome `clean`, correlated to the session-start event for that launch
+
+### Requirement: Session-end classification on crash
+The application SHALL track a session-end event with a `crashed` outcome when termination occurs via the global unhandled-exception handling path, distinguishable from a clean shutdown.
+
+#### Scenario: Unhandled exception terminates the app
+- **WHEN** `HandleUnhandledException` or `HandleTopLevelError` handles an exception that results in the application terminating
+- **THEN** a session-end event is tracked with outcome `crashed`, correlated to the session-start event for that launch
+
+### Requirement: Session duration
+The session-end event SHALL include the elapsed wall-clock duration since the corresponding session-start event.
+
+#### Scenario: Session lasts a measurable duration
+- **WHEN** a session-end event is tracked
+- **THEN** it carries a duration value reflecting the elapsed time since that session's session-start event
+
+### Requirement: Session correlation identifier
+Session-start and session-end events belonging to the same process run SHALL share a common session identifier, and that identifier SHALL NOT be reused across different launches.
+
+#### Scenario: Multiple launches produce distinct sessions
+- **WHEN** the application is launched, exited, and launched again
+- **THEN** each launch's session-start and session-end events share one identifier, and the two launches' identifiers differ
+
+### Requirement: Single terminal event per session
+At most one session-end event SHALL be recorded per session-start event; a crash-path session-end SHALL prevent a duplicate clean-path session-end from also firing for the same session.
+
+#### Scenario: Crash path already recorded the session end
+- **WHEN** the crash-handling path has already tracked a `crashed` session-end event for the current session
+- **THEN** no additional `clean` session-end event is tracked for that same session
diff --git a/openspec/changes/telemetry-migration-baseline/specs/telemetry-usage-enrichment/spec.md b/openspec/changes/telemetry-migration-baseline/specs/telemetry-usage-enrichment/spec.md
new file mode 100644
index 0000000000..4f6a118824
--- /dev/null
+++ b/openspec/changes/telemetry-migration-baseline/specs/telemetry-usage-enrichment/spec.md
@@ -0,0 +1,36 @@
+## ADDED Requirements
+
+### Requirement: UiFramework application property
+The application SHALL set a `DesktopAnalytics` application property named `UiFramework` with the constant value `WinForms` once at startup, via the existing `Analytics.SetApplicationProperty` mechanism, so it is attached to all subsequently tracked events and exception reports.
+
+#### Scenario: Any event tracked after startup
+- **WHEN** any `Analytics.Track` or `Analytics.ReportException` call occurs after startup has completed
+- **THEN** the resulting Mixpanel event includes a `UiFramework` property with value `WinForms`
+
+### Requirement: No dependency on UI-mode concepts
+The `UiFramework` property SHALL be set to a fixed constant and SHALL NOT read from, branch on, or otherwise depend on any UI-mode or Avalonia-specific concept.
+
+#### Scenario: Built without any Avalonia/UIMode code present
+- **WHEN** the application is built and run on a codebase that contains no `UIMode`, `LexicalEditSurfaceResolver`, or other Avalonia-branch-only code
+- **THEN** the `UiFramework` property still resolves to a valid constant value with no compile-time or run-time dependency on such code
+
+### Requirement: SwitchToTool dwell-time enrichment
+The existing `SwitchToTool` event SHALL include a duration property representing the time spent in the previously active tool before the switch (or before application exit).
+
+#### Scenario: User switches tools after working in one
+- **WHEN** a user works in tool A for a period of time and then switches to tool B
+- **THEN** the `SwitchToTool` event recorded for that switch includes a duration reflecting the time spent in tool A
+
+### Requirement: Preserve existing throttle and properties
+The dwell-time enrichment SHALL NOT change the existing `area`/`tool` properties on `SwitchToTool`, nor change its existing once-per-tool-per-day throttle behavior.
+
+#### Scenario: Same tool revisited multiple times in one day
+- **WHEN** a user switches into the same tool more than once within the same day
+- **THEN** `SwitchToTool` still fires at most once for that (tool, day) pair as before, now additionally carrying a duration value
+
+### Requirement: Final dwell time captured at shutdown
+Dwell time accumulated in the tool active at the moment the application exits SHALL be captured and tracked rather than silently discarded because no further tool switch occurs.
+
+#### Scenario: User exits while a tool is active
+- **WHEN** the user exits the application while a tool is the currently active one
+- **THEN** a dwell-time record reflecting that tool's final active period is tracked as part of shutdown
diff --git a/openspec/changes/telemetry-migration-baseline/tasks.md b/openspec/changes/telemetry-migration-baseline/tasks.md
new file mode 100644
index 0000000000..c9cb32f797
--- /dev/null
+++ b/openspec/changes/telemetry-migration-baseline/tasks.md
@@ -0,0 +1,33 @@
+## 1. Usage enrichment — `UiFramework` property (`Src/Common/FieldWorks/FieldWorks.cs`)
+
+- [x] 1.1 Call `Analytics.SetApplicationProperty("UiFramework", "WinForms")` once at startup, right after `Analytics` construction (design.md D1).
+- [ ] 1.2 **GAP — not implemented.** No automated test verifies that events tracked after startup carry `UiFramework=WinForms`: `SetApplicationProperty`'s effect is internal to the `DesktopAnalytics` package (it tags outgoing Mixpanel payloads, which this codebase has no visibility into after the fact) and requires a live `Analytics` singleton to exercise at all. Verifiable only by manual/Mixpanel-side inspection (§4.3).
+
+## 2. Session baseline (`Src/Common/FieldWorks/FieldWorks.cs`)
+
+- [x] 2.1 Generate a `SessionId` (`Guid.NewGuid()`) once at startup and store it for the process lifetime.
+- [x] 2.2 Track a session-start event immediately after the `Analytics` instance is constructed, carrying `SessionId`.
+- [x] 2.3 Add the `Interlocked.CompareExchange`-guarded single-terminal-event flag (design.md D2).
+- [x] 2.4 Track a `clean`-outcome session-end event in `Main`'s `finally`, including duration since session-start.
+- [x] 2.5 Track a `crashed`-outcome session-end event from `HandleUnhandledException`/`HandleTopLevelError`, including duration.
+- [ ] 2.6 **GAP — not implemented.** Unit/integration tests for session-start/end tracking. `FieldWorks.Main`'s startup/shutdown sequence is not currently structured for isolated unit testing (it constructs a real `Analytics` singleton, which can only happen once per process). Recommended follow-up: extract the session-lifecycle logic into a small, independently-testable class, or cover via manual verification (§4.3) until then.
+
+## 3. Usage enrichment — `SwitchToTool` dwell time (`Src/LexText/LexTextDll/AreaListener.cs`)
+
+- [x] 3.1 Add a field tracking the last tool-activation timestamp, updated on every tool change regardless of the once-per-day throttle.
+- [x] 3.2 When the throttled `SwitchToTool` event does fire, include a `duration` property computed from the stored timestamp (design.md D3), without changing the existing `area`/`tool` properties.
+- [x] 3.3 Add a final dwell-time record at application shutdown for whatever tool is currently active.
+- [ ] 3.4 **GAP — not implemented.** No unit test covers the dwell-time behavior itself (repeated same-day revisits still throttle but carry a duration; a delayed switch reports that delay; shutdown while a tool is active produces one final record). Recommended follow-up: extract the dwell-time computation (`m_lastToolActivationUtc` diff) into a small pure function that can be unit-tested directly — the logic is simple and low-risk but currently unverified by an automated test.
+
+## 4. Verification
+
+- [x] 4.1 Run `.\build.ps1` for the full managed build (no native changes expected; only managed projects relinked).
+- [x] 4.2 Run `.\test.ps1` for the affected test projects and confirm zero regressions vs. `main`: `FwUtilsTests` 375/375, `LexTextDllTests` 3/3, `FieldWorksTests` 34/34 (1 pre-existing skip).
+- [ ] 4.3 **MANUAL — not performed.** In a real FieldWorks launch with `OkToPingBasicUsageData` enabled, confirm session-start/end and `SwitchToTool` dwell events appear in Mixpanel carrying the `UiFramework=WinForms` property, and that the Tools > Options > Privacy checkbox still gates all telemetry. Not covered by the automated suite.
+
+## 5. Offline durability (external — `SIL.DesktopAnalytics` library)
+
+Offline durability is provided by the `SIL.DesktopAnalytics` library's durable `MixpanelClient` (on-disk spool, retry/backoff, real per-event delivery ack, `$insert_id` dedup, consent purge), **not** by any FieldWorks-side code (design.md D4). Local iteration against an unreleased build is supported via `Build/Manage-LocalLibraries.ps1 -DesktopAnalytics` (see `Docs/architecture/local-library-debugging.md`).
+
+- [ ] 5.1 **EXTERNAL (DesktopAnalytics.net repo).** Release the durable-Mixpanel `SIL.DesktopAnalytics` version. Smoke-tested directly (without a FieldWorks launch) via `scripts/Test-AnalyticsOfflineDrain.ps1` (per-process firewall block, accumulate-then-drain across processes) and `scripts/Test-AnalyticsOfflineSandbox.ps1` (Windows Sandbox, no guest NIC — born-offline, never falsely acked). Both PASS as of 2026-07-09 against the live Mixpanel endpoint (PR sillsdev/DesktopAnalytics.net#43).
+- [ ] 5.2 Once released, bump `SilDesktopAnalyticsVersion` in `Build/SilVersions.props` so FieldWorks inherits durable offline delivery with no other code change (diff the 4.0.0 → new API surface first; verify CPM transitive pinning for the library's new DiskQueue/Polly dependencies).
diff --git a/scripts/Test-AnalyticsOfflineDrain.ps1 b/scripts/Test-AnalyticsOfflineDrain.ps1
new file mode 100644
index 0000000000..805cfa6890
--- /dev/null
+++ b/scripts/Test-AnalyticsOfflineDrain.ps1
@@ -0,0 +1,269 @@
+#Requires -RunAsAdministrator
+<#
+.SYNOPSIS
+ Smoke-tests SIL.DesktopAnalytics' durable Mixpanel spool (EventSpool +
+ MixpanelEventSender) against a real, per-process network block.
+
+.DESCRIPTION
+ Unlike the old NetworkInterface.GetIsNetworkAvailable() gate, the durable
+ MixpanelClient decides "offline" from real HTTP send failures. That means a
+ per-process outbound firewall rule against just the test executable -- not a
+ whole-machine network outage -- is now an accurate way to prove
+ accumulate-while-blocked / drain-when-unblocked. This script:
+
+ 1. Builds DesktopAnalytics.net's SampleApp (a small console app that fires
+ two Track() calls with SampleApp's own internal wait/shutdown sequence).
+ 2. Blocks SampleApp.exe's outbound traffic with a scoped firewall rule.
+ 3. Runs SampleApp once fully blocked and confirms both events were
+ submitted but NOT delivered (they stay in the on-disk spool).
+ 4. Removes the block.
+ 5. Runs SampleApp again and confirms it delivers MORE events than it
+ itself submitted -- proof the leftover spooled events from the blocked
+ run were drained, not lost.
+
+ The firewall rule is always removed, even on failure (see try/finally).
+ No host-wide network state is ever touched.
+
+ Both runs pass SampleApp's `c=false` flag to skip its built-in AllowTracking
+ on/off demo: toggling AllowTracking calls PurgeQueuedEvents, which empties
+ the entire on-disk spool immediately -- including events left over from the
+ blocked run -- so with that demo left on, step 5's assertion could never
+ actually pass (leftover events get purged, not delivered, before the next
+ scheduled flush tick).
+
+.PARAMETER DesktopAnalyticsPath
+ Path to a local DesktopAnalytics.net checkout. Defaults to the
+ DESKTOPANALYTICS_PATH env var used by Build/Manage-LocalLibraries.ps1.
+
+.PARAMETER ApiSecret
+ Mixpanel project token to use for this test run. If not supplied (and
+ DESKTOPANALYTICS_TEST_API_SECRET is not set), this script reads
+ FieldWorks' own DEBUG-build analytics key directly out of
+ Src/Common/FieldWorks/FieldWorks.cs at run time (the "analyticsKey"
+ constant under #if DEBUG) -- the same dev/test Mixpanel project every
+ Debug FieldWorks build already reports to, kept separate from the
+ RELEASE key. The key is read from that single source of truth each run,
+ never duplicated as a literal in this script. Override with
+ -ApiSecret/DESKTOPANALYTICS_TEST_API_SECRET to point elsewhere (e.g. a
+ local WireMock stub's own placeholder token).
+
+.PARAMETER ClientType
+ DesktopAnalytics client type to exercise. Defaults to Mixpanel, which is
+ the only client with the new durable spool as of this writing.
+
+.PARAMETER Configuration
+ Build configuration for SampleApp. Defaults to Debug.
+
+.PARAMETER SkipClean
+ Skip clearing the pre-existing on-disk spool for this API secret before
+ the test. Without this switch, the spool directory for ApiSecret is
+ deleted first so the two-run assertion below is deterministic.
+
+.EXAMPLE
+ .\scripts\Test-AnalyticsOfflineDrain.ps1 -DesktopAnalyticsPath C:\Repos\DesktopAnalytics.net
+#>
+[CmdletBinding()]
+param(
+ [string]$DesktopAnalyticsPath = $env:DESKTOPANALYTICS_PATH,
+
+ [string]$ApiSecret = $env:DESKTOPANALYTICS_TEST_API_SECRET,
+
+ [ValidateSet('Mixpanel', 'Segment')]
+ [string]$ClientType = 'Mixpanel',
+
+ [string]$Configuration = 'Debug',
+
+ [switch]$SkipClean
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Test-IsElevated {
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+ $principal = [Security.Principal.WindowsPrincipal]::new($identity)
+ return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+}
+
+function Get-FieldWorksDebugAnalyticsKey {
+ # Reads the DEBUG-build Mixpanel key directly out of FieldWorks.cs (single source of
+ # truth) rather than duplicating it as a literal anywhere in this script.
+ param([string]$RepoRoot)
+
+ $fieldWorksCs = Join-Path $RepoRoot 'Src\Common\FieldWorks\FieldWorks.cs'
+ if (-not (Test-Path $fieldWorksCs)) {
+ return $null
+ }
+
+ $content = Get-Content -Path $fieldWorksCs -Raw
+ $match = [regex]::Match($content, '#if DEBUG\s+const string analyticsKey = "([0-9a-fA-F]+)"')
+ if (-not $match.Success) {
+ return $null
+ }
+ return $match.Groups[1].Value
+}
+
+function Get-SpoolPath {
+ param([string]$Secret)
+
+ $sha256 = [Security.Cryptography.SHA256]::Create()
+ try {
+ $hashBytes = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($Secret))
+ }
+ finally {
+ $sha256.Dispose()
+ }
+ # Matches EventSpool.HashApiKey: first 8 bytes, lowercase hex.
+ $hex = -join ($hashBytes[0..7] | ForEach-Object { $_.ToString('x2') })
+ return Join-Path $env:LOCALAPPDATA "SIL\DesktopAnalytics\spool\$hex"
+}
+
+function Invoke-SampleApp {
+ param(
+ [string]$ExePath,
+ [string]$Secret,
+ [string]$ClientType,
+ [string]$StdOutPath
+ )
+
+ # c=false: skip SampleApp's built-in AllowTracking on/off demo. Toggling AllowTracking calls
+ # PurgeQueuedEvents, which empties the ENTIRE on-disk spool immediately -- including any events
+ # left over from a prior (blocked) run -- so with the toggle left on, this two-phase test could
+ # never actually observe a drain: the leftover events get silently purged before the next
+ # scheduled flush tick, not delivered. See SampleApp/Program.cs's "c[onsentToggle]" flag.
+ $process = Start-Process -FilePath $ExePath `
+ -ArgumentList @($Secret, $ClientType, 'c=false') `
+ -NoNewWindow -Wait -PassThru `
+ -RedirectStandardOutput $StdOutPath
+
+ if ($process.ExitCode -ne 0) {
+ throw "SampleApp exited with code $($process.ExitCode); see $StdOutPath"
+ }
+
+ $stdout = Get-Content -Path $StdOutPath -Raw
+ $match = [regex]::Match($stdout, 'Succeeded:\s*(\d+);\s*Submitted:\s*(\d+);\s*Failed:\s*(\d+)')
+ if (-not $match.Success) {
+ Write-Output $stdout
+ throw "Could not find a 'Succeeded: N; Submitted: N; Failed: N' line in SampleApp output (see above)."
+ }
+
+ return [PSCustomObject]@{
+ Succeeded = [int]$match.Groups[1].Value
+ Submitted = [int]$match.Groups[2].Value
+ Failed = [int]$match.Groups[3].Value
+ StdOut = $stdout
+ }
+}
+
+if (-not (Test-IsElevated)) {
+ throw "This script must run elevated (New-NetFirewallRule requires admin). Re-run from an elevated PowerShell session."
+}
+
+if ([string]::IsNullOrWhiteSpace($DesktopAnalyticsPath)) {
+ throw "DesktopAnalyticsPath was not supplied and DESKTOPANALYTICS_PATH is not set."
+}
+if (-not (Test-Path $DesktopAnalyticsPath)) {
+ throw "DesktopAnalyticsPath does not exist: $DesktopAnalyticsPath"
+}
+$sampleAppProj = Join-Path $DesktopAnalyticsPath 'src\SampleApp\SampleApp.csproj'
+if (-not (Test-Path $sampleAppProj)) {
+ throw "SampleApp project not found at $sampleAppProj"
+}
+
+if ([string]::IsNullOrWhiteSpace($ApiSecret)) {
+ $fieldWorksRepoRoot = Split-Path -Parent $PSScriptRoot
+ $ApiSecret = Get-FieldWorksDebugAnalyticsKey -RepoRoot $fieldWorksRepoRoot
+ if ([string]::IsNullOrWhiteSpace($ApiSecret)) {
+ throw "ApiSecret was not supplied, DESKTOPANALYTICS_TEST_API_SECRET is not set, and the DEBUG analytics key could not be read from Src\Common\FieldWorks\FieldWorks.cs under $fieldWorksRepoRoot. Pass -ApiSecret explicitly."
+ }
+ Write-Output "[INFO] No -ApiSecret supplied; using FieldWorks' own DEBUG-build Mixpanel key read from FieldWorks.cs (separate dev/test project from RELEASE)."
+}
+
+Write-Output "[INFO] Building SampleApp ($Configuration)..."
+dotnet build $sampleAppProj -c $Configuration | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet build failed with exit code $LASTEXITCODE"
+}
+
+$exePath = Join-Path $DesktopAnalyticsPath "src\SampleApp\bin\$Configuration\net462\SampleApp.exe"
+if (-not (Test-Path $exePath)) {
+ throw "Built SampleApp.exe not found at $exePath"
+}
+Write-Output "[OK] Built $exePath"
+
+$spoolPath = Get-SpoolPath -Secret $ApiSecret
+Write-Output "[INFO] Spool directory for this API secret: $spoolPath"
+
+if (-not $SkipClean -and (Test-Path $spoolPath)) {
+ Write-Output "[INFO] Clearing pre-existing spool for a deterministic run..."
+ Remove-Item -Path $spoolPath -Recurse -Force -Confirm:$false
+}
+
+$ruleName = 'FieldWorks-AnalyticsOfflineDrain-SmokeTest'
+$tempDir = Join-Path ([IO.Path]::GetTempPath()) "AnalyticsOfflineDrain-$([Guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
+
+$overallPass = $true
+
+try {
+ # Remove any stale rule from a previous interrupted run before adding a fresh one.
+ Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue |
+ Remove-NetFirewallRule -Confirm:$false -ErrorAction SilentlyContinue
+
+ Write-Output "[INFO] Blocking outbound traffic for SampleApp.exe only (per-process, not host-wide)..."
+ New-NetFirewallRule -DisplayName $ruleName -Direction Outbound -Program $exePath `
+ -Action Block -Profile Any | Out-Null
+
+ Write-Output "[INFO] Phase 1: running SampleApp while blocked (~45s)..."
+ $blockedStdOut = Join-Path $tempDir 'phase1-blocked.log'
+ $phase1 = Invoke-SampleApp -ExePath $exePath -Secret $ApiSecret -ClientType $ClientType -StdOutPath $blockedStdOut
+ Write-Output "[INFO] Phase 1 result: Submitted=$($phase1.Submitted) Succeeded=$($phase1.Succeeded) Failed=$($phase1.Failed)"
+
+ $phase1Ok = ($phase1.Submitted -gt 0) -and ($phase1.Succeeded -eq 0) -and ($phase1.Failed -eq 0)
+ if ($phase1Ok) {
+ Write-Output "[OK] Phase 1: events were submitted but not delivered while blocked -- accumulated in the spool as expected."
+ }
+ else {
+ Write-Output "[FAIL] Phase 1: expected Submitted>0, Succeeded=0, Failed=0 while blocked. Got Submitted=$($phase1.Submitted) Succeeded=$($phase1.Succeeded) Failed=$($phase1.Failed)."
+ $overallPass = $false
+ }
+
+ Write-Output "[INFO] Removing the per-process block..."
+ Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue |
+ Remove-NetFirewallRule -Confirm:$false
+
+ Write-Output "[INFO] Phase 2: running SampleApp again, now unblocked (~45s)..."
+ $unblockedStdOut = Join-Path $tempDir 'phase2-unblocked.log'
+ $phase2 = Invoke-SampleApp -ExePath $exePath -Secret $ApiSecret -ClientType $ClientType -StdOutPath $unblockedStdOut
+ Write-Output "[INFO] Phase 2 result: Submitted=$($phase2.Submitted) Succeeded=$($phase2.Succeeded) Failed=$($phase2.Failed)"
+
+ $expectedLeftover = $phase1.Submitted - $phase1.Succeeded - $phase1.Failed
+ $phase2Ok = ($phase2.Succeeded -ge ($phase2.Submitted + $expectedLeftover)) -and ($phase2.Failed -eq 0)
+ if ($phase2Ok) {
+ Write-Output "[OK] Phase 2: delivered $($phase2.Succeeded) events against only $($phase2.Submitted) submitted this run -- the $expectedLeftover event(s) spooled during Phase 1 were drained."
+ }
+ else {
+ Write-Output "[FAIL] Phase 2: expected Succeeded >= Submitted + $expectedLeftover leftover event(s), Failed=0. Got Submitted=$($phase2.Submitted) Succeeded=$($phase2.Succeeded) Failed=$($phase2.Failed)."
+ $overallPass = $false
+ }
+
+ if (Test-Path $spoolPath) {
+ $remaining = Get-ChildItem -Path $spoolPath -File -ErrorAction SilentlyContinue
+ Write-Output "[INFO] Spool directory now contains $($remaining.Count) file(s)."
+ }
+}
+finally {
+ Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue |
+ Remove-NetFirewallRule -Confirm:$false -ErrorAction SilentlyContinue
+ Write-Output "[INFO] Firewall rule '$ruleName' removed (or was already absent)."
+ Write-Output "[INFO] Logs kept at: $tempDir"
+}
+
+if ($overallPass) {
+ Write-Output "[OK] PASS: accumulate-while-blocked / drain-when-unblocked confirmed via a per-process firewall rule."
+ exit 0
+}
+else {
+ Write-Output "[FAIL] One or more assertions failed. See details above and logs in $tempDir."
+ exit 1
+}
diff --git a/scripts/Test-AnalyticsOfflineSandbox.ps1 b/scripts/Test-AnalyticsOfflineSandbox.ps1
new file mode 100644
index 0000000000..6493b94eb9
--- /dev/null
+++ b/scripts/Test-AnalyticsOfflineSandbox.ps1
@@ -0,0 +1,274 @@
+<#
+.SYNOPSIS
+ Runs DesktopAnalytics.net's SampleApp inside a Windows Sandbox with
+ networking fully disabled, to confirm the durable Mixpanel spool never
+ falsely reports delivery when there is genuinely no network route.
+
+.DESCRIPTION
+ Complementary to Test-AnalyticsOfflineDrain.ps1, which blocks traffic at
+ the firewall (packets dropped, adapter stays "up"). This script instead
+ boots a disposable Windows Sandbox guest with no virtual NIC at all
+ (Disable), so the guest has no route to
+ anywhere -- while the host machine's own network is completely
+ untouched. It exists to guard against a regression where delivery
+ success is inferred from NetworkInterface.GetIsNetworkAvailable() rather
+ than a real per-event send outcome.
+
+ Windows Sandbox is ephemeral and has no live network toggle, so this is
+ a "born offline, stays offline" check, not an accumulate/drain
+ transition test -- use Test-AnalyticsOfflineDrain.ps1 for the
+ transition. Expected outcome here: every event is submitted, none are
+ delivered, none are poison-dropped.
+
+ A small run.cmd is generated into a temp, read-only-mapped folder
+ alongside the built SampleApp.exe; it launches SampleApp with the
+ supplied API secret, greps its "Succeeded: N; Submitted: N; Failed: N"
+ line, writes a result.txt into a separate writable-mapped folder, then
+ shuts the sandbox down so this script can detect completion. The temp
+ directory (which briefly contains the API secret in run.cmd) is deleted
+ afterward.
+
+.PARAMETER DesktopAnalyticsPath
+ Path to a local DesktopAnalytics.net checkout. Defaults to the
+ DESKTOPANALYTICS_PATH env var.
+
+.PARAMETER ApiSecret
+ Mixpanel project token to use for this test run. If not supplied (and
+ DESKTOPANALYTICS_TEST_API_SECRET is not set), this script reads
+ FieldWorks' own DEBUG-build analytics key directly out of
+ Src/Common/FieldWorks/FieldWorks.cs at run time (the "analyticsKey"
+ constant under #if DEBUG) -- the same dev/test Mixpanel project every
+ Debug FieldWorks build already reports to. Doesn't matter much here
+ either way: with no guest NIC at all, no connection is ever attempted
+ regardless of token validity.
+
+.PARAMETER ClientType
+ DesktopAnalytics client type to exercise. Defaults to Mixpanel.
+
+.PARAMETER Configuration
+ Build configuration for SampleApp. Defaults to Debug.
+
+.PARAMETER TimeoutSeconds
+ How long to wait for the sandbox to finish and write result.txt before
+ giving up. Defaults to 120 (SampleApp's own run is ~45s plus sandbox
+ boot time).
+
+.EXAMPLE
+ .\scripts\Test-AnalyticsOfflineSandbox.ps1 -DesktopAnalyticsPath C:\Repos\DesktopAnalytics.net
+#>
+[CmdletBinding()]
+param(
+ [string]$DesktopAnalyticsPath = $env:DESKTOPANALYTICS_PATH,
+
+ [string]$ApiSecret = $env:DESKTOPANALYTICS_TEST_API_SECRET,
+
+ [ValidateSet('Mixpanel', 'Segment')]
+ [string]$ClientType = 'Mixpanel',
+
+ [string]$Configuration = 'Debug',
+
+ [int]$TimeoutSeconds = 120
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Get-FieldWorksDebugAnalyticsKey {
+ # Reads the DEBUG-build Mixpanel key directly out of FieldWorks.cs (single source of
+ # truth) rather than duplicating it as a literal anywhere in this script.
+ param([string]$RepoRoot)
+
+ $fieldWorksCs = Join-Path $RepoRoot 'Src\Common\FieldWorks\FieldWorks.cs'
+ if (-not (Test-Path $fieldWorksCs)) {
+ return $null
+ }
+
+ $content = Get-Content -Path $fieldWorksCs -Raw
+ $match = [regex]::Match($content, '#if DEBUG\s+const string analyticsKey = "([0-9a-fA-F]+)"')
+ if (-not $match.Success) {
+ return $null
+ }
+ return $match.Groups[1].Value
+}
+
+if ([string]::IsNullOrWhiteSpace($DesktopAnalyticsPath)) {
+ throw "DesktopAnalyticsPath was not supplied and DESKTOPANALYTICS_PATH is not set."
+}
+if (-not (Test-Path $DesktopAnalyticsPath)) {
+ throw "DesktopAnalyticsPath does not exist: $DesktopAnalyticsPath"
+}
+
+if ([string]::IsNullOrWhiteSpace($ApiSecret)) {
+ $fieldWorksRepoRoot = Split-Path -Parent $PSScriptRoot
+ $ApiSecret = Get-FieldWorksDebugAnalyticsKey -RepoRoot $fieldWorksRepoRoot
+ if ([string]::IsNullOrWhiteSpace($ApiSecret)) {
+ throw "ApiSecret was not supplied, DESKTOPANALYTICS_TEST_API_SECRET is not set, and the DEBUG analytics key could not be read from Src\Common\FieldWorks\FieldWorks.cs under $fieldWorksRepoRoot. Pass -ApiSecret explicitly."
+ }
+ Write-Output "[INFO] No -ApiSecret supplied; using FieldWorks' own DEBUG-build Mixpanel key read from FieldWorks.cs (separate dev/test project from RELEASE)."
+}
+
+$feature = Get-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM -ErrorAction SilentlyContinue
+if (-not $feature -or $feature.State -ne 'Enabled') {
+ throw "Windows Sandbox is not enabled on this machine. Enable it via: Enable-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM -All (requires a restart), or Settings > Apps > Optional features > Windows Sandbox."
+}
+
+$wsbExe = Get-Command WindowsSandbox.exe -ErrorAction SilentlyContinue
+if (-not $wsbExe) {
+ throw "WindowsSandbox.exe was not found on PATH even though the feature reports Enabled. A restart may be required after enabling it."
+}
+
+$sampleAppProj = Join-Path $DesktopAnalyticsPath 'src\SampleApp\SampleApp.csproj'
+if (-not (Test-Path $sampleAppProj)) {
+ throw "SampleApp project not found at $sampleAppProj"
+}
+
+Write-Output "[INFO] Building SampleApp ($Configuration)..."
+dotnet build $sampleAppProj -c $Configuration | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet build failed with exit code $LASTEXITCODE"
+}
+
+$exeSourceDir = Join-Path $DesktopAnalyticsPath "src\SampleApp\bin\$Configuration\net462"
+$exePath = Join-Path $exeSourceDir 'SampleApp.exe'
+if (-not (Test-Path $exePath)) {
+ throw "Built SampleApp.exe not found at $exePath"
+}
+Write-Output "[OK] Built $exePath"
+
+$tempDir = Join-Path ([IO.Path]::GetTempPath()) "AnalyticsOfflineSandbox-$([Guid]::NewGuid().ToString('N'))"
+$binDir = Join-Path $tempDir 'bin'
+$outDir = Join-Path $tempDir 'out'
+New-Item -ItemType Directory -Path $binDir -Force | Out-Null
+New-Item -ItemType Directory -Path $outDir -Force | Out-Null
+
+$guestBin = 'C:\AnalyticsTest\bin'
+$guestOut = 'C:\AnalyticsTest\out'
+
+try {
+ Write-Output "[INFO] Staging SampleApp build output and a launcher into a temp folder (contains the test API secret; deleted at the end)..."
+ Copy-Item -Path (Join-Path $exeSourceDir '*') -Destination $binDir -Recurse -Force
+
+ # check-result.ps1: a real script FILE, not a fragile inline `powershell -Command`
+ # one-liner with several layers of cmd.exe/PowerShell quote-escaping (the previous
+ # design). That inline form failed silently -- run.cmd still reached `shutdown`
+ # (proving the guest ran to completion), but result.txt never appeared, which a
+ # batch file's lack of any error propagation between lines would hide either way.
+ # A plain script file removes the escaping risk entirely and wraps its body in
+ # try/catch so ANY failure still writes a diagnosable message to $ResultPath
+ # instead of silently leaving nothing behind.
+ $checkResultPath = Join-Path $binDir 'check-result.ps1'
+ $checkResultContent = @'
+param(
+ [string]$LogPath,
+ [string]$ResultPath
+)
+try {
+ $content = Get-Content -Path $LogPath -Raw -ErrorAction Stop
+ $m = [regex]::Match($content, 'Succeeded:\s*(\d+);\s*Submitted:\s*(\d+);\s*Failed:\s*(\d+)')
+ if (-not $m.Success) {
+ "FAIL: no stats line found in $LogPath. Content: $content" | Out-File -FilePath $ResultPath -Encoding utf8
+ return
+ }
+ $succeeded = [int]$m.Groups[1].Value
+ $submitted = [int]$m.Groups[2].Value
+ $failed = [int]$m.Groups[3].Value
+ $pass = ($submitted -gt 0) -and ($succeeded -eq 0) -and ($failed -eq 0)
+ $status = if ($pass) { 'PASS' } else { 'FAIL' }
+ "${status}: Submitted=$submitted Succeeded=$succeeded Failed=$failed" | Out-File -FilePath $ResultPath -Encoding utf8
+}
+catch {
+ "FAIL: check-result.ps1 threw: $_" | Out-File -FilePath $ResultPath -Encoding utf8
+}
+'@
+ Set-Content -Path $checkResultPath -Value $checkResultContent -Encoding UTF8
+
+ # run.cmd: launch SampleApp with the secret, run check-result.ps1 to write a
+ # PASS/FAIL result.txt to the writable mapped folder, then shut the guest down so
+ # the host-side wait loop below can detect completion without manual interaction.
+ # marker.txt records SampleApp's own exit code as an extra diagnostic in case
+ # result.txt is still missing (e.g. SampleApp itself crashed before ever logging a
+ # stats line). The `ping` line between the write and `shutdown` is a deliberate
+ # delay (the traditional cmd.exe sleep -- `timeout` refuses to run with
+ # redirected/non-interactive input, which is exactly this context): Windows
+ # Sandbox's mapped folders are a shared-folder sync back to the host, not a local
+ # write, and `shutdown /s /t 0` tears the guest down immediately, so without a
+ # buffer here the guest could in principle disconnect before a write has actually
+ # propagated to the host.
+ $runCmdPath = Join-Path $binDir 'run.cmd'
+ $runCmdLines = @(
+ '@echo off'
+ "`"$guestBin\SampleApp.exe`" `"$ApiSecret`" $ClientType > `"$guestOut\sampleapp.log`" 2>&1"
+ "echo SAMPLEAPP_EXITCODE=%ERRORLEVEL% > `"$guestOut\marker.txt`""
+ "powershell -NoProfile -ExecutionPolicy Bypass -File `"$guestBin\check-result.ps1`" -LogPath `"$guestOut\sampleapp.log`" -ResultPath `"$guestOut\result.txt`""
+ 'ping -n 6 127.0.0.1 >nul'
+ 'shutdown /s /t 0'
+ )
+ Set-Content -Path $runCmdPath -Value $runCmdLines -Encoding ASCII
+
+ $wsbPath = Join-Path $tempDir 'offline-check.wsb'
+ $wsbContent = @"
+
+ Disable
+
+
+ $binDir
+ $guestBin
+ true
+
+
+ $outDir
+ $guestOut
+ false
+
+
+
+ cmd /c $guestBin\run.cmd
+
+
+"@
+ Set-Content -Path $wsbPath -Value $wsbContent -Encoding UTF8
+
+ Write-Output "[INFO] Launching Windows Sandbox (networking disabled; host network is untouched)..."
+ Start-Process -FilePath $wsbPath | Out-Null
+
+ $resultPath = Join-Path $outDir 'result.txt'
+ Write-Output "[INFO] Waiting up to $TimeoutSeconds s for the sandbox to finish and shut down..."
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while (-not (Test-Path $resultPath) -and (Get-Date) -lt $deadline) {
+ Start-Sleep -Seconds 2
+ }
+
+ if (-not (Test-Path $resultPath)) {
+ throw "Timed out after $TimeoutSeconds s waiting for $resultPath. The sandbox may still be running -- check for a WindowsSandboxRemoteSession window."
+ }
+
+ $result = Get-Content -Path $resultPath -Raw
+ Write-Output "[INFO] Sandbox result: $result"
+
+ if ($result -match '^PASS') {
+ Write-Output "[OK] PASS: with no guest NIC at all, events were submitted but never reported delivered."
+ exit 0
+ }
+ else {
+ Write-Output "[FAIL] Sandbox reported: $result"
+ exit 1
+ }
+}
+finally {
+ Start-Sleep -Seconds 3 # let the sandbox fully release its mapped-folder handles after shutdown
+
+ # The bin folder holds run.cmd with the API secret embedded in cleartext -- always
+ # remove that promptly regardless of outcome.
+ Remove-Item -Path $binDir -Recurse -Force -ErrorAction SilentlyContinue
+
+ if (Test-Path (Join-Path $outDir 'result.txt')) {
+ Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Output "[INFO] Cleaned up temp folder (including the API secret it briefly held)."
+ }
+ else {
+ # No result.txt: keep the (secret-free) out folder around so marker.txt/sampleapp.log
+ # can be inspected instead of losing all diagnostics to an unconditional delete, as
+ # happened on earlier timed-out runs.
+ Write-Output "[INFO] No result.txt was found; kept $outDir for diagnostics (marker.txt has SampleApp's exit code, sampleapp.log has its output). The API-secret-bearing bin folder was still removed."
+ }
+}