Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-loss acceptance — DECIDED (2026-07-02)**: build a durable local retry/queue layer for `Analytics.Track`/`ReportException` rather than accept the current lossy fire-and-forget behavior. No existing seam for this — it is new infrastructure (a local persisted outbox that survives process restarts and retries when a network is available) sitting in front of the `Analytics` calls, since the underlying `DesktopAnalytics` package itself does not support this.
- **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.
3 changes: 1 addition & 2 deletions Src/Common/Controls/FwControls/ObtainProjectMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using System.Text;
using System.Windows.Forms;
using System.Xml;
using DesktopAnalytics;
using SIL.LCModel.Core.WritingSystems;
using SIL.FieldWorks.Common.FwUtils;
using SIL.LCModel;
Expand Down Expand Up @@ -53,7 +52,7 @@ public static string ObtainProjectFromAnySource(Form parent, IHelpTopicProvider
obtainedProjectType = ObtainedProjectType.Lift;
}

Analytics.Track("CreateFromSRRepo", new Dictionary<string, string>
AnalyticsOutbox.Track("CreateFromSRRepo", new Dictionary<string, string>
{
{ "type", obtainedProjectType.ToString() },
{ "modelVersion", LcmCache.ModelVersion.ToString() },
Expand Down
68 changes: 65 additions & 3 deletions Src/Common/FieldWorks/FieldWorks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,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

Expand Down Expand Up @@ -232,6 +240,18 @@ 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;
AnalyticsOutbox.Track("SessionStart", new Dictionary<string, string> { { "SessionId", s_sessionId } });
AnalyticsOutbox.FlushInBackground();

Logger.WriteEvent("Starting app");
SetGlobalExceptionHandler();
Expand Down Expand Up @@ -385,16 +405,28 @@ 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");
// Best-effort, time-bounded so it never materially delays shutdown; anything not
// reached is left for the next launch's startup sweep (design.md D9).
AnalyticsOutbox.FlushSync(TimeSpan.FromSeconds(2));
StaticDispose();
if (Xpcom.IsInitialized)
{
Expand Down Expand Up @@ -1068,12 +1100,12 @@ private static void HandleUnhandledException(object sender, UnhandledExceptionEv
{
if (e.ExceptionObject is Exception exception)
{
Analytics.ReportException(exception, GetInnerExceptionInfo(exception));
AnalyticsOutbox.ReportException(exception, GetInnerExceptionInfo(exception));
DisplayError(exception, e.IsTerminating);
}
else
{
Analytics.ReportException(new Exception("Unknown Exception"),
AnalyticsOutbox.ReportException(new Exception("Unknown Exception"),
new Dictionary<string, string>
{
{
Expand All @@ -1086,6 +1118,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");
}

/// <summary>
/// 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 D10.
/// </summary>
/// <param name="outcome">"clean" or "crashed"</param>
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;
AnalyticsOutbox.Track("SessionEnd", new Dictionary<string, string>
{
{ "SessionId", s_sessionId },
{ "outcome", outcome },
{ "duration", durationSeconds.ToString("F1", CultureInfo.InvariantCulture) }
});
}

/// <summary>
Expand Down Expand Up @@ -1129,7 +1186,7 @@ private static Dictionary<string, string> GetInnerExceptionInfo(Exception except
/// ------------------------------------------------------------------------------------
private static void HandleTopLevelError(object sender, ThreadExceptionEventArgs eventArgs)
{
Analytics.ReportException(eventArgs.Exception, GetInnerExceptionInfo(eventArgs.Exception));
AnalyticsOutbox.ReportException(eventArgs.Exception, GetInnerExceptionInfo(eventArgs.Exception));
if (FwUtils.IsUnsupportedCultureException(eventArgs.Exception)) // LT-8248
{
Logger.WriteEvent("Unsupported culture: " + eventArgs.Exception.Message);
Expand All @@ -1141,6 +1198,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
Expand Down
Loading
Loading