Skip to content
Draft
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
31 changes: 26 additions & 5 deletions Build/Manage-LocalLibraries.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
1 change: 1 addition & 0 deletions Build/SilVersions.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<SilChorusVersion>6.0.0-beta0065</SilChorusVersion>
<SilChorusL10nsVersion>3.0.1</SilChorusL10nsVersion>
<SilMachineVersion>3.8.2</SilMachineVersion>
<SilDesktopAnalyticsVersion>4.0.0</SilDesktopAnalyticsVersion>
<SilIPCFrameworkVersion>1.1.1-beta0001</SilIPCFrameworkVersion>
<L10NSharpVersion>10.0.0-beta0004</L10NSharpVersion>
<EncodingConvertersCoreVersion>0.9.8</EncodingConvertersCoreVersion>
Expand Down
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 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.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
<PackageVersion Include="ILRepack" Version="2.0.18" />
<PackageVersion Include="SIL.Chorus.App" Version="$(SilChorusVersion)" />
<PackageVersion Include="SIL.Chorus.LibChorus" Version="$(SilChorusVersion)" />
<PackageVersion Include="SIL.DesktopAnalytics" Version="4.0.0" />
<PackageVersion Include="SIL.DesktopAnalytics" Version="$(SilDesktopAnalyticsVersion)" />
<PackageVersion Include="SIL.ExCSS" Version="2.0.2" />
<PackageVersion Include="SIL.FLExBridge.IPCFramework" Version="$(SilIPCFrameworkVersion)" />
<PackageVersion Include="SIL.IdlImporter" Version="4.0.0-beta0052" />
Expand Down
39 changes: 34 additions & 5 deletions Docs/architecture/local-library-debugging.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 `<Networking>Disable</Networking>`
(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

Expand Down
58 changes: 58 additions & 0 deletions Src/Common/FieldWorks/FieldWorks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<string, string> { { "SessionId", s_sessionId } });

Logger.WriteEvent("Starting app");
SetGlobalExceptionHandler();
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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");
}

/// <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 D2.
/// </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;
Analytics.Track("SessionEnd", new Dictionary<string, string>
{
{ "SessionId", s_sessionId },
{ "outcome", outcome },
{ "duration", durationSeconds.ToString("F1", CultureInfo.InvariantCulture) }
});
}

/// <summary>
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading