From 1a2c0cf6c2c18ec021d3bb9ad171eaa5e8d97b81 Mon Sep 17 00:00:00 2001 From: tombogle Date: Fri, 27 Feb 2026 08:51:35 -0500 Subject: [PATCH 1/6] +semver:minor Made it possible to disable and enable tracking on the fly --- CHANGELOG.md | 16 ++++ src/DesktopAnalytics/Analytics.cs | 151 ++++++++++++++---------------- src/SampleApp/Program.cs | 8 +- 3 files changed, 94 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6757dce..a1b2ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] + +## [6.1.0] - 2026-02-27 + +### Added +- [DesktopAnalytics] Made it possible to disable and enable tracking while running the application + +## [6.0.3] - 2026-01-09 + +### Changed +- [DesktopAnalytics] Added DeviceUILanguage application property + +## [6.0.2] - 2025-05-09 + +### Fixed +- [DesktopAnalytics] Prevented crash in constructor if config file cannot be upgraded or saved + ## [6.0.0] - 2024-04-22 ### Changed diff --git a/src/DesktopAnalytics/Analytics.cs b/src/DesktopAnalytics/Analytics.cs index 2c5e5c4..1ad59d2 100644 --- a/src/DesktopAnalytics/Analytics.cs +++ b/src/DesktopAnalytics/Analytics.cs @@ -16,6 +16,9 @@ using JetBrains.Annotations; using Newtonsoft.Json.Linq; using Segment.Serialization; +using static System.Attribute; +using static System.Environment; +using static System.Environment.SpecialFolder; using static System.String; namespace DesktopAnalytics @@ -37,6 +40,7 @@ namespace DesktopAnalytics public class Analytics : IDisposable { private const string kUserConfigFileName = "user.config"; + private const int kMaxExceptionReportsPerRun = 10; /// /// Collection of "location"-specific traits about the user. This information is @@ -51,9 +55,7 @@ public class Analytics : IDisposable private readonly Dictionary _propertiesThatGoWithEveryEvent; private static int s_exceptionCount = 0; - const int MAX_EXCEPTION_REPORTS_PER_RUN = 10; - - private readonly IClient Client; + private readonly IClient _client; /// /// Initialized a singleton; after calling this, use Analytics.Track() for each event. @@ -100,7 +102,7 @@ private void UpdateServerInformationOnThisUser() if (!AllowTracking) return; - Client.Identify(AnalyticsSettings.Default.IdForAnalytics, s_traits, s_locationInfo); + _client.Identify(AnalyticsSettings.Default.IdForAnalytics, s_traits, s_locationInfo); } /// @@ -143,12 +145,12 @@ public Analytics(string apiSecret, UserInfo userInfo, Dictionary> GetLocationPropertiesOfThisMachine() - { - using (var client = new WebClient()) - { - var json = client.DownloadString("http://freegeoip.net/json"); - JObject results = JObject.Parse(json); - yield return new KeyValuePair("Country", (string)results["country_name"]); - yield return new KeyValuePair("City", (string)results["city"]); - } - } + //private IEnumerable> GetLocationPropertiesOfThisMachine() + //{ + // using (var client = new WebClient()) + // { + // var json = client.DownloadString("http://freegeoip.net/json"); + // JObject results = JObject.Parse(json); + // yield return new KeyValuePair("Country", (string)results["country_name"]); + // yield return new KeyValuePair("City", (string)results["city"]); + // } + //} /// /// Records an event @@ -610,12 +614,12 @@ public static void ReportException(Exception e, Dictionary moreP // we had an incident where some problem caused a user to emit hundreds of thousands of exceptions, // in the background, blowing through our Analytics service limits and getting us kicked off. - if (s_exceptionCount > MAX_EXCEPTION_REPORTS_PER_RUN) + if (s_exceptionCount > kMaxExceptionReportsPerRun) { return; } - var props = new JsonObject() + var props = new JsonObject { { "Message", e.Message }, { "Stack Trace", e.StackTrace } @@ -623,9 +627,7 @@ public static void ReportException(Exception e, Dictionary moreP if (moreProperties != null) { foreach (var key in moreProperties.Keys) - { props.Add(key, moreProperties[key]); - } } TrackWithApplicationProperties("Exception", props); } @@ -634,31 +636,24 @@ private static JsonObject MakeSegmentIOProperties(Dictionary pro { var prop = new JsonObject(); foreach (var key in properties.Keys) - { prop.Add(key, properties[key]); - } return prop; } - private static void Client_Succeeded(string action) - { - Debug.WriteLine($"Analytics action succeeded: {action}"); - } - private static void Client_Failed(Exception e) { - Debug.WriteLine($"**** Analytics action Failed: {Environment.NewLine}{e.StackTrace}"); + Debug.WriteLine($"**** Analytics action Failed: {NewLine}{e.StackTrace}"); } public void Dispose() { - Client?.ShutDown(); + _client?.ShutDown(); } /// /// Indicates whether we are tracking or not /// - public static bool AllowTracking { get; private set; } + public static bool AllowTracking { get; set; } #region OSVersion class Version @@ -685,7 +680,7 @@ public bool Match(OperatingSystem os) private static string GetOperatingSystemLabel() { - if (Environment.OSVersion.Platform == PlatformID.Unix) + if (OSVersion.Platform == PlatformID.Unix) { return UnixName == "Linux" ? $"{LinuxVersion} / {LinuxDesktop}" : UnixName; } @@ -705,23 +700,23 @@ private static string GetOperatingSystemLabel() foreach (var version in list) { - if (version.Match(Environment.OSVersion)) - return version.Label + " " + Environment.OSVersion.ServicePack; + if (version.Match(OSVersion)) + return version.Label + " " + OSVersion.ServicePack; } // Handle any as yet unrecognized (possibly unmanifested) versions, or anything that reported its self as Windows 8. - if (Environment.OSVersion.Platform == PlatformID.Win32NT) + if (OSVersion.Platform == PlatformID.Win32NT) { - return GetWindowsVersionInfoFromNetworkAPI() + " " + Environment.OSVersion.ServicePack; + return GetWindowsVersionInfoFromNetworkAPI() + " " + OSVersion.ServicePack; } - return Environment.OSVersion.VersionString; + return OSVersion.VersionString; } private string GetOperatingSystemVersionLabel() { - return Environment.OSVersion.Version.ToString(); + return OSVersion.Version.ToString(); } #region Windows8PlusVersionReportingSupport @@ -786,7 +781,7 @@ private static string UnixName { get { - if (Environment.OSVersion.Platform != PlatformID.Unix) + if (OSVersion.Platform != PlatformID.Unix) return Empty; if (s_unixName == null) { @@ -812,16 +807,16 @@ private static string UnixName } } - private static string _linuxVersion; + private static string s_linuxVersion; private static string LinuxVersion { get { - if (Environment.OSVersion.Platform != PlatformID.Unix) + if (OSVersion.Platform != PlatformID.Unix) return Empty; - if (_linuxVersion == null) + if (s_linuxVersion == null) { - _linuxVersion = Empty; + s_linuxVersion = Empty; if (File.Exists("/etc/wasta-release")) { var versionData = File.ReadAllText("/etc/wasta-release"); @@ -830,7 +825,7 @@ private static string LinuxVersion { if (line.StartsWith("DESCRIPTION=\"")) { - _linuxVersion = line.Substring(13).Trim('"'); + s_linuxVersion = line.Substring(13).Trim('"'); break; } } @@ -843,7 +838,7 @@ private static string LinuxVersion { if (t.StartsWith("DISTRIB_DESCRIPTION=\"")) { - _linuxVersion = t.Substring(21).Trim('"'); + s_linuxVersion = t.Substring(21).Trim('"'); break; } } @@ -851,10 +846,10 @@ private static string LinuxVersion else { // If it's linux, it really should have /etc/lsb-release! - _linuxVersion = Environment.OSVersion.VersionString; + s_linuxVersion = OSVersion.VersionString; } } - return _linuxVersion; + return s_linuxVersion; } } @@ -866,15 +861,15 @@ private static string DesktopEnvironment { get { - if (Environment.OSVersion.Platform != PlatformID.Unix) - return Environment.OSVersion.Platform.ToString(); + if (OSVersion.Platform != PlatformID.Unix) + return OSVersion.Platform.ToString(); // see http://unix.stackexchange.com/a/116694 // and http://askubuntu.com/a/227669 - var currentDesktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"); + var currentDesktop = GetEnvironmentVariable("XDG_CURRENT_DESKTOP"); if (IsNullOrEmpty(currentDesktop)) { - var dataDirs = Environment.GetEnvironmentVariable("XDG_DATA_DIRS"); + var dataDirs = GetEnvironmentVariable("XDG_DATA_DIRS"); if (dataDirs != null) { dataDirs = dataDirs.ToLowerInvariant(); @@ -886,7 +881,7 @@ private static string DesktopEnvironment currentDesktop = "Gnome"; } if (IsNullOrEmpty(currentDesktop)) - currentDesktop = Environment.GetEnvironmentVariable("GDMSESSION") ?? Empty; + currentDesktop = GetEnvironmentVariable("GDMSESSION") ?? Empty; } return currentDesktop.ToLowerInvariant(); } @@ -900,25 +895,25 @@ private static string LinuxDesktop { get { - if (Environment.OSVersion.Platform != PlatformID.Unix) + if (OSVersion.Platform != PlatformID.Unix) return Empty; if (s_linuxDesktop == null) { // see http://unix.stackexchange.com/a/116694 // and http://askubuntu.com/a/227669 var currentDesktop = DesktopEnvironment; - var mirSession = Environment.GetEnvironmentVariable("MIR_SERVER_NAME"); + var mirSession = GetEnvironmentVariable("MIR_SERVER_NAME"); var additionalInfo = Empty; if (!IsNullOrEmpty(mirSession)) additionalInfo = " [display server: Mir]"; - var gdmSession = Environment.GetEnvironmentVariable("GDMSESSION") ?? "not set"; + var gdmSession = GetEnvironmentVariable("GDMSESSION") ?? "not set"; s_linuxDesktop = $"{currentDesktop} ({gdmSession}{additionalInfo})"; } return s_linuxDesktop; } } - public static Statistics Statistics => s_singleton.Client.Statistics; + public static Statistics Statistics => s_singleton._client.Statistics; /// /// All calls to Client.Track should run through here so we can provide defaults for every event @@ -933,11 +928,10 @@ private static void TrackWithApplicationProperties(string eventName, JsonObject properties = new JsonObject(); foreach (var p in s_singleton._propertiesThatGoWithEveryEvent) { - if (properties.ContainsKey(p.Key)) - properties.Remove(p.Key); + properties.Remove(p.Key); properties.Add(p.Key, p.Value ?? Empty); } - s_singleton.Client.Track(AnalyticsSettings.Default.IdForAnalytics, eventName, properties); + s_singleton._client.Track(AnalyticsSettings.Default.IdForAnalytics, eventName, properties); } /// @@ -951,10 +945,7 @@ public static void SetApplicationProperty(string key, string value) throw new ArgumentNullException(key); if (value == null) value = Empty; - if (s_singleton._propertiesThatGoWithEveryEvent.ContainsKey(key)) - { - s_singleton._propertiesThatGoWithEveryEvent.Remove(key); - } + s_singleton._propertiesThatGoWithEveryEvent.Remove(key); s_singleton._propertiesThatGoWithEveryEvent.Add(key, value); } @@ -967,7 +958,7 @@ private static string GetUserNameForEvent() public static void FlushClient() { - s_singleton.Client.Flush(); + s_singleton._client.Flush(); } } } diff --git a/src/SampleApp/Program.cs b/src/SampleApp/Program.cs index 33c6e87..2957caa 100644 --- a/src/SampleApp/Program.cs +++ b/src/SampleApp/Program.cs @@ -44,7 +44,13 @@ static int Main(string[] args) Debug.WriteLine("Sleeping for 20 seconds to give it all a chance to send an event in the background..."); Thread.Sleep(20000); - Analytics.SetApplicationProperty("TimeSinceLaunch", "23 seconds"); + Analytics.AllowTracking = false; + Analytics.Track("Should not be tracked"); + Debug.WriteLine("Sleeping for 2 seconds just for fun"); + Thread.Sleep(2000); + + Analytics.AllowTracking = true; + Analytics.SetApplicationProperty("TimeSinceLaunch", "25 seconds"); Analytics.Track("SomeEvent", new Dictionary {{"SomeValue", "42"}}); Console.WriteLine("Sleeping for another 20 seconds to give it all a chance to send an event in the background..."); Thread.Sleep(20000); From 7816fa3c00b253ee090f8d67c7c5a07434a0f4a3 Mon Sep 17 00:00:00 2001 From: tombogle Date: Wed, 18 Mar 2026 09:03:30 -0400 Subject: [PATCH 2/6] Enabled deferred initialization of client when AllowTracking is false --- src/DesktopAnalytics/Analytics.cs | 104 ++++++++++++++++++++++-------- src/SampleApp/Program.cs | 26 ++++++-- 2 files changed, 99 insertions(+), 31 deletions(-) diff --git a/src/DesktopAnalytics/Analytics.cs b/src/DesktopAnalytics/Analytics.cs index 1ad59d2..da17356 100644 --- a/src/DesktopAnalytics/Analytics.cs +++ b/src/DesktopAnalytics/Analytics.cs @@ -19,6 +19,7 @@ using static System.Attribute; using static System.Environment; using static System.Environment.SpecialFolder; +using static System.Reflection.Assembly; using static System.String; namespace DesktopAnalytics @@ -55,7 +56,17 @@ public class Analytics : IDisposable private readonly Dictionary _propertiesThatGoWithEveryEvent; private static int s_exceptionCount = 0; + private class InitializationParameters + { + public string ApiSecret; + public string Host; + public int FlushAt; + public int FlushInterval; + public Assembly Assembly; + } + private readonly IClient _client; + private InitializationParameters _deferredInitializationParameters; /// /// Initialized a singleton; after calling this, use Analytics.Track() for each event. @@ -79,7 +90,6 @@ public Analytics(string apiSecret, UserInfo userInfo, bool allowTracking = true, : this(apiSecret, userInfo, new Dictionary(), allowTracking, retainPii, clientType, host, assemblyToUseForVersion:useCallingAssemblyVersion ? Assembly.GetCallingAssembly() : null) { - } private void UpdateServerInformationOnThisUser() @@ -132,6 +142,7 @@ public Analytics(string apiSecret, UserInfo userInfo, Dictionary a + "." + b); SetApplicationProperty("Version", versionNumber); @@ -240,7 +271,7 @@ public Analytics(string apiSecret, UserInfo userInfo, Dictionary - { - if (first == second) - return 0; + { + if (first == second) + return 0; var firstConfigPath = Path.Combine(first, kUserConfigFileName); var secondConfigPath = Path.Combine(second, kUserConfigFileName); - // Reversing the arguments like this means that second comes before first if it has a LARGER mod time. - // That is, we end up with the most recently modified user.config first. + // Reversing the arguments like this means that second comes before first if it has a LARGER mod time. + // That is, we end up with the most recently modified user.config first. return new FileInfo(secondConfigPath).LastWriteTimeUtc.CompareTo(new FileInfo(firstConfigPath).LastWriteTimeUtc); - }); + }); foreach (var folder in possibleFolders) { try @@ -477,6 +508,8 @@ public static void IdentifyUpdate(UserInfo userInfo) private void ReportIpAddressOfThisMachineAsync() { + if (!AllowTracking) + return; using (var client = new WebClient()) { try @@ -531,7 +564,7 @@ private bool AddGeolocationProperty(JObject j, string primary, string secondary var value = j.GetValue(primary)?.ToString(); if (IsNullOrWhiteSpace(value)) return secondary != null && AddGeolocationProperty(j, secondary); - + s_locationInfo.Add(primary, value); _propertiesThatGoWithEveryEvent.Add(primary, value); return true; @@ -653,7 +686,26 @@ public void Dispose() /// /// Indicates whether we are tracking or not /// - public static bool AllowTracking { get; set; } + public static bool AllowTracking + { + get => s_allowTracking; + set + { + if (value == s_allowTracking) + return; + s_allowTracking = value; + if (!value) + return; + var initializationParameters = s_singleton?._deferredInitializationParameters; + if (initializationParameters != null) + { + s_singleton._deferredInitializationParameters = null; + s_singleton.FinishInitialization(initializationParameters); + } + } + } + + private static bool s_allowTracking; #region OSVersion class Version @@ -763,7 +815,7 @@ public static string GetWindowsVersionInfoFromNetworkAPI() } else if (info._majorVersion == 10 && info._minorVersion == 0) { - windowsVersion = "Windows 10"; + windowsVersion = "Windows 10"; } else { @@ -828,27 +880,27 @@ private static string LinuxVersion s_linuxVersion = line.Substring(13).Trim('"'); break; } - } - } + } + } else if (File.Exists("/etc/lsb-release")) - { + { var versionData = File.ReadAllText("/etc/lsb-release"); var versionLines = versionData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (var t in versionLines) - { + { if (t.StartsWith("DISTRIB_DESCRIPTION=\"")) - { + { s_linuxVersion = t.Substring(21).Trim('"'); break; } } } else - { + { // If it's linux, it really should have /etc/lsb-release! s_linuxVersion = OSVersion.VersionString; - } } + } return s_linuxVersion; } } diff --git a/src/SampleApp/Program.cs b/src/SampleApp/Program.cs index 2957caa..99da2af 100644 --- a/src/SampleApp/Program.cs +++ b/src/SampleApp/Program.cs @@ -10,9 +10,9 @@ class Program { static int Main(string[] args) { - if (args.Length != 2) + if (args.Length < 2 || args.Length > 3) { - Console.WriteLine("Usage: SampleApp "); + Console.WriteLine("Usage: SampleApp [i[nitialTrackingState]=true|false]"); return 1; } @@ -22,6 +22,22 @@ static int Main(string[] args) return 1; } + var initialTracking = true; + + if (args.Length == 3) + { + var parts = args[2].Split('='); + if (parts.Length != 2 || + (!parts[0].Equals("i", StringComparison.OrdinalIgnoreCase) && + !parts[0].Equals("initialTrackingState", StringComparison.OrdinalIgnoreCase)) || + !bool.TryParse(parts[1], out initialTracking)) + { + Console.WriteLine( + $"Unrecognized parameter: {args[2]}{Environment.NewLine}Usage: SampleApp [i[nitialTrackingState]=true|false]"); + return 1; + } + } + var userInfo = new UserInfo { FirstName = "John", @@ -33,7 +49,7 @@ static int Main(string[] args) "This is a really long explanation of how I use this product to see how much you would be able to extract from Mixpanel.\r\nAnd a second line of it."); var propsForEveryEvent = new Dictionary {{"channel", "beta"}}; - using (new Analytics(args[0], userInfo, propertiesThatGoWithEveryEvent: propsForEveryEvent, clientType: clientType)) + using (new Analytics(args[0], userInfo, propsForEveryEvent, initialTracking, clientType: clientType)) { Thread.Sleep(3000); //note that anything we set from here on didn't make it into the initial "Launch" event. Things we want to @@ -44,12 +60,12 @@ static int Main(string[] args) Debug.WriteLine("Sleeping for 20 seconds to give it all a chance to send an event in the background..."); Thread.Sleep(20000); - Analytics.AllowTracking = false; + Analytics.AllowTracking = !Analytics.AllowTracking; Analytics.Track("Should not be tracked"); Debug.WriteLine("Sleeping for 2 seconds just for fun"); Thread.Sleep(2000); - Analytics.AllowTracking = true; + Analytics.AllowTracking = !Analytics.AllowTracking; Analytics.SetApplicationProperty("TimeSinceLaunch", "25 seconds"); Analytics.Track("SomeEvent", new Dictionary {{"SomeValue", "42"}}); Console.WriteLine("Sleeping for another 20 seconds to give it all a chance to send an event in the background..."); From 956f9cade33fd74bae5de1e2d68830b842cc7a48 Mon Sep 17 00:00:00 2001 From: tombogle Date: Wed, 18 Mar 2026 09:03:45 -0400 Subject: [PATCH 3/6] Code/comment cleanup and a little refactoring --- src/DesktopAnalytics/Analytics.cs | 284 ++++++++++++++++++------------ src/SampleAppWithForm/Program.cs | 2 +- 2 files changed, 173 insertions(+), 113 deletions(-) diff --git a/src/DesktopAnalytics/Analytics.cs b/src/DesktopAnalytics/Analytics.cs index da17356..f03c926 100644 --- a/src/DesktopAnalytics/Analytics.cs +++ b/src/DesktopAnalytics/Analytics.cs @@ -17,8 +17,11 @@ using Newtonsoft.Json.Linq; using Segment.Serialization; using static System.Attribute; +using static System.Configuration.ConfigurationUserLevel; using static System.Environment; using static System.Environment.SpecialFolder; +using static System.IO.Directory; +using static System.IO.Path; using static System.Reflection.Assembly; using static System.String; @@ -85,10 +88,23 @@ private class InitializationParameters /// assembly to get version information. (GetEntryAssembly is null for MAF plugins.) /// [PublicAPI] - public Analytics(string apiSecret, UserInfo userInfo, bool allowTracking = true, - bool retainPii = false, ClientType clientType = ClientType.Segment, string host = null, bool useCallingAssemblyVersion = false) - : this(apiSecret, userInfo, new Dictionary(), allowTracking, retainPii, clientType, host, - assemblyToUseForVersion:useCallingAssemblyVersion ? Assembly.GetCallingAssembly() : null) + public Analytics( + string apiSecret, + UserInfo userInfo, + bool allowTracking = true, + bool retainPii = false, + ClientType clientType = ClientType.Segment, + string host = null, + bool useCallingAssemblyVersion = false) : this( + apiSecret, + userInfo, + new Dictionary(), + allowTracking, + retainPii, + clientType, + host, + assemblyToUseForVersion: useCallingAssemblyVersion ? GetCallingAssembly() : null + ) { } @@ -119,10 +135,10 @@ private void UpdateServerInformationOnThisUser() /// Initialized a singleton; after calling this, use Analytics.Track() for each event. /// /// The segment.com apiSecret - /// Information about the user that you have previous collected + /// Information about the user that you have previously collected /// A set of key-value pairs to send with /// *every* event - /// If false, this will not do any communication with segment.io + /// If false, prevents communication with segment.io /// If false, userInfo will be stripped/hashed/adjusted to prevent /// communication of personally identifiable information to the analytics server. /// @@ -137,11 +153,17 @@ private void UpdateServerInformationOnThisUser() /// program, pass a specific assembly to use as the basis for getting the version /// information. (Note: GetEntryAssembly is null for MAF plugins.) /// - public Analytics(string apiSecret, UserInfo userInfo, Dictionary propertiesThatGoWithEveryEvent, bool allowTracking = true, - bool retainPii = false, ClientType clientType = ClientType.Segment, - string host = null, int flushAt = -1, int flushInterval = -1, - Assembly assemblyToUseForVersion = null) + public Analytics( + string apiSecret, + UserInfo userInfo, + Dictionary propertiesThatGoWithEveryEvent, + bool allowTracking = true, + bool retainPii = false, + ClientType clientType = ClientType.Segment, + string host = null, + int flushAt = -1, + int flushInterval = -1, + Assembly assemblyToUseForVersion = null ) { if (s_singleton != null) @@ -274,8 +296,9 @@ private void FinishInitialization(InitializationParameters parameters) if (IsNullOrEmpty(AnalyticsSettings.Default.LastVersionLaunched)) { - //"Created" is a special property that segment.io understands and coverts to equivalents in various analytics services - //So it's not as descriptive for us as "FirstLaunchOnSystem", but it will give the best experience on the analytics sites. + // "Created" is a special property that segment.io understands and coverts to + // equivalents in various analytics services. So it's not as descriptive for us as + // "FirstLaunchOnSystem", but it will give the best experience on the analytics sites. TrackWithApplicationProperties("Created"); } else if (AnalyticsSettings.Default.LastVersionLaunched != versionNumberWithBuild) @@ -286,8 +309,8 @@ private void FinishInitialization(InitializationParameters parameters) }); } - // We want to record the launch event independent of whether we also recorded a special first launch - // But that is done after we retrieve (or fail to retrieve) our external ip address. + // We want to record the launch event even if we also recorded a special first launch, + // but that is done after we retrieve (or fail to retrieve) our external IP address. // See http://issues.bloomlibrary.org/youtrack/issue/BL-4011. AnalyticsSettings.Default.LastVersionLaunched = versionNumberWithBuild; @@ -321,13 +344,15 @@ private static void TrySaveSettings() private void AttemptToGetUserIdSettingsFromDifferentChannel() { - // We need to get the company name and exe name of the main application, without introducing a dependency on - // Windows.Forms, so we can't use the Windows.Forms.Application methods. For maximum robustness, we try two - // different approaches. - // REVIEW: The first approach will NOT work for plugins or calls from unmanaged code, but for maximum - // compatibility (to keep from breaking Bloom), we try it first. If it fails, we try the second approach, - // which should work for everyone (though until there is a plugin that supports channels, it will presumably - // never actually find a pre-existing config file from a different channel). + // We need to get the company name and exe name of the main application, without + // introducing a dependency on Windows.Forms, so we can't use the + // Windows.Forms.Application methods. For maximum robustness, we try two different + // approaches. + // REVIEW: The first approach will NOT work for plugins or calls from unmanaged code, + // but for maximum compatibility (to keep from breaking Bloom), we try it first. If it + // fails, we try the second approach, which should work for everyone (though until + // there is a plugin that supports channels, it will presumably never actually find a + // pre-existing config file from a different channel). for (int attempt = 0; attempt < 2; attempt++) { @@ -335,12 +360,12 @@ private void AttemptToGetUserIdSettingsFromDifferentChannel() string softwareName; if (attempt == 0) { - if (!TryGetSettingsLocationInfoFromEntryAssembly(out settingsLocation, out softwareName)) + if (!TryGetSettingsLocationFromEntryAssembly(out settingsLocation, out softwareName)) continue; } else { - if (!TryGetDefaultSettingsLocationInfo(out settingsLocation, out softwareName)) + if (!TryGetDefaultSettingsLocation(out settingsLocation, out softwareName)) return; } @@ -354,61 +379,80 @@ private void AttemptToGetUserIdSettingsFromDifferentChannel() var index = Math.Min(5, softwareName.Length); var prefix = softwareName.Substring(0, index); var pattern = prefix + "*"; - var possibleParentFolders = Directory.GetDirectories(settingsLocation, pattern); + var possibleParentFolders = GetDirectories(settingsLocation, pattern); var possibleFolders = new List(); foreach (var folder in possibleParentFolders) - { - possibleFolders.AddRange(Directory.GetDirectories(folder).Where(f => File.Exists(Path.Combine(f, kUserConfigFileName)))); - } + possibleFolders.AddRange(GetDirectories(folder).Where(f => File.Exists(Combine(f, kUserConfigFileName)))); possibleFolders.Sort((first, second) => { if (first == second) return 0; - var firstConfigPath = Path.Combine(first, kUserConfigFileName); - var secondConfigPath = Path.Combine(second, kUserConfigFileName); + var firstConfigPath = Combine(first, kUserConfigFileName); + var secondConfigPath = Combine(second, kUserConfigFileName); // Reversing the arguments like this means that second comes before first if it has a LARGER mod time. // That is, we end up with the most recently modified user.config first. - return new FileInfo(secondConfigPath).LastWriteTimeUtc.CompareTo(new FileInfo(firstConfigPath).LastWriteTimeUtc); + return new FileInfo(secondConfigPath).LastWriteTimeUtc.CompareTo( + new FileInfo(firstConfigPath).LastWriteTimeUtc + ); }); + foreach (var folder in possibleFolders) { try { - var doc = XDocument.Load(Path.Combine(folder, kUserConfigFileName)); - var idSetting = - doc.XPathSelectElement( - "configuration/userSettings/DesktopAnalytics.AnalyticsSettings/setting[@name='IdForAnalytics']"); + var doc = XDocument.Load(Combine(folder, kUserConfigFileName)); + var idSetting = doc.XPathSelectElement( + "configuration/userSettings/DesktopAnalytics.AnalyticsSettings/setting[@name='IdForAnalytics']" + ); if (idSetting == null) continue; string analyticsId = idSetting.Value; if (IsNullOrEmpty(analyticsId)) continue; AnalyticsSettings.Default.IdForAnalytics = analyticsId; - AnalyticsSettings.Default.FirstName = ExtractSetting(AnalyticsSettings.Default.FirstName, doc, "FirstName"); - AnalyticsSettings.Default.LastName = ExtractSetting(AnalyticsSettings.Default.LastName, doc, "LastName"); - AnalyticsSettings.Default.LastVersionLaunched = ExtractSetting(AnalyticsSettings.Default.LastVersionLaunched, doc, "LastVersionLaunched"); - AnalyticsSettings.Default.Email = ExtractSetting(AnalyticsSettings.Default.Email, doc, "Email"); + AnalyticsSettings.Default.FirstName = ExtractSetting( + AnalyticsSettings.Default.FirstName, + doc, + "FirstName" + ); + AnalyticsSettings.Default.LastName = ExtractSetting( + AnalyticsSettings.Default.LastName, + doc, + "LastName" + ); + AnalyticsSettings.Default.LastVersionLaunched = ExtractSetting( + AnalyticsSettings.Default.LastVersionLaunched, + doc, + "LastVersionLaunched" + ); + AnalyticsSettings.Default.Email = ExtractSetting( + AnalyticsSettings.Default.Email, + doc, + "Email" + ); TrySaveSettings(); return; } catch (Exception) { - // If anything goes wrong we just won't try to get our ID from this source. + // If anything goes wrong, skip trying to get our ID from this source. } } } } - private bool TryGetSettingsLocationInfoFromEntryAssembly(out string settingsLocation, out string softwareName) + private static bool TryGetSettingsLocationFromEntryAssembly(out string settingsLocation, + out string softwareName + ) { settingsLocation = null; softwareName = null; - var entryAssembly = Assembly.GetEntryAssembly(); // the main exe assembly + var entryAssembly = GetEntryAssembly(); // the main exe assembly if (entryAssembly == null) // Called from unmanaged code? return false; - softwareName = Path.GetFileNameWithoutExtension(entryAssembly.Location); + softwareName = GetFileNameWithoutExtension(entryAssembly.Location); if (!(GetCustomAttribute(entryAssembly, typeof(AssemblyCompanyAttribute)) is AssemblyCompanyAttribute companyAttribute) || IsNullOrEmpty(softwareName)) { @@ -418,22 +462,21 @@ private bool TryGetSettingsLocationInfoFromEntryAssembly(out string settingsLoca string companyName = companyAttribute.Company; if (companyName == null) return false; - settingsLocation = Path.Combine(GetFolderPath(LocalApplicationData), - companyName); + settingsLocation = Combine(GetFolderPath(LocalApplicationData), companyName); return true; } - private bool TryGetDefaultSettingsLocationInfo(out string settingsLocation, out string softwareName) + private static bool TryGetDefaultSettingsLocation(out string settingsLocation, out string softwareName) { settingsLocation = null; softwareName = null; try { var userConfigPath = GetUserConfigPath(); - if (Path.GetFileName(userConfigPath) != kUserConfigFileName) + if (GetFileName(userConfigPath) != kUserConfigFileName) return false; - userConfigPath = Path.GetDirectoryName(Path.GetDirectoryName(userConfigPath)); // strip file name and last folder level - softwareName = Path.GetFileName(userConfigPath); // This is actually a folder, not a file. + userConfigPath = GetDirectoryName(GetDirectoryName(userConfigPath)); // strip file name and last folder level + softwareName = GetFileName(userConfigPath); // This is actually a folder, not a file. if (softwareName == null) return false; int i = softwareName.IndexOf(".exe", StringComparison.Ordinal); @@ -445,7 +488,7 @@ private bool TryGetDefaultSettingsLocationInfo(out string settingsLocation, out if (i > 0) softwareName = softwareName.Substring(0, i); } - settingsLocation = Path.GetDirectoryName(userConfigPath); // strip product folder + settingsLocation = GetDirectoryName(userConfigPath); // strip product folder return true; } @@ -455,11 +498,11 @@ private bool TryGetDefaultSettingsLocationInfo(out string settingsLocation, out } } - private string GetUserConfigPath() + private static string GetUserConfigPath() { try { - return ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath; + return ConfigurationManager.OpenExeConfiguration(PerUserRoamingAndLocal).FilePath; } catch (ConfigurationErrorsException ex) { @@ -470,20 +513,19 @@ private string GetUserConfigPath() } // If the specified setting's current value is empty, try to extract it from the document. - private string ExtractSetting(string current, XDocument doc, string name) + private static string ExtractSetting(string current, XDocument doc, string name) { if (!IsNullOrEmpty(current)) return current; - var setting = - doc.XPathSelectElement( - "configuration/userSettings/DesktopAnalytics.AnalyticsSettings/setting[@name='" + name + "']"); - if (setting == null) - return ""; - return setting.Value; + var setting = doc.XPathSelectElement( + $"configuration/userSettings/DesktopAnalytics.AnalyticsSettings/setting[@name='{name}']" + ); + return setting?.Value ?? ""; } /// - /// Use this after showing a registration dialog, so that this stuff is sent right away, rather than the next time you start up Analytics + /// Use this after showing a registration dialog, so that this stuff is sent right away, + /// rather than the next time you start up Analytics /// [PublicAPI] public static void IdentifyUpdate(UserInfo userInfo) @@ -493,16 +535,20 @@ public static void IdentifyUpdate(UserInfo userInfo) } /// - /// Override this if you want your analytics to report an actual IP address to the server (which could be considered PII), rather than - /// just the general geolocation info. The service should simply return a page with a body containing the ip address alone. + /// Override this if you want your analytics to report an actual IP address to the server + /// (which could be considered PII), rather than just the general geolocation info. The + /// service should simply return a page with a body containing the ip address alone. /// - /// This used to default to "http://icanhazip.com"; //formerly: "http://ipecho.net/plain" (that URL went down, but is now back up) + /// This used to default to "http://icanhazip.com"; + /// formerly: "http://ipecho.net/plain" (that URL went down, but is now back up) public static string UrlThatReturnsExternalIpAddress { get; set; } + /// - /// Override this for any reason you like, including if the built-in one ( http://ip-api.com/json/) stops working some day. - /// This will be ignored if is set. - /// The service should return json that contains values for one or more of the following names: city, country, countryCode, - /// region, regionName. + /// Override this for any reason you like, including if the built-in one + /// (http://ip-api.com/json/) stops working some day. This will be ignored if + /// is set. The service should return json + /// that contains values for one or more of the following names: city, country, + /// countryCode, region, regionName. /// public static string UrlThatReturnsGeolocationJson { get; set; } @@ -514,12 +560,20 @@ private void ReportIpAddressOfThisMachineAsync() { try { - var useGeoLocation = IsNullOrEmpty(UrlThatReturnsExternalIpAddress); - Uri.TryCreate(useGeoLocation ? UrlThatReturnsGeolocationJson : UrlThatReturnsExternalIpAddress, - UriKind.Absolute, out var uri); + var url = UrlThatReturnsExternalIpAddress; + var useGeoLocation = IsNullOrEmpty(url); + if (useGeoLocation) + url = UrlThatReturnsGeolocationJson; + Uri.TryCreate(url, UriKind.Absolute, out var uri); client.DownloadDataCompleted += (sender, e) => { - var launchProperties = new JsonObject { { "installedUiLangId", CultureInfo.InstalledUICulture.ThreeLetterISOLanguageName } }; + var launchProperties = new JsonObject + { + { + "installedUiLangId", + CultureInfo.InstalledUICulture.ThreeLetterISOLanguageName + }, + }; try { @@ -541,8 +595,9 @@ private void ReportIpAddressOfThisMachineAsync() } catch (Exception) { - // We get here when the user isn't online, or anything else prevents us from getting - // their IP address or location. Still worth reporting the launch in the latter case. + // We get here when the user isn't online, or anything else prevents us + // from getting their IP address or location. Still worth reporting the + // launch in the latter case. TrackWithApplicationProperties("Launch", launchProperties); return; } @@ -550,7 +605,6 @@ private void ReportIpAddressOfThisMachineAsync() TrackWithApplicationProperties("Launch", launchProperties); }; client.DownloadDataAsync(uri); - } catch (Exception) { @@ -722,6 +776,7 @@ public Version(PlatformID platform, int major, int minor, string label) _minor = minor; Label = label; } + public bool Match(OperatingSystem os) { return os.Version.Minor == _minor && @@ -765,7 +820,6 @@ private static string GetOperatingSystemLabel() return OSVersion.VersionString; } - private string GetOperatingSystemVersionLabel() { return OSVersion.Version.ToString(); @@ -773,9 +827,7 @@ private string GetOperatingSystemVersionLabel() #region Windows8PlusVersionReportingSupport [DllImport("netapi32.dll", CharSet = CharSet.Auto)] - private static extern int NetWkstaGetInfo(string server, - int level, - out IntPtr info); + private static extern int NetWkstaGetInfo(string server, int level, out IntPtr info); [DllImport("netapi32.dll")] private static extern int NetApiBufferFree(IntPtr pBuf); @@ -784,8 +836,10 @@ private static extern int NetWkstaGetInfo(string server, private struct MachineInfo { private readonly int platform_id; + [MarshalAs(UnmanagedType.LPWStr)] private readonly string _computerName; + [MarshalAs(UnmanagedType.LPWStr)] private readonly string _languageGroup; public readonly int _majorVersion; @@ -828,6 +882,7 @@ public static string GetWindowsVersionInfoFromNetworkAPI() [DllImport("libc")] static extern int uname(IntPtr buf); + private static string s_unixName; private static string UnixName { @@ -866,43 +921,35 @@ private static string LinuxVersion { if (OSVersion.Platform != PlatformID.Unix) return Empty; - if (s_linuxVersion == null) - { - s_linuxVersion = Empty; - if (File.Exists("/etc/wasta-release")) - { - var versionData = File.ReadAllText("/etc/wasta-release"); - var versionLines = versionData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in versionLines) - { - if (line.StartsWith("DESCRIPTION=\"")) - { - s_linuxVersion = line.Substring(13).Trim('"'); - break; - } + if (s_linuxVersion != null) + return s_linuxVersion; + + s_linuxVersion = DetermineLinuxVersion(); + return s_linuxVersion; } } - else if (File.Exists("/etc/lsb-release")) + + private static string DetermineLinuxVersion() { - var versionData = File.ReadAllText("/etc/lsb-release"); - var versionLines = versionData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var t in versionLines) + var candidates = new[] { - if (t.StartsWith("DISTRIB_DESCRIPTION=\"")) + (FilePath: "/etc/wasta-release", Prefix: "DESCRIPTION=\""), + (FilePath: "/etc/lsb-release", Prefix: "DISTRIB_DESCRIPTION=\"") + }; + + foreach (var candidate in candidates) { - s_linuxVersion = t.Substring(21).Trim('"'); - break; - } - } - } - else + if (File.Exists(candidate.FilePath)) { - // If it's linux, it really should have /etc/lsb-release! - s_linuxVersion = OSVersion.VersionString; + var line = File.ReadLines(candidate.FilePath) + .FirstOrDefault(l => l.StartsWith(candidate.Prefix)); + if (line != null) + return line.Substring(candidate.Prefix.Length).Trim('"'); } } - return s_linuxVersion; - } + + // Fallback, but if it's linux, it really should have /etc/lsb-release! + return OSVersion.VersionString; } /// @@ -940,6 +987,7 @@ private static string DesktopEnvironment } private static string s_linuxDesktop; + /// /// Get the currently running desktop environment (like Unity, Gnome shell etc) /// @@ -973,17 +1021,25 @@ private static string LinuxDesktop private static void TrackWithApplicationProperties(string eventName, JsonObject properties = null) { if (s_singleton == null) - { throw new ApplicationException("The application must first construct a single Analytics object"); - } + + if (!AllowTracking) + return; + if (properties == null) properties = new JsonObject(); + foreach (var p in s_singleton._propertiesThatGoWithEveryEvent) { properties.Remove(p.Key); properties.Add(p.Key, p.Value ?? Empty); } - s_singleton._client.Track(AnalyticsSettings.Default.IdForAnalytics, eventName, properties); + + s_singleton._client.Track( + AnalyticsSettings.Default.IdForAnalytics, + eventName, + properties + ); } /// @@ -1003,8 +1059,12 @@ public static void SetApplicationProperty(string key, string value) private static string GetUserNameForEvent() { - return s_userInfo == null ? "unknown" : - (IsNullOrWhiteSpace(s_userInfo.FirstName) ? "" : s_userInfo.FirstName + " ") + s_userInfo.LastName; + if (s_userInfo == null) + return "unknown"; + + return IsNullOrWhiteSpace(s_userInfo.FirstName) + ? s_userInfo.LastName + : $"{s_userInfo.FirstName} {s_userInfo.LastName}"; } #endregion diff --git a/src/SampleAppWithForm/Program.cs b/src/SampleAppWithForm/Program.cs index d853109..c5e04a4 100644 --- a/src/SampleAppWithForm/Program.cs +++ b/src/SampleAppWithForm/Program.cs @@ -22,7 +22,7 @@ static int Main(string[] args) if (!Enum.TryParse(args[1], true, out var clientType)) { - Console.WriteLine($"Usage: SampleApp {Environment.NewLine}Unrecoginzed client type: {args[1]}"); + Console.WriteLine($"Usage: SampleApp {Environment.NewLine}Unrecognized client type: {args[1]}"); return 1; } From d286c0c8882e4132cd55dfabe14b47e0d6fc4519 Mon Sep 17 00:00:00 2001 From: tombogle Date: Wed, 18 Mar 2026 09:44:05 -0400 Subject: [PATCH 4/6] Added unit tests for testable static methods in Analytics.cs --- src/DesktopAnalytics/Analytics.cs | 28 ++++--- src/DesktopAnalyticsTests/AnalyticsTests.cs | 91 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 src/DesktopAnalyticsTests/AnalyticsTests.cs diff --git a/src/DesktopAnalytics/Analytics.cs b/src/DesktopAnalytics/Analytics.cs index f03c926..6c8f207 100644 --- a/src/DesktopAnalytics/Analytics.cs +++ b/src/DesktopAnalytics/Analytics.cs @@ -275,8 +275,9 @@ private void FinishInitialization(InitializationParameters parameters) ReportIpAddressOfThisMachineAsync(); //this will take a while and may fail, so just do it when/if we can var assembly = parameters.Assembly; - var versionNumberWithBuild = assembly?.GetName().Version?.ToString() ?? ""; - var versionNumber = versionNumberWithBuild.Split('.').Take(2).Aggregate((a, b) => a + "." + b); + var version = assembly?.GetName().Version; + var versionNumberWithBuild = version?.ToString() ?? ""; + var versionNumber = version == null ? "" : $"{version.Major}.{version.Minor}"; SetApplicationProperty("Version", versionNumber); SetApplicationProperty("FullVersion", versionNumberWithBuild); SetApplicationProperty("UserName", GetUserNameForEvent()); @@ -287,11 +288,7 @@ private void FinishInitialization(InitializationParameters parameters) // This (and "64bit OS" above) really belong in Context, but segment.io doesn't seem // to convey context to Mixpanel in a reliable/predictable form. var ci = CultureInfo.CurrentUICulture; - const string invariantCulture = "iv"; - var installedUICulture = !IsNullOrEmpty(ci.TwoLetterISOLanguageName) && - ci.TwoLetterISOLanguageName != invariantCulture - ? ci.TwoLetterISOLanguageName - : ci.ThreeLetterISOLanguageName; + var installedUICulture = GetInstalledUICultureCode(ci); SetApplicationProperty("DeviceUILanguage", installedUICulture); if (IsNullOrEmpty(AnalyticsSettings.Default.LastVersionLaunched)) @@ -396,7 +393,7 @@ private void AttemptToGetUserIdSettingsFromDifferentChannel() new FileInfo(firstConfigPath).LastWriteTimeUtc ); }); - + foreach (var folder in possibleFolders) { try @@ -512,8 +509,17 @@ private static string GetUserConfigPath() } } + internal static string GetInstalledUICultureCode(CultureInfo ci) + { + const string invariantCulture = "iv"; + return !IsNullOrEmpty(ci.TwoLetterISOLanguageName) && + ci.TwoLetterISOLanguageName != invariantCulture + ? ci.TwoLetterISOLanguageName + : ci.ThreeLetterISOLanguageName; + } + // If the specified setting's current value is empty, try to extract it from the document. - private static string ExtractSetting(string current, XDocument doc, string name) + internal static string ExtractSetting(string current, XDocument doc, string name) { if (!IsNullOrEmpty(current)) return current; @@ -1028,13 +1034,13 @@ private static void TrackWithApplicationProperties(string eventName, JsonObject if (properties == null) properties = new JsonObject(); - + foreach (var p in s_singleton._propertiesThatGoWithEveryEvent) { properties.Remove(p.Key); properties.Add(p.Key, p.Value ?? Empty); } - + s_singleton._client.Track( AnalyticsSettings.Default.IdForAnalytics, eventName, diff --git a/src/DesktopAnalyticsTests/AnalyticsTests.cs b/src/DesktopAnalyticsTests/AnalyticsTests.cs new file mode 100644 index 0000000..20e8f3c --- /dev/null +++ b/src/DesktopAnalyticsTests/AnalyticsTests.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Xml.Linq; +using DesktopAnalytics; +using NUnit.Framework; + +namespace DesktopAnalyticsTests +{ + [TestFixture] + public class AnalyticsTests + { + #region ExtractSetting tests + + private static readonly XDocument s_settingsDoc = XDocument.Parse(@" + + + + abc-123 + Bob + Smith + test@example.com + 1.2.3.4 + + +"); + + [Test] + public void ExtractSetting_CurrentNonEmpty_ReturnsCurrentWithoutConsultingDoc() + { + Assert.AreEqual("existing", Analytics.ExtractSetting("existing", s_settingsDoc, "IdForAnalytics")); + } + + [Test] + public void ExtractSetting_CurrentEmpty_SettingPresent_ReturnsSettingValue() + { + Assert.AreEqual("abc-123", Analytics.ExtractSetting("", s_settingsDoc, "IdForAnalytics")); + } + + [Test] + public void ExtractSetting_CurrentNull_SettingPresent_ReturnsSettingValue() + { + Assert.AreEqual("test@example.com", Analytics.ExtractSetting(null, s_settingsDoc, "Email")); + } + + [Test] + public void ExtractSetting_CurrentEmpty_SettingAbsent_ReturnsEmptyString() + { + Assert.AreEqual("", Analytics.ExtractSetting("", s_settingsDoc, "NonExistent")); + } + + #endregion + + #region GetInstalledUICultureCode tests + + [Test] + public void GetInstalledUICultureCode_NormalCulture_ReturnsTwoLetterCode() + { + var ci = new CultureInfo("en-US"); + Assert.AreEqual("en", Analytics.GetInstalledUICultureCode(ci)); + } + + [Test] + public void GetInstalledUICultureCode_FrenchCulture_ReturnsTwoLetterCode() + { + var ci = new CultureInfo("fr-FR"); + Assert.AreEqual("fr", Analytics.GetInstalledUICultureCode(ci)); + } + + [Test] + public void GetInstalledUICultureCode_InvariantCulture_FallsBackToThreeLetter() + { + // InvariantCulture has TwoLetterISOLanguageName == "iv" + var ci = CultureInfo.InvariantCulture; + Assert.AreEqual(ci.ThreeLetterISOLanguageName, + Analytics.GetInstalledUICultureCode(ci)); + } + + #endregion + + #region GetWindowsVersionInfoFromNetworkAPI tests + + [Test] + [Platform("Win")] + public void GetWindowsVersionInfoFromNetworkAPI_ReturnsRecognizedWindowsString() + { + var result = Analytics.GetWindowsVersionInfoFromNetworkAPI(); + Assert.That(result, Does.Match(@"^Windows [\d.]+$")); + } + + #endregion + } +} From 359332bb104ad2109f0bf40f475f7e1eb8c5bfe4 Mon Sep 17 00:00:00 2001 From: tombogle Date: Wed, 18 Mar 2026 15:26:15 -0400 Subject: [PATCH 5/6] Address some concerns about multi-threading/race conditions --- src/DesktopAnalytics/Analytics.cs | 68 +++++++++++++++++-------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/src/DesktopAnalytics/Analytics.cs b/src/DesktopAnalytics/Analytics.cs index 6c8f207..36fe51c 100644 --- a/src/DesktopAnalytics/Analytics.cs +++ b/src/DesktopAnalytics/Analytics.cs @@ -69,7 +69,7 @@ private class InitializationParameters } private readonly IClient _client; - private InitializationParameters _deferredInitializationParameters; + private volatile InitializationParameters _deferredInitializationParameters; /// /// Initialized a singleton; after calling this, use Analytics.Track() for each event. @@ -108,7 +108,7 @@ public Analytics( { } - private void UpdateServerInformationOnThisUser() + private void UpdateServerInformationOnThisUser(bool initializing = false) { s_traits = new JsonObject { @@ -125,7 +125,7 @@ private void UpdateServerInformationOnThisUser() s_traits.Add(property.Key, property.Value); } - if (!AllowTracking) + if (!AllowTracking && !initializing) return; _client.Identify(AnalyticsSettings.Default.IdForAnalytics, s_traits, s_locationInfo); @@ -195,8 +195,6 @@ public Analytics( s_userInfo = retainPii ? userInfo : userInfo.CreateSanitized(); - s_allowTracking = allowTracking; - var initializationParameters = new InitializationParameters { ApiSecret = apiSecret, @@ -211,13 +209,16 @@ public Analytics( if (IsNullOrEmpty(UrlThatReturnsExternalIpAddress)) UrlThatReturnsGeolocationJson = "http://ip-api.com/json/"; - if (s_allowTracking) - FinishInitialization(initializationParameters); + if (allowTracking) + Initialize(initializationParameters); else + { + s_allowTracking = false; _deferredInitializationParameters = initializationParameters; + } } - private void FinishInitialization(InitializationParameters parameters) + private void Initialize(InitializationParameters parameters) { // Bring in settings from any previous version. if (AnalyticsSettings.Default.NeedUpgrade) @@ -271,7 +272,7 @@ private void FinishInitialization(InitializationParameters parameters) s_locationInfo = new JsonObject(); - UpdateServerInformationOnThisUser(); + UpdateServerInformationOnThisUser(true); ReportIpAddressOfThisMachineAsync(); //this will take a while and may fail, so just do it when/if we can var assembly = parameters.Assembly; @@ -291,6 +292,13 @@ private void FinishInitialization(InitializationParameters parameters) var installedUICulture = GetInstalledUICultureCode(ci); SetApplicationProperty("DeviceUILanguage", installedUICulture); + // This method only ever gets called when the client has requested that we allow + // tracking. We can set this flag to true now that the client is created and our user + // and application properties are initialized. In practice, we hope that no other + // tracking events come through before our Created/Upgrade event below, but in the + // unlikely event that they do, it's not the end of the world. + s_allowTracking = true; + if (IsNullOrEmpty(AnalyticsSettings.Default.LastVersionLaunched)) { // "Created" is a special property that segment.io understands and coverts to @@ -560,8 +568,6 @@ public static void IdentifyUpdate(UserInfo userInfo) private void ReportIpAddressOfThisMachineAsync() { - if (!AllowTracking) - return; using (var client = new WebClient()) { try @@ -630,17 +636,6 @@ private bool AddGeolocationProperty(JObject j, string primary, string secondary return true; } - //private IEnumerable> GetLocationPropertiesOfThisMachine() - //{ - // using (var client = new WebClient()) - // { - // var json = client.DownloadString("http://freegeoip.net/json"); - // JObject results = JObject.Parse(json); - // yield return new KeyValuePair("Country", (string)results["country_name"]); - // yield return new KeyValuePair("City", (string)results["city"]); - // } - //} - /// /// Records an event /// @@ -753,15 +748,26 @@ public static bool AllowTracking { if (value == s_allowTracking) return; - s_allowTracking = value; - if (!value) - return; - var initializationParameters = s_singleton?._deferredInitializationParameters; - if (initializationParameters != null) + + // The following is not strictly thread safe because another thread could set this + // after the singleton is created but before it checks the flag to decide whether + // to complete initialization based on the value of the flag. In practice, the + // Analytics object is normally constructed during application startup, before + // there is any real chance of multiple threads running. + if (value && s_singleton != null) { - s_singleton._deferredInitializationParameters = null; - s_singleton.FinishInitialization(initializationParameters); + var initializationParameters = s_singleton._deferredInitializationParameters; + if (initializationParameters != null) + { + // By clearing these parameters, we reduce the chance that we will try to + // initialize twice if multiple threads are setting AllowTracking to true. + s_singleton._deferredInitializationParameters = null; + s_singleton.Initialize(initializationParameters); + return; // Initialize sets s_allowTracking = true + } } + + s_allowTracking = value; } } @@ -1019,7 +1025,7 @@ private static string LinuxDesktop } } - public static Statistics Statistics => s_singleton._client.Statistics; + public static Statistics Statistics => s_singleton._client?.Statistics ?? new Statistics(0, 0, 0); /// /// All calls to Client.Track should run through here so we can provide defaults for every event @@ -1076,7 +1082,7 @@ private static string GetUserNameForEvent() public static void FlushClient() { - s_singleton._client.Flush(); + s_singleton._client?.Flush(); } } } From a8a355384df254baf9cae84397ff2fb5574b68c8 Mon Sep 17 00:00:00 2001 From: tombogle Date: Wed, 18 Mar 2026 15:35:41 -0400 Subject: [PATCH 6/6] Fixed bug in argument parsing logic in SampleAppWithForm --- src/SampleAppWithForm/Program.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SampleAppWithForm/Program.cs b/src/SampleAppWithForm/Program.cs index c5e04a4..f6d598e 100644 --- a/src/SampleAppWithForm/Program.cs +++ b/src/SampleAppWithForm/Program.cs @@ -14,7 +14,7 @@ internal static class Program [STAThread] static int Main(string[] args) { - if (args.Length != 2) + if (args.Length < 2 || args.Length > 4) { Console.WriteLine("Usage: SampleApp {-q:maxQueuedEvents} {-f:flushInterval}"); return 1; @@ -39,9 +39,9 @@ static int Main(string[] args) var propertiesThatGoWithEveryEvent = new Dictionary { { "channel", "beta" } }; - if (!int.TryParse(args.Skip(1).SingleOrDefault(a => a.StartsWith("-q:"))?.Substring(2), out var flushAt)) + if (!int.TryParse(args.Skip(2).SingleOrDefault(a => a.StartsWith("-q:"))?.Substring(3), out var flushAt)) flushAt = -1; - if (!int.TryParse(args.Skip(1).SingleOrDefault(a => a.StartsWith("-f:"))?.Substring(2), out var flushInterval)) + if (!int.TryParse(args.Skip(2).SingleOrDefault(a => a.StartsWith("-f:"))?.Substring(3), out var flushInterval)) flushInterval = -1; using (new Analytics(args[0], userInfo, propertiesThatGoWithEveryEvent, clientType: clientType, flushAt: flushAt, @@ -53,4 +53,4 @@ static int Main(string[] args) return 0; } } -} \ No newline at end of file +}