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..36fe51c 100644 --- a/src/DesktopAnalytics/Analytics.cs +++ b/src/DesktopAnalytics/Analytics.cs @@ -16,6 +16,13 @@ using JetBrains.Annotations; 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; namespace DesktopAnalytics @@ -37,6 +44,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 +59,17 @@ public class Analytics : IDisposable private readonly Dictionary _propertiesThatGoWithEveryEvent; private static int s_exceptionCount = 0; - const int MAX_EXCEPTION_REPORTS_PER_RUN = 10; + private class InitializationParameters + { + public string ApiSecret; + public string Host; + public int FlushAt; + public int FlushInterval; + public Assembly Assembly; + } - private readonly IClient Client; + private readonly IClient _client; + private volatile InitializationParameters _deferredInitializationParameters; /// /// Initialized a singleton; after calling this, use Analytics.Track() for each event. @@ -72,15 +88,27 @@ public class Analytics : IDisposable /// 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 + ) { - } - private void UpdateServerInformationOnThisUser() + private void UpdateServerInformationOnThisUser(bool initializing = false) { s_traits = new JsonObject { @@ -97,20 +125,20 @@ 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); + _client.Identify(AnalyticsSettings.Default.IdForAnalytics, s_traits, s_locationInfo); } /// /// 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. /// @@ -125,11 +153,18 @@ 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) { @@ -143,12 +178,12 @@ public Analytics(string apiSecret, UserInfo userInfo, Dictionary a + "." + b); + + var assembly = parameters.Assembly; + 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()); SetApplicationProperty("Browser", GetOperatingSystemLabel()); SetApplicationProperty("OS Version Number", GetOperatingSystemVersionLabel()); - SetApplicationProperty("64bit OS", Environment.Is64BitOperatingSystem.ToString()); - SetApplicationProperty("64bit App", Environment.Is64BitProcess.ToString()); + SetApplicationProperty("64bit OS", Is64BitOperatingSystem.ToString()); + SetApplicationProperty("64bit App", Is64BitProcess.ToString()); // 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); - + + // 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 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) @@ -253,8 +314,8 @@ public Analytics(string apiSecret, UserInfo userInfo, Dictionary(); 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); - // 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); - }); + { + if (first == second) + return 0; + 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 + ); + }); + 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); - AssemblyCompanyAttribute companyAttribute = Attribute.GetCustomAttribute(entryAssembly, typeof(AssemblyCompanyAttribute)) as AssemblyCompanyAttribute; - if (companyAttribute == null || IsNullOrEmpty(softwareName)) + softwareName = GetFileNameWithoutExtension(entryAssembly.Location); + if (!(GetCustomAttribute(entryAssembly, typeof(AssemblyCompanyAttribute)) is + AssemblyCompanyAttribute companyAttribute) || IsNullOrEmpty(softwareName)) + { return false; + } + string companyName = companyAttribute.Company; if (companyName == null) return false; - settingsLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.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); @@ -408,7 +493,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; } @@ -418,35 +503,43 @@ 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) { // If the user.config file is corrupt then it will throw a ConfigurationErrorsException - // Fortunately we can still gets it path from the exception. + // Fortunately we can still get its path from the exception. return ex.Filename; } } + 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 string ExtractSetting(string current, XDocument doc, string name) + internal 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) @@ -456,16 +549,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; } @@ -475,12 +572,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 { @@ -502,8 +607,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; } @@ -511,7 +617,6 @@ private void ReportIpAddressOfThisMachineAsync() TrackWithApplicationProperties("Launch", launchProperties); }; client.DownloadDataAsync(uri); - } catch (Exception) { @@ -523,25 +628,12 @@ private void ReportIpAddressOfThisMachineAsync() private bool AddGeolocationProperty(JObject j, string primary, string secondary = null) { var value = j.GetValue(primary)?.ToString(); - if (!IsNullOrWhiteSpace(value)) - { - s_locationInfo.Add(primary, value); - _propertiesThatGoWithEveryEvent.Add(primary, value); - return true; - } + if (IsNullOrWhiteSpace(value)) + return secondary != null && AddGeolocationProperty(j, secondary); - return secondary != null && AddGeolocationProperty(j, secondary); - } - - 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"]); - } + s_locationInfo.Add(primary, value); + _propertiesThatGoWithEveryEvent.Add(primary, value); + return true; } /// @@ -610,12 +702,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 +715,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 +724,54 @@ 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 => s_allowTracking; + set + { + if (value == s_allowTracking) + return; + + // 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) + { + 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; + } + } + + private static bool s_allowTracking; #region OSVersion class Version @@ -675,6 +788,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 && @@ -685,7 +799,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,30 +819,27 @@ 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 [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); @@ -737,8 +848,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; @@ -768,7 +881,7 @@ public static string GetWindowsVersionInfoFromNetworkAPI() } else if (info._majorVersion == 10 && info._minorVersion == 0) { - windowsVersion = "Windows 10"; + windowsVersion = "Windows 10"; } else { @@ -781,12 +894,13 @@ public static string GetWindowsVersionInfoFromNetworkAPI() [DllImport("libc")] static extern int uname(IntPtr buf); + private static string s_unixName; private static string UnixName { get { - if (Environment.OSVersion.Platform != PlatformID.Unix) + if (OSVersion.Platform != PlatformID.Unix) return Empty; if (s_unixName == null) { @@ -812,50 +926,42 @@ 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) + return s_linuxVersion; + + s_linuxVersion = DetermineLinuxVersion(); + return s_linuxVersion; + } + } + + private static string DetermineLinuxVersion() + { + var candidates = new[] + { + (FilePath: "/etc/wasta-release", Prefix: "DESCRIPTION=\""), + (FilePath: "/etc/lsb-release", Prefix: "DISTRIB_DESCRIPTION=\"") + }; + + foreach (var candidate in candidates) + { + if (File.Exists(candidate.FilePath)) { - _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=\"")) - { - _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=\"")) - { - _linuxVersion = t.Substring(21).Trim('"'); - break; - } - } - } - else - { - // If it's linux, it really should have /etc/lsb-release! - _linuxVersion = Environment.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 _linuxVersion; } + + // Fallback, but if it's linux, it really should have /etc/lsb-release! + return OSVersion.VersionString; } /// @@ -866,15 +972,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,13 +992,14 @@ private static string DesktopEnvironment currentDesktop = "Gnome"; } if (IsNullOrEmpty(currentDesktop)) - currentDesktop = Environment.GetEnvironmentVariable("GDMSESSION") ?? Empty; + currentDesktop = GetEnvironmentVariable("GDMSESSION") ?? Empty; } return currentDesktop.ToLowerInvariant(); } } private static string s_linuxDesktop; + /// /// Get the currently running desktop environment (like Unity, Gnome shell etc) /// @@ -900,25 +1007,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 ?? new Statistics(0, 0, 0); /// /// All calls to Client.Track should run through here so we can provide defaults for every event @@ -926,18 +1033,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) { - 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,23 +1065,24 @@ 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); } 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 public static void FlushClient() { - s_singleton.Client.Flush(); + s_singleton._client?.Flush(); } } } 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 + } +} diff --git a/src/SampleApp/Program.cs b/src/SampleApp/Program.cs index 33c6e87..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,7 +60,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 = !Analytics.AllowTracking; + Analytics.Track("Should not be tracked"); + Debug.WriteLine("Sleeping for 2 seconds just for fun"); + Thread.Sleep(2000); + + 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..."); Thread.Sleep(20000); diff --git a/src/SampleAppWithForm/Program.cs b/src/SampleAppWithForm/Program.cs index d853109..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; @@ -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; } @@ -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 +}