Skip to content

Telemetry baseline: durable analytics outbox, session tracking, UI-framework marker#987

Open
johnml1135 wants to merge 4 commits into
mainfrom
telemetry-migration-baseline
Open

Telemetry baseline: durable analytics outbox, session tracking, UI-framework marker#987
johnml1135 wants to merge 4 commits into
mainfrom
telemetry-migration-baseline

Conversation

@johnml1135

@johnml1135 johnml1135 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Establishes a FieldWorks-only telemetry baseline (openspec change: telemetry-migration-baseline) ahead of the planned Avalonia UI migration, so there's a "legacy WinForms" usage/crash/session baseline to compare against later.

  • AnalyticsOutbox (Src/Common/FwUtils/AnalyticsOutbox.cs): a durable local queue in front of DesktopAnalytics.Analytics.Track/ReportException. Today, an event generated while offline is silently dropped (DesktopAnalytics is fire-and-forget with no retry). The outbox persists each event as its own small JSON file, flushes on enqueue/startup/shutdown, and re-checks consent at flush time (not just enqueue time) so revoking consent stops even already-queued data from going out.
  • All 6 existing Analytics.Track/ReportException call sites are migrated to the new facade (FieldWorks.cs, AreaListener.cs, TrackingHelper.cs, ConcordanceContainer.cs, ConfigureInterlinDialog.cs, ObtainProjectMethod.cs).
  • Session baseline: session-start/session-end events with clean-vs-crashed classification, giving session duration and crash-free-session rate.
  • Usage enrichment: Analytics.SetApplicationProperty("UiFramework", "WinForms") set once at startup (forward-compatible — a future Avalonia surface sets a different value on the same property), plus duration (dwell time) added to the existing throttled SwitchToTool event.

Bug found and fixed during implementation

While writing the concurrency test for the outbox's file-claim logic, I found that System.IO.File.Move does not reliably report failure to the "losing" thread when two callers race an identical rename on .NET Framework — both can return without throwing, even though the OS performs exactly one physical rename. Verified with an isolated repro outside this codebase (~99% reproducible across hundreds of trials; confirmed via on-disk state that only one physical rename ever occurs, so the underlying filesystem operation is correct — the wrapper's exception surfacing for the loser is not). This invalidated the original "atomic rename = exclusive claim" design. Fixed by following the rename with a FileShare.None exclusive open as the real, OS-enforced single-owner check (documented as design.md D14). This was caught by AnalyticsOutboxTests.Flush_ConcurrentFlushCalls_DeliverEachEventExactlyOnce, which failed intermittently (11-20 deliveries instead of 10) before the fix, and now passes deterministically.

Test plan

  • .\build.ps1 — full managed build succeeds, no native rebuild triggered.
  • .\test.ps1 on FwUtilsTests — 390/390 pass, including 15 new AnalyticsOutboxTests (consent gating, FIFO delivery, cap eviction by count/age, claim-race exactly-once delivery, orphan recovery with staleness gating, delivery-failure rollback).
  • .\test.ps1 on LexTextDllTests, FieldWorksTests, ITextDllTests — zero regressions from the call-site migration.
  • Gap: no automated test for session-start/end tracking (FieldWorks.cs) or SwitchToTool dwell-time computation (AreaListener.cs) — both are blocked on cross-assembly access to AnalyticsOutbox's test seams (currently InternalsVisibleTo only covers FwUtilsTests). Documented as a follow-up in tasks.md §3.6/§4.6.
  • Gap: no automated way to verify the UiFramework application property actually attaches to outgoing events — that's internal to the DesktopAnalytics/Mixpanel client. tasks.md §4.2.
  • Manual, not yet performed: end-to-end offline queueing (disconnect network, use FieldWorks, confirm files accumulate under %LocalAppData%\SIL\FieldWorks\Analytics\Outbox\, reconnect, relaunch, confirm the directory empties). tasks.md §5.3.
  • Manual, not yet performed: confirm the Tools > Options > Privacy checkbox (OkToPingBasicUsageData) still gates all telemetry, including already-queued-but-unflushed events. tasks.md §5.4.

🤖 Generated with Claude Code


This change is Reviewable

@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

NUnit Tests

    1 files  ± 0      1 suites  ±0   11m 19s ⏱️ +40s
4 315 tests +16  4 241 ✅ +15  73 💤 ±0  1 ❌ +1 
4 324 runs  +16  4 250 ✅ +15  73 💤 ±0  1 ❌ +1 

For more details on these failures, see this check.

Results for commit 3a59a1c. ± Comparison against base commit 4509530.

♻️ This comment has been updated with latest results.

@johnml1135 johnml1135 force-pushed the telemetry-migration-baseline branch from d0c2288 to c2b97ca Compare July 7, 2026 10:57
* durable analytics outbox
* session baseline
* forward-compatible UI-framework marker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@johnml1135 johnml1135 force-pushed the telemetry-migration-baseline branch from c2b97ca to f5b9386 Compare July 7, 2026 13:06
@johnml1135

Copy link
Copy Markdown
Contributor Author

Code review (verified against the code at the PR head)

This is a large, unusually well-documented change (design.md walks through several real bugs found and fixed during implementation — D13/D14 in particular are genuinely subtle Windows File.Move semantics worth keeping as institutional knowledge). One gap survived that documentation process:

HIGH — orphan-recovery path skips the network-availability check, so a stale .inflight file recovered while offline is silently dropped instead of requeued

AnalyticsOutbox.cs, ProcessOutboxFile (the normal, not-yet-claimed path) checks NetworkAvailable before attempting a claim:

