Browse Source

Merge pull request #635 from PixiEditor/analytics-platform

Send OS to analytics and recover analytics session after crash
Krzysztof Krysiński 11 months ago
parent
commit
a8ec46771d

+ 43 - 0
src/PixiEditor.Linux/LinuxOperatingSystem.cs

@@ -1,4 +1,5 @@
 using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
 using Avalonia.Threading;
 using PixiEditor.OperatingSystem;
 
@@ -7,6 +8,8 @@ namespace PixiEditor.Linux;
 public sealed class LinuxOperatingSystem : IOperatingSystem
 {
     public string Name { get; } = "Linux";
+    public string AnalyticsId => "Linux";
+    public string AnalyticsName => LinuxOSInformation.FromReleaseFile().ToString();
     public IInputKeys InputKeys { get; }
     public IProcessUtility ProcessUtility { get; }
     
@@ -24,4 +27,44 @@ public sealed class LinuxOperatingSystem : IOperatingSystem
     {
         return true;
     }
+
+    class LinuxOSInformation
+    {
+        const string FilePath = "/etc/os-release";
+        
+        private LinuxOSInformation(string? name, string? version, bool available)
+        {
+            Name = name;
+            Version = version;
+            Available = available;
+        }
+
+        public static LinuxOSInformation FromReleaseFile()
+        {
+            if (!File.Exists(FilePath))
+            {
+                return new LinuxOSInformation(null, null, false);
+            }
+            
+            // Parse /etc/os-release file (e.g. 'NAME="Ubuntu"')
+            var lines = File.ReadAllLines(FilePath).Select<string, (string Key, string Value)>(x =>
+            {
+                var separatorIndex = x.IndexOf('=');
+                return (x[..separatorIndex], x[(separatorIndex + 1)..]);
+            }).ToList();
+            
+            var name = lines.FirstOrDefault(x => x.Key == "NAME").Value.Trim('"');
+            var version = lines.FirstOrDefault(x => x.Key == "VERSION").Value.Trim('"');
+            
+            return new LinuxOSInformation(name, version, true);
+        }
+        
+        public bool Available { get; }
+        
+        public string? Name { get; private set; }
+        
+        public string? Version { get; private set; }
+
+        public override string ToString() => $"{Name} {Version}";
+    }
 }

+ 3 - 0
src/PixiEditor.MacOs/MacOperatingSystem.cs

@@ -7,6 +7,9 @@ namespace PixiEditor.MacOs;
 public sealed class MacOperatingSystem : IOperatingSystem
 {
     public string Name { get; } = "MacOS";
+
+    public string AnalyticsId => "macOS";
+    
     public IInputKeys InputKeys { get; }
     public IProcessUtility ProcessUtility { get; }
     public void OpenUri(string uri)

+ 3 - 0
src/PixiEditor.OperatingSystem/IOperatingSystem.cs

@@ -8,6 +8,9 @@ public interface IOperatingSystem
     public static IOperatingSystem Current { get; protected set; }
     public string Name { get; }
 
+    public virtual string AnalyticsName => Environment.OSVersion.ToString();
+    public string AnalyticsId { get; }
+
     public IInputKeys InputKeys { get; }
     public IProcessUtility ProcessUtility { get; }
 

+ 3 - 0
src/PixiEditor.Windows/WindowsOperatingSystem.cs

@@ -9,6 +9,9 @@ namespace PixiEditor.Windows;
 public sealed class WindowsOperatingSystem : IOperatingSystem
 {
     public string Name => "Windows";
+    
+    public string AnalyticsId => "Windows";
+    
     public IInputKeys InputKeys { get; } = new WindowsInputKeys();
     public IProcessUtility ProcessUtility { get; } = new WindowsProcessUtility();
     

+ 7 - 0
src/PixiEditor/Exceptions/CommandInvocationException.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Exceptions;
+
+public class CommandInvocationException : Exception
+{
+    public CommandInvocationException(string commandName, Exception? innerException = null) : 
+        base($"Command '{commandName}' threw an exception", innerException) { }
+}

+ 12 - 3
src/PixiEditor/Helpers/CrashHelper.cs

@@ -4,6 +4,7 @@ using System.IO;
 using System.Net.Http;
 using System.Runtime.CompilerServices;
 using System.Text;
+using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using ByteSizeLib;
 using Hardware.Info;
@@ -12,7 +13,7 @@ using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Helpers;
 
-internal class CrashHelper
+internal partial class CrashHelper
 {
     private readonly IHardwareInfo hwInfo;
 
@@ -84,7 +85,7 @@ internal class CrashHelper
             .AppendLine("\n-------Crash message-------")
             .Append(e.GetType().ToString())
             .Append(": ")
-            .AppendLine(e.Message);
+            .AppendLine(TrimFilePaths(e.Message));
         {
             var innerException = e.InnerException;
             while (innerException != null)
@@ -93,7 +94,7 @@ internal class CrashHelper
                     .Append("\n-----Inner exception-----\n")
                     .Append(innerException.GetType().ToString())
                     .Append(": ")
-                    .Append(innerException.Message);
+                    .Append(TrimFilePaths(innerException.Message));
                 innerException = innerException.InnerException;
             }
         }
@@ -112,6 +113,8 @@ internal class CrashHelper
             }
         }
     }
+
+    private static string TrimFilePaths(string text) => FilePathRegex().Replace(text, "{{ FILE PATH }}");
     
     public static void SendExceptionInfoToWebhook(Exception e, bool wait = false,
         [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
@@ -156,4 +159,10 @@ internal class CrashHelper
         }
         catch { }
     }
+
+    /// <summary>
+    /// Matches file paths with spaces when in quotes, otherwise not
+    /// </summary>
+    [GeneratedRegex(@"'([^']*[\/\\][^']*)'|(\S*[\/\\]\S*)")]
+    private static partial Regex FilePathRegex();
 }

+ 1 - 0
src/PixiEditor/Models/AnalyticsAPI/AnalyticEventTypes.cs

@@ -12,6 +12,7 @@ public class AnalyticEventTypes
     public static string GeneralCommand { get; } = GetEventType("GeneralCommand");
     public static string SwitchTool { get; } = GetEventType("SwitchTool");
     public static string UseTool { get; } = GetEventType("UseTool");
+    public static string ResumeSession { get; } = GetEventType("ResumeSession");
 
     private static string GetEventType(string value) => $"PixiEditor.{value}";
 }

+ 8 - 2
src/PixiEditor/Models/AnalyticsAPI/AnalyticSessionInfo.cs

@@ -1,8 +1,14 @@
-namespace PixiEditor.Models.AnalyticsAPI;
+using PixiEditor.OperatingSystem;
 
-public class AnalyticSessionInfo
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class AnalyticSessionInfo(IOperatingSystem os)
 {
     public Version Version { get; set; }
 
     public string BuildId { get; set; }
+
+    public string? PlatformId { get; set; } = os.AnalyticsId;
+
+    public string? PlatformName { get; set; } = os.AnalyticsName;
 }

+ 4 - 2
src/PixiEditor/Models/AnalyticsAPI/AnalyticsClient.cs

@@ -6,6 +6,7 @@ using System.Text.Json.Serialization;
 using PixiEditor.Helpers;
 using PixiEditor.Models.Input;
 using PixiEditor.Numerics;
+using PixiEditor.OperatingSystem;
 
 namespace PixiEditor.Models.AnalyticsAPI;
 
@@ -31,9 +32,10 @@ public class AnalyticsClient
 
     public async Task<Guid?> CreateSessionAsync(CancellationToken cancellationToken = default)
     {
-        var session = new AnalyticSessionInfo()
+        var session = new AnalyticSessionInfo(IOperatingSystem.Current)
         {
-            Version = VersionHelpers.GetCurrentAssemblyVersion(), BuildId = VersionHelpers.GetBuildId()
+            Version = VersionHelpers.GetCurrentAssemblyVersion(),
+            BuildId = VersionHelpers.GetBuildId(),
         };
         
         var response = await _client.PostAsJsonAsync("sessions/new", session, _options, cancellationToken);

+ 25 - 7
src/PixiEditor/Models/AnalyticsAPI/AnalyticsPeriodicReporter.cs

@@ -5,6 +5,7 @@ namespace PixiEditor.Models.AnalyticsAPI;
 public class AnalyticsPeriodicReporter
 {
     private int _sendExceptions = 0;
+    private bool _resumeSession;
     
     private readonly SemaphoreSlim _semaphore = new(1, 1);
     private readonly AnalyticsClient _client;
@@ -28,8 +29,16 @@ public class AnalyticsPeriodicReporter
         _client = client;
     }
 
-    public void Start()
+    public void Start(Guid? sessionId)
     {
+        if (sessionId != null)
+        {
+            SessionId = sessionId.Value;
+            _resumeSession = true;
+            
+            _backlog.Add(new AnalyticEvent { Time = DateTime.UtcNow, EventType = AnalyticEventTypes.ResumeSession });
+        }
+
         Task.Run(RunAsync);
     }
 
@@ -42,6 +51,12 @@ public class AnalyticsPeriodicReporter
 
     public void AddEvent(AnalyticEvent value)
     {
+        // Don't send startup as it gives invalid results for crash resumed sessions
+        if (value.EventType == AnalyticEventTypes.Startup && _resumeSession)
+        {
+            return;
+        }
+        
         Task.Run(() =>
         {
             _semaphore.Wait();
@@ -59,14 +74,17 @@ public class AnalyticsPeriodicReporter
 
     private async Task RunAsync()
     {
-        var createSession = await _client.CreateSessionAsync(_cancellationToken.Token);
-
-        if (!createSession.HasValue)
+        if (!_resumeSession)
         {
-            return;
-        }
+            var createSession = await _client.CreateSessionAsync(_cancellationToken.Token);
+
+            if (!createSession.HasValue)
+            {
+                return;
+            }
 
-        SessionId = createSession.Value;
+            SessionId = createSession.Value;
+        }
 
         Task.Run(RunHeartbeatAsync);
 

+ 12 - 4
src/PixiEditor/Models/Commands/CommandController.cs

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
 using Avalonia.Media;
 using Microsoft.Extensions.DependencyInjection;
 using Newtonsoft.Json;
+using PixiEditor.Exceptions;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Models.AnalyticsAPI;
@@ -376,10 +377,17 @@ internal class CommandController
         {
             Analytics.SendCommand(name, (parameter as CommandExecutionContext)?.SourceInfo);
         }
-                
-        object result = method.Invoke(instance, parameters);
-        if (result is Task task)
-            task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+
+        try
+        {
+            object result = method.Invoke(instance, parameters);
+            if (result is Task task)
+                task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+        }
+        catch (TargetInvocationException e)
+        {
+            throw new CommandInvocationException(name, e);
+        }
 
         return;
 

+ 15 - 11
src/PixiEditor/Models/ExceptionHandling/CrashReport.cs

@@ -12,6 +12,7 @@ using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
 using PixiEditor.Helpers;
+using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels;
@@ -115,6 +116,8 @@ internal class CrashReport : IDisposable
         builder
             .AppendLine("Environment:")
             .AppendLine($"  Thread Count: {GetFormatted(() => Process.GetCurrentProcess().Threads.Count)}")
+            .AppendLine("Analytics:")
+            .AppendLine($"  Analytics Id: {GetFormatted(() => AnalyticsPeriodicReporter.Instance?.SessionId)}")
             .AppendLine("\nCulture:")
             .AppendLine($"  Selected language: {GetPreferenceFormatted("LanguageCode", true, "system")}")
             .AppendLine($"  Current Culture: {GetFormatted(() => CultureInfo.CurrentCulture)}")
@@ -267,15 +270,16 @@ internal class CrashReport : IDisposable
 
     public int GetDocumentCount() => ZipFile.Entries.Where(x => x.FullName.EndsWith(".pixi")).Count();
 
-    public bool TryRecoverDocuments(out List<RecoveredPixi> list)
+    public bool TryRecoverDocuments(out List<RecoveredPixi> list, out CrashedSessionInfo? sessionInfo)
     {
         try
         {
-            list = RecoverDocuments();
+            list = RecoverDocuments(out sessionInfo);
         }
         catch (Exception e)
         {
             list = null;
+            sessionInfo = null;
             CrashHelper.SendExceptionInfoToWebhook(e);
             return false;
         }
@@ -283,12 +287,12 @@ internal class CrashReport : IDisposable
         return true;
     }
 
-    public List<RecoveredPixi> RecoverDocuments()
+    public List<RecoveredPixi> RecoverDocuments(out CrashedSessionInfo? sessionInfo)
     {
         List<RecoveredPixi> recoveredDocuments = new();
 
-        var paths = TryGetOriginalPaths();
-        if (paths == null)
+        sessionInfo = TryGetSessionInfo();
+        if (sessionInfo?.OpenedDocuments == null)
         {
             recoveredDocuments.AddRange(
                 ZipFile.Entries
@@ -300,11 +304,11 @@ internal class CrashReport : IDisposable
             return recoveredDocuments;
         }
 
-        recoveredDocuments.AddRange(paths.Select(path => new RecoveredPixi(path.Value, ZipFile.GetEntry($"Documents/{path.Key}"))));
+        recoveredDocuments.AddRange(sessionInfo.OpenedDocuments.Select(path => new RecoveredPixi(path.OriginalPath, ZipFile.GetEntry($"Documents/{path.ZipName}"))));
 
         return recoveredDocuments;
 
-        Dictionary<string, string>? TryGetOriginalPaths()
+        CrashedSessionInfo? TryGetSessionInfo()
         {
             var originalPathsEntry = ZipFile.Entries.FirstOrDefault(entry => entry.FullName == "DocumentInfo.json");
 
@@ -317,7 +321,7 @@ internal class CrashReport : IDisposable
                 using var reader = new StreamReader(stream);
                 string json = reader.ReadToEnd();
 
-                return JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
+                return JsonConvert.DeserializeObject<CrashedSessionInfo>(json);
             }
             catch
             {
@@ -373,7 +377,7 @@ internal class CrashReport : IDisposable
 
         // Write the documents into zip
         int counter = 0;
-        var originalPaths = new Dictionary<string, string>();
+        var originalPaths = new List<CrashedFileInfo>();
         //TODO: Implement
         foreach (var document in documents)
         {
@@ -389,7 +393,7 @@ internal class CrashReport : IDisposable
                 using Stream documentStream = archive.CreateEntry($"Documents/{nameInZip}").Open();
                 documentStream.Write(serialized);
 
-                originalPaths.Add(nameInZip, document.FullFilePath);
+                originalPaths.Add(new CrashedFileInfo(nameInZip, document.FullFilePath));
             }
             catch { }
             counter++;
@@ -400,7 +404,7 @@ internal class CrashReport : IDisposable
             using Stream jsonStream = archive.CreateEntry("DocumentInfo.json").Open();
             using StreamWriter writer = new StreamWriter(jsonStream);
 
-            string originalPathsJson = JsonConvert.SerializeObject(originalPaths, Formatting.Indented);
+            string originalPathsJson = JsonConvert.SerializeObject(new CrashedSessionInfo(AnalyticsPeriodicReporter.Instance?.SessionId ?? Guid.Empty, originalPaths), Formatting.Indented);
             writer.Write(originalPathsJson);
         }
     }

+ 16 - 0
src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs

@@ -0,0 +1,16 @@
+namespace PixiEditor.Models.ExceptionHandling;
+
+public class CrashedFileInfo
+{
+    public string ZipName { get; set; }
+    
+    public string OriginalPath { get; set; }
+    
+    public CrashedFileInfo() { }
+
+    public CrashedFileInfo(string zipName, string originalPath)
+    {
+        ZipName = zipName;
+        OriginalPath = originalPath;
+    }
+}

+ 18 - 0
src/PixiEditor/Models/ExceptionHandling/CrashedSessionInfo.cs

@@ -0,0 +1,18 @@
+namespace PixiEditor.Models.ExceptionHandling;
+
+public class CrashedSessionInfo
+{
+    public Guid? AnalyticsSessionId { get; set; }
+    
+    public ICollection<CrashedFileInfo>? OpenedDocuments { get; set; }
+
+    public CrashedSessionInfo()
+    {
+    }
+
+    public CrashedSessionInfo(Guid? analyticsSessionId, ICollection<CrashedFileInfo> openedDocuments)
+    {
+        AnalyticsSessionId = analyticsSessionId;
+        OpenedDocuments = openedDocuments;
+    }
+}

+ 9 - 9
src/PixiEditor/Views/MainWindow.axaml.cs

@@ -55,7 +55,7 @@ internal partial class MainWindow : Window
         }
     }
 
-    public MainWindow(ExtensionLoader extensionLoader)
+    public MainWindow(ExtensionLoader extensionLoader, Guid? analyticsSessionId = null)
     {
         StartupPerformance.ReportToMainWindow();
         
@@ -83,7 +83,7 @@ internal partial class MainWindow : Window
         StartupPerformance.ReportToMainViewModel();
 
         var analytics = services.GetService<AnalyticsPeriodicReporter>();
-        analytics?.Start();
+        analytics?.Start(analyticsSessionId);
         
         InitializeComponent();
     }
@@ -115,15 +115,15 @@ internal partial class MainWindow : Window
 
     public static MainWindow CreateWithRecoveredDocuments(CrashReport report, out bool showMissingFilesDialog)
     {
-        var window = GetMainWindow();
-        var fileVM = window.services.GetRequiredService<FileViewModel>();
-
-        if (!report.TryRecoverDocuments(out var documents))
+        if (!report.TryRecoverDocuments(out var documents, out var sessionInfo))
         {
             showMissingFilesDialog = true;
-            return window;
+            return GetMainWindow(null);
         }
 
+        var window = GetMainWindow(sessionInfo?.AnalyticsSessionId);
+        var fileVM = window.services.GetRequiredService<FileViewModel>();
+
         var i = 0;
 
         foreach (var document in documents)
@@ -143,13 +143,13 @@ internal partial class MainWindow : Window
 
         return window;
 
-        MainWindow GetMainWindow()
+        MainWindow GetMainWindow(Guid? analyticsSession)
         {
             try
             {
                 var app = (App)Application.Current;
                 ClassicDesktopEntry entry = new(app.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime);
-                return new MainWindow(entry.InitApp());
+                return new MainWindow(entry.InitApp(), analyticsSession);
             }
             catch (Exception e)
             {