if (!NetworkAvailable)
    return; // known gap (design.md D12): this only proves *a* network exists, not that Mixpanel is reachable
if (!TryClaimExclusive(path, InflightSuffix, out var claimedPath, out var claimedStream))
    return;
DeliverClaimedFile(claimedStream, claimedPath, path);

ProcessInflightFile (the orphan-recovery path, reached when a sweep finds an .inflight file older than OrphanClaimStaleness) has no equivalent check — it goes straight from the staleness check to TryClaimExclusive/DeliverClaimedFile:

if (DateTime.UtcNow - claimedAtUtc < OrphanClaimStaleness)
    return;
var originalPath = path.Substring(0, path.Length - InflightSuffix.Length);
if (!TryClaimExclusive(path, RecoveringSuffix, out var claimedPath, out var claimedStream))
    return;
DeliverClaimedFile(claimedStream, claimedPath, originalPath);

Per D12, Analytics.Track's internal call is fire-and-forget and essentially never throws regardless of connectivity — that's exactly why the normal path added the NetworkAvailable pre-check as a (documented, imperfect) guard against claiming-then-deleting a file that was never actually sent. Without that same guard here, a genuinely offline sweep that finds an old orphaned .inflight file (left behind by a crashed prior claimant) will claim it, "deliver" it (a no-op fire-and-forget that succeeds locally with no network), and delete it — permanently losing exactly the kind of event (crash-then-offline) this durability mechanism exists to protect. AnalyticsOutboxTests.Flush_DeliversOrphanedInflightFile_LeftBehindByACrashedPriorClaim only exercises this path with NetworkAvailableOverride = () => true, so the gap isn't caught by the current suite.

Suggest adding the same if (!NetworkAvailable) return; check to ProcessInflightFile before the claim attempt (or better yet, fold the offline check into the top of DeliverClaimedFile/TryClaimExclusive so both callers can't diverge again).

Minor — FlushSync's timeout is only checked between files, not within one

Flush's loop checks the deadline once per file (if (deadlineUtc.HasValue && DateTime.UtcNow >= deadlineUtc.Value) break;) but ProcessOutboxFile itself has no per-call timeout. Given Analytics.Track is fire-and-forget (per D12) this is low-risk in practice, but a single slow file op (e.g. AV-scanner-held file, RobustFile's retry path) could still make the shutdown sweep's "≤2s" guarantee (D9) soft rather than hard. Not blocking — just worth knowing it's a soft bound.

Reviewed with Claude Code; findings verified against the code at the PR head. No code changed.

@johnml1135

Copy link
Copy Markdown
Contributor Author

Fixed the HIGH issue in b98e841: added the same NetworkAvailable check to ProcessInflightFile that ProcessOutboxFile already had, so an orphaned .inflight file recovered while offline is left queued instead of being claimed, "delivered" via a no-op fire-and-forget Track, and deleted as if sent.

While verifying that fix, stress-testing (10 runs) turned up a second, real bug in the same file: Flush_ConcurrentFlushCalls_DeliverEachEventExactlyOnce is genuinely flaky on unmodified code (~2/3 failure rate in my testing, not a fluke — I reproduced it on a stash of the original code before making any changes). Root cause: DeliverClaimedFile released the FileShare.None exclusive lock immediately after reading the file, before delivery and delete. A racer whose RobustFile.Move spuriously "succeeds" against the same file (the .NET File.Move quirk your own design.md D14 documents) can win its own exclusive open in that now-unlocked window and deliver the same file a second time. Fixed by holding the lock open through delivery and releasing it only immediately before the terminal delete/restore. 10/10 green after the fix; full AnalyticsOutboxTests suite (16/16) also green.

Added Flush_LeavesOrphanedInflightFileQueued_WhenNetworkUnavailable to cover the first fix. Didn't add a dedicated regression test for the second (the existing concurrent-flush test already covers it once it's reliably green - a flaky test only proves the bug intermittently, so the real signal is that it now passes consistently rather than ~33% of the time).

Also verified the minor point re: FlushSync's per-file (not per-call) deadline check — leaving that as-is per the original note, since it's a soft bound consistent with the fire-and-forget Track assumption, not a correctness issue.

@github-actions

This comment has been minimized.

@johnml1135

Copy link
Copy Markdown
Contributor Author

(Follow-up: trimmed the two comments from the fix above down to one line each, per repo convention.)

johnml1135 and others added 3 commits July 7, 2026 16:03
ProcessInflightFile (orphan recovery) skipped the NetworkAvailable check
that ProcessOutboxFile relies on, so a stale .inflight file recovered
while offline would be claimed, "delivered" via the fire-and-forget
Analytics.Track (a silent no-op with no network), and deleted as if sent -
permanently losing it. Add the same check before claiming an orphan.

Also found, while verifying the above, that
Flush_ConcurrentFlushCalls_DeliverEachEventExactlyOnce is genuinely flaky
(~2/3 failure rate) on unmodified code: DeliverClaimedFile released the
FileShare.None exclusive lock immediately after reading the file, before
delivery and delete. A second racer whose RobustFile.Move spuriously
"succeeds" against the same file (the .NET File.Move quirk documented in
design.md D14) can win its own exclusive open in that window and deliver
the same file again. Fix: hold the lock through delivery and release it
only immediately before the terminal delete/restore. Stress-tested 10/10
green after the fix (was ~1/3 pass rate before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@johnml1135 johnml1135 force-pushed the telemetry-migration-baseline branch from 9eb16d3 to 3a59a1c Compare July 7, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant