Browse Source

Merge pull request #713 from PixiEditor/crash-report-api

Crash report Analytics API
Krzysztof Krysiński 7 months ago
parent
commit
46046530df

+ 45 - 9
src/PixiEditor/Helpers/CrashHelper.cs

@@ -8,6 +8,7 @@ using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using ByteSizeLib;
 using Hardware.Info;
+using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.ExceptionHandling;
 using PixiEditor.ViewModels.Document;
 
@@ -28,7 +29,7 @@ internal partial class CrashHelper
             exception = new AggregateException(exception, e);
         }
         
-        var report = CrashReport.Generate(exception);
+        var report = CrashReport.Generate(exception, null);
         report.TrySave(documents);
         report.RestartToCrashReport();
     }
@@ -38,11 +39,18 @@ internal partial class CrashHelper
         hwInfo = new HardwareInfo();
     }
 
-    public void GetCPUInformation(StringBuilder builder)
+    public void GetCPUInformation(StringBuilder builder, ApiCrashReport report)
     {
         builder.AppendLine("CPU:");
         hwInfo.RefreshCPUList(false);
 
+        report.SystemInformation["CPUs"] = hwInfo.CpuList.Select(x => new
+        {
+            x.Name,
+            SpeedGhz = (x.CurrentClockSpeed / 1000f).ToString("F2", CultureInfo.InvariantCulture),
+            MaxSpeedGhz = (x.MaxClockSpeed / 1000f).ToString("F2", CultureInfo.InvariantCulture)
+        });
+        
         foreach (var processor in hwInfo.CpuList)
         {
             builder
@@ -53,11 +61,17 @@ internal partial class CrashHelper
         }
     }
 
-    public void GetGPUInformation(StringBuilder builder)
+    public void GetGPUInformation(StringBuilder builder, ApiCrashReport report)
     {
         builder.AppendLine("GPU:");
         hwInfo.RefreshVideoControllerList();
 
+        report.SystemInformation["GPUs"] = hwInfo.VideoControllerList.Select(x => new
+        {
+            x.Name,
+            x.DriverVersion
+        });
+        
         foreach (var gpu in hwInfo.VideoControllerList)
         {
             builder
@@ -67,13 +81,19 @@ internal partial class CrashHelper
         }
     }
 
-    public void GetMemoryInformation(StringBuilder builder)
+    public void GetMemoryInformation(StringBuilder builder, ApiCrashReport report)
     {
         builder.AppendLine("Memory:");
         hwInfo.RefreshMemoryStatus();
 
         var memInfo = hwInfo.MemoryStatus;
 
+        report.SystemInformation["Memory"] = new
+        {
+            memInfo.AvailablePhysical,
+            memInfo.TotalPhysical
+        };
+        
         builder
             .AppendLine($"  Available: {new ByteSize(memInfo.AvailablePhysical).ToString("", CultureInfo.InvariantCulture)}")
             .AppendLine($"  Total: {new ByteSize(memInfo.TotalPhysical).ToString("", CultureInfo.InvariantCulture)}");
@@ -114,26 +134,30 @@ internal partial class CrashHelper
         }
     }
 
-    private static string TrimFilePaths(string text) => FilePathRegex().Replace(text, "{{ FILE PATH }}");
+    public static string TrimFilePaths(string text) => FilePathRegex().Replace(text, "{{ FILE PATH }}");
     
-    public static void SendExceptionInfoToWebhook(Exception e, bool wait = false,
+    public static void SendExceptionInfo(Exception e, bool wait = false,
         [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
     {
         // TODO: quadruple check that this Task.Run is actually acceptable here
         // I think it might not be because there is stuff about the main window in the crash report, so Avalonia is touched from a different thread (is it bad for avalonia?)
-        var task = Task.Run(() => SendExceptionInfoToWebhookAsync(e, filePath, memberName));
+        var task = Task.Run(() => SendExceptionInfoAsync(e, filePath, memberName));
         if (wait)
         {
             task.Wait();
         }
     }
 
-    public static async Task SendExceptionInfoToWebhookAsync(Exception e, [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
+    public static async Task SendExceptionInfoAsync(Exception e, [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
     {
         // TODO: Proper DebugBuild checking
         /*if (DebugViewModel.IsDebugBuild)
             return;*/
-        await SendReportTextToWebhookAsync(CrashReport.Generate(e), $"{filePath}; Method {memberName}");
+
+        var report = CrashReport.Generate(e, new NonCrashInfo(filePath, memberName));
+        
+        await SendReportTextToWebhookAsync(report, $"{filePath}; Method {memberName}");
+        await SendReportToAnalyticsApiAsync(report);
     }
 
     public static async Task SendReportTextToWebhookAsync(CrashReport report, string catchLocation = null)
@@ -160,6 +184,18 @@ internal partial class CrashHelper
         catch { }
     }
 
+    public static async Task SendReportToAnalyticsApiAsync(CrashReport report)
+    {
+        if (AnalyticsClient.GetAnalyticsUrl() is not { } analyticsUrl)
+        {
+            return;
+        }
+        
+        using var analyticsClient = new AnalyticsClient(analyticsUrl);
+
+        await analyticsClient.SendReportAsync(report.ApiReportJson);
+    }
+
     /// <summary>
     /// Matches file paths with spaces when in quotes, otherwise not
     /// </summary>

+ 1 - 13
src/PixiEditor/Helpers/ServiceCollectionHelpers.cs

@@ -132,13 +132,7 @@ internal static class ServiceCollectionHelpers
 
     private static IServiceCollection AddAnalyticsAsNeeded(this IServiceCollection collection)
     {
-        string url = BuildConstants.AnalyticsUrl;
-
-        if (url == "${analytics-url}")
-        {
-            url = null;
-            SetDebugUrl(ref url);
-        }
+        var url = AnalyticsClient.GetAnalyticsUrl();
 
         if (!string.IsNullOrWhiteSpace(url))
         {
@@ -148,12 +142,6 @@ internal static class ServiceCollectionHelpers
         }
 
         return collection;
-
-        [Conditional("DEBUG")]
-        static void SetDebugUrl(ref string? url)
-        {
-            url = Environment.GetEnvironmentVariable("PixiEditorAnalytics");
-        }
     }
     
     private static IServiceCollection AddAssemblyTypes<T>(this IServiceCollection collection)

+ 1 - 1
src/PixiEditor/Initialization/ClassicDesktopEntry.cs

@@ -51,7 +51,7 @@ internal class ClassicDesktopEntry
             {
                 try
                 {
-                    CrashHelper.SendExceptionInfoToWebhook(exception, true);
+                    CrashHelper.SendExceptionInfo(exception, true);
                 }
                 finally
                 {

+ 38 - 3
src/PixiEditor/Models/AnalyticsAPI/AnalyticsClient.cs

@@ -1,7 +1,11 @@
-using System.Globalization;
+using System.Diagnostics;
+using System.Globalization;
 using System.Net;
+using System.Net.Http.Headers;
 using System.Net.Http.Json;
+using System.Net.Mime;
 using System.Reflection;
+using System.Text;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using PixiEditor.Helpers;
@@ -11,7 +15,7 @@ using PixiEditor.OperatingSystem;
 
 namespace PixiEditor.Models.AnalyticsAPI;
 
-public class AnalyticsClient
+public class AnalyticsClient : IDisposable
 {
     private readonly HttpClient _client = new();
 
@@ -85,9 +89,35 @@ public class AnalyticsClient
         await _client.DeleteAsync($"sessions/{sessionId}", cancellationToken);
     }
 
+    public async Task SendReportAsync(string jsonText)
+    {
+        var textContent = new StringContent(jsonText, Encoding.UTF8, MediaTypeNames.Application.Json);
+        
+        await _client.PostAsync("reports/new", textContent);
+    }
+
+    public static string? GetAnalyticsUrl()
+    {
+        string url = BuildConstants.AnalyticsUrl;
+
+        if (url == "${analytics-url}")
+        {
+            url = null;
+            SetDebugUrl(ref url);
+        }
+
+        return url;
+
+        [Conditional("DEBUG")]
+        static void SetDebugUrl(ref string? url)
+        {
+            url = Environment.GetEnvironmentVariable("PixiEditorAnalytics");
+        }
+    }
+    
     private static async Task ReportInvalidStatusCodeAsync(HttpStatusCode statusCode, string message)
     {
-        await CrashHelper.SendExceptionInfoToWebhookAsync(new InvalidOperationException($"Invalid status code from analytics API '{statusCode}', message: {message}"));
+        await CrashHelper.SendExceptionInfoAsync(new InvalidOperationException($"Invalid status code from analytics API '{statusCode}', message: {message}"));
     }
 
     class KeyCombinationConverter : JsonConverter<KeyCombination>
@@ -129,4 +159,9 @@ public class AnalyticsClient
             writer.WriteStringValue(value.ToString());
         }
     }
+
+    public void Dispose()
+    {
+        _client.Dispose();
+    }
 }

+ 1 - 1
src/PixiEditor/Models/AnalyticsAPI/AnalyticsPeriodicReporter.cs

@@ -192,7 +192,7 @@ public class AnalyticsPeriodicReporter
     {
         if (_sendExceptions > 6)
         {
-            await CrashHelper.SendExceptionInfoToWebhookAsync(e);
+            await CrashHelper.SendExceptionInfoAsync(e);
             _sendExceptions++;
         }
     }

+ 26 - 0
src/PixiEditor/Models/AnalyticsAPI/ApiCrashReport.cs

@@ -0,0 +1,26 @@
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class ApiCrashReport
+{
+    public DateTime ProcessStart { get; set; }
+
+    public DateTime ReportTime { get; set; }
+
+    public Guid? SessionId { get; set; }
+
+    public bool IsCrash { get; set; }
+
+    public string CatchLocation { get; set; }
+
+    public string CatchMethod { get; set; }
+
+    public Version Version { get; set; }
+
+    public string BuildId { get; set; }
+
+    public Dictionary<string, object> SystemInformation { get; set; } = [];
+
+    public Dictionary<string, object> StateInformation { get; set; } = [];
+
+    public ExceptionDetails Exception { get; set; }
+}

+ 53 - 0
src/PixiEditor/Models/AnalyticsAPI/ExceptionDetails.cs

@@ -0,0 +1,53 @@
+using PixiEditor.Helpers;
+
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class ExceptionDetails
+{
+    /// <summary>
+    /// The fully qualified type name of the exception.
+    /// </summary>
+    public string ExceptionType { get; set; }
+    
+    /// <summary>
+    /// The exception message.
+    /// </summary>
+    public string Message { get; set; }
+    
+    /// <summary>
+    /// The exception stack trace.
+    /// </summary>
+    public string StackTrace { get; set; }
+    
+    /// <summary>
+    /// A collection of <see cref="ExceptionDetails"/> that represent inner or aggregated exceptions.
+    /// </summary>
+    public List<ExceptionDetails> InnerExceptions { get; set; }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ExceptionDetails"/> class
+    /// from the specified <see cref="Exception"/>.
+    /// </summary>
+    /// <param name="ex">The exception to capture details from.</param>
+    public ExceptionDetails(Exception ex)
+    {
+        ArgumentNullException.ThrowIfNull(ex);
+
+        ExceptionType = ex.GetType().FullName;
+        Message = CrashHelper.TrimFilePaths(ex.Message);
+        StackTrace = ex.StackTrace;
+        InnerExceptions = [];
+
+        if (ex is AggregateException aggEx)
+        {
+            foreach (var innerException in aggEx.InnerExceptions)
+            {
+                InnerExceptions.Add(new ExceptionDetails(innerException));
+            }
+        }
+        else if (ex.InnerException != null)
+        {
+            InnerExceptions.Add(new ExceptionDetails(ex.InnerException));
+        }
+    }
+}

+ 5 - 0
src/PixiEditor/Models/ExceptionHandling/CrashInfoCollectionException.cs

@@ -0,0 +1,5 @@
+namespace PixiEditor.Models.ExceptionHandling;
+
+public class CrashInfoCollectionException(string collecting, Exception caught) : Exception($"Caught {caught.GetType().FullName} while collecting {collecting}.", caught)
+{
+}

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

@@ -14,6 +14,7 @@ using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
 using PixiEditor.Helpers;
 using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands;
+using PixiEditor.OperatingSystem;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels;
 using PixiEditor.ViewModels.Document;
@@ -24,14 +25,54 @@ namespace PixiEditor.Models.ExceptionHandling;
 #nullable enable
 internal class CrashReport : IDisposable
 {
-    public static CrashReport Generate(Exception exception)
+    public static CrashReport Generate(Exception exception, NonCrashInfo? nonCrashInfo)
     {
+        var apiReport = new ApiCrashReport();
         StringBuilder builder = new();
         DateTimeOffset currentTime = DateTimeOffset.Now;
+        var processStartTime = Process.GetCurrentProcess().StartTime;
+
+        apiReport.Version = VersionHelpers.GetCurrentAssemblyVersion();
+        apiReport.BuildId = VersionHelpers.GetBuildId();
+        
+        apiReport.ReportTime = currentTime.UtcDateTime;
+        apiReport.ProcessStart = processStartTime.ToUniversalTime();
+        apiReport.IsCrash = nonCrashInfo == null;
+
+        if (nonCrashInfo != null)
+        {
+            apiReport.CatchLocation = nonCrashInfo.CatchLocation;
+            apiReport.CatchMethod = nonCrashInfo.CatchMember;
+        }
+
+        try
+        {
+            var os = IOperatingSystem.Current;
+
+            apiReport.SystemInformation["PlatformId"] = os.AnalyticsId;
+            apiReport.SystemInformation["PlatformName"] = os.AnalyticsName;
+        }
+        catch (Exception e)
+        {
+            exception = new AggregateException(exception, new CrashInfoCollectionException("OS Information", e));
+        }
+
+        try
+        {
+            var sessionId = AnalyticsPeriodicReporter.Instance?.SessionId;
+
+            if (sessionId == Guid.Empty) sessionId = null;
+            
+            apiReport.SessionId = sessionId;
+        }
+        catch (Exception e)
+        {
+            exception = new AggregateException(exception, new CrashInfoCollectionException("Session Id", e));
+        }
 
         builder
             .AppendLine($"PixiEditor {VersionHelpers.GetCurrentAssemblyVersionString(moreSpecific: true)} x{IntPtr.Size * 8} crashed on {currentTime:yyyy.MM.dd} at {currentTime:HH:mm:ss} {currentTime:zzz}")
-            .AppendLine($"Application started {GetFormatted(() => Process.GetCurrentProcess().StartTime, "yyyy.MM.dd HH:hh:ss")}, {GetFormatted(() => DateTime.Now - Process.GetCurrentProcess().StartTime, @"d\ hh\:mm\.ss")} ago")
+            .AppendLine($"Application started {GetFormatted(() => processStartTime, "yyyy.MM.dd HH:hh:ss")}, {GetFormatted(() => currentTime - processStartTime, @"d\ hh\:mm\.ss")} ago")
             .AppendLine($"Report: {Guid.NewGuid()}\n")
             .AppendLine("-----System Information----")
             .AppendLine("General:")
@@ -40,7 +81,7 @@ internal class CrashReport : IDisposable
 
         CrashHelper helper = new();
 
-        AppendHardwareInfo(helper, builder);
+        AppendHardwareInfo(helper, builder, apiReport);
 
         builder.AppendLine("\n--------Command Log--------\n");
 
@@ -57,13 +98,15 @@ internal class CrashReport : IDisposable
 
         try
         {
-            AppendStateInfo(builder);
+            AppendStateInfo(builder, apiReport);
         }
         catch (Exception stateException)
         {
+            exception = new AggregateException(exception, new CrashInfoCollectionException("state information", stateException));
             builder.AppendLine($"Error ({stateException.GetType().FullName}: {stateException.Message}) while gathering state (Must be bug in GetPreferenceFormatted, GetFormatted or StringBuilder.AppendLine as these should not throw), skipping...");
         }
 
+        apiReport.Exception = new ExceptionDetails(exception);
         CrashHelper.AddExceptionMessage(builder, exception);
 
         string filename = $"crash-{currentTime:yyyy-MM-dd_HH-mm-ss_fff}.zip";
@@ -75,16 +118,27 @@ internal class CrashReport : IDisposable
 
         CrashReport report = new();
         report.FilePath = Path.Combine(path, filename);
+        try
+        {
+            report.ApiReportJson = System.Text.Json.JsonSerializer.Serialize(apiReport);
+        }
+        catch (Exception apiReportSerializationException)
+        {
+            // TODO: Handle this using the API once webhook reports are no longer a thing
+            builder.AppendLine($"-- API Report Json Exception --");
+            CrashHelper.AddExceptionMessage(builder, apiReportSerializationException);
+        }
+
         report.ReportText = builder.ToString();
 
         return report;
     }
 
-    private static void AppendHardwareInfo(CrashHelper helper, StringBuilder builder)
+    private static void AppendHardwareInfo(CrashHelper helper, StringBuilder builder, ApiCrashReport apiReport)
     {
         try
         {
-            helper.GetCPUInformation(builder);
+            helper.GetCPUInformation(builder, apiReport);
         }
         catch (Exception cpuE)
         {
@@ -93,7 +147,7 @@ internal class CrashReport : IDisposable
 
         try
         {
-            helper.GetGPUInformation(builder);
+            helper.GetGPUInformation(builder, apiReport);
         }
         catch (Exception gpuE)
         {
@@ -103,7 +157,7 @@ internal class CrashReport : IDisposable
         
         try
         {
-            helper.GetMemoryInformation(builder);
+            helper.GetMemoryInformation(builder, apiReport);
         }
         catch (Exception memE)
         {
@@ -111,7 +165,7 @@ internal class CrashReport : IDisposable
         }
 }
 
-    private static void AppendStateInfo(StringBuilder builder)
+    private static void AppendStateInfo(StringBuilder builder, ApiCrashReport apiReport)
     {
         builder
             .AppendLine("Environment:")
@@ -138,6 +192,43 @@ internal class CrashReport : IDisposable
             .AppendLine($"  Secondary Color: {GetFormattedFromViewModelMain(x => x.ColorsSubViewModel?.SecondaryColor)}")
             .Append("\nActive Document: ");
 
+        apiReport.StateInformation["Environment"] = new
+        {
+            ThreadCount = GetOrExceptionMessage(() => Process.GetCurrentProcess().Threads.Count)
+        };
+        
+        apiReport.StateInformation["Culture"] = new
+        {
+            SelectedLanguage = GetPreferenceFormatted("LanguageCode", true, "system"),
+            CurrentCulture = GetFormatted(() => CultureInfo.CurrentCulture),
+            CurrentUICulture = GetFormatted(() => CultureInfo.CurrentUICulture)
+        };
+
+        apiReport.StateInformation["Preferences"] = new
+        {
+            HasSharedToolbarEnabled = GetPreferenceFormatted("EnableSharedToolbar", true, false),
+            RightClickMode = GetPreferenceFormatted<RightClickMode>("RightClickMode", true),
+            HasRichPresenceEnabled = GetPreferenceOrExceptionMessage("EnableRichPresence", true, true),
+            DebugModeEnabled = GetPreferenceOrExceptionMessage("IsDebugModeEnabled", true, false)
+        };
+
+        apiReport.StateInformation["UI"] = new
+        {
+            MainWindowNotNull = GetOrExceptionMessage(() => MainWindow.Current != null),
+            MainWindowSize = GetOrExceptionMessage(() => GetSimplifiedRect(MainWindow.Current?.Bounds)),
+            MainWindowState = GetFormatted(() => MainWindow.Current?.WindowState)
+        };
+
+        apiReport.StateInformation["ViewModels"] = new
+        {
+            HasActiveUpdateableChange = GetOrExceptionMessage(() => ViewModelMain.Current?.DocumentManagerSubViewModel?.ActiveDocument?.BlockingUpdateableChangeActive),
+            CurrentTool = GetOrExceptionMessage(() => ViewModelMain.Current?.ToolsSubViewModel?.ActiveTool?.ToolName),
+            PrimaryColor = GetOrExceptionMessage(() => ViewModelMain.Current?.ColorsSubViewModel?.PrimaryColor.ToString()),
+            SecondaryColor = GetOrExceptionMessage(() => ViewModelMain.Current?.ColorsSubViewModel?.SecondaryColor.ToString())
+        };
+
+        apiReport.StateInformation["ActiveDocument"] = new { };
+
         try
         {
             AppendActiveDocumentInfo(builder);
@@ -146,6 +237,14 @@ internal class CrashReport : IDisposable
         {
             builder.AppendLine($"Could not get active document info:\n{e}");
         }
+
+        object GetSimplifiedRect(Avalonia.Rect? rect)
+        {
+            if (rect == null) return null;
+            var nonNull = rect.Value;
+
+            return new { Left = nonNull.Left, Top = nonNull.Top, Width = nonNull.Width, Height = nonNull.Height };
+        }
     }
 
     private static void AppendActiveDocumentInfo(StringBuilder builder)
@@ -186,6 +285,27 @@ internal class CrashReport : IDisposable
             .AppendLine($"  Updateable Change Active: {FormatObject(document.BlockingUpdateableChangeActive)}")
             .AppendLine($"  Transform: {FormatObject(document.TransformViewModel)}");
     }
+    
+    private static object GetPreferenceOrExceptionMessage<T>(string name, bool roaming, T defaultValue)
+    {        
+        try
+        {
+            var preferences = IPreferences.Current;
+
+            if (preferences == null)
+                return "{ Preferences are null }";
+
+            var value = roaming
+                ? preferences.GetPreference(name, defaultValue)
+                : preferences.GetLocalPreference(name, defaultValue);
+
+            return value;
+        }
+        catch (Exception e)
+        {
+            return $$"""{ Failed getting preference: {{e.Message}} }""";
+        }
+    }
 
     private static string GetPreferenceFormatted<T>(string name, bool roaming, T defaultValue = default, string? format = null)
     {
@@ -232,6 +352,18 @@ internal class CrashReport : IDisposable
         }
     }
 
+    private static object GetOrExceptionMessage<T>(Func<T?> getter)
+    {
+        try
+        {
+            return getter();
+        }
+        catch (Exception e)
+        {
+            return e.GetType().FullName;
+        }
+    }
+
     private static string FormatObject<T>(T? value, string? format = null)
     {
         return value switch
@@ -258,6 +390,7 @@ internal class CrashReport : IDisposable
 
         report.ZipFile = System.IO.Compression.ZipFile.Open(path, ZipArchiveMode.Read);
         report.ExtractReport();
+        report.ExtractJsonReport();
 
         return report;
     }
@@ -265,6 +398,8 @@ internal class CrashReport : IDisposable
     public string FilePath { get; set; }
 
     public string ReportText { get; set; }
+    
+    public string ApiReportJson { get; set; }
 
     private ZipArchive ZipFile { get; set; }
 
@@ -280,7 +415,7 @@ internal class CrashReport : IDisposable
         {
             list = null;
             sessionInfo = null;
-            CrashHelper.SendExceptionInfoToWebhook(e);
+            CrashHelper.SendExceptionInfo(e);
             return false;
         }
 
@@ -379,6 +514,11 @@ internal class CrashReport : IDisposable
             reportStream.Write(Encoding.UTF8.GetBytes(ReportText));
         }
 
+        using (var reportStream = archive.CreateEntry("report.json").Open())
+        {
+            reportStream.Write(Encoding.UTF8.GetBytes(ApiReportJson));
+        }
+
         // Write the documents into zip
         int counter = 0;
         var originalPaths = new List<CrashedFileInfo>();
@@ -419,11 +559,22 @@ internal class CrashReport : IDisposable
         using Stream stream = entry.Open();
 
         byte[] encodedReport = new byte[entry.Length];
-        stream.Read(encodedReport);
+        stream.ReadExactly(encodedReport);
 
         ReportText = Encoding.UTF8.GetString(encodedReport);
     }
 
+    private void ExtractJsonReport()
+    {
+        ZipArchiveEntry entry = ZipFile.GetEntry("report.json");
+        using Stream stream = entry.Open();
+
+        byte[] encodedReport = new byte[entry.Length];
+        stream.ReadExactly(encodedReport);
+
+        ApiReportJson = Encoding.UTF8.GetString(encodedReport);
+    }
+
     public class RecoveredPixi
     {
         public string? Path { get; }

+ 8 - 0
src/PixiEditor/Models/ExceptionHandling/NonCrashInfo.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.ExceptionHandling;
+
+public class NonCrashInfo(string catchLocation, string catchMember)
+{
+    public string CatchLocation { get; } = catchLocation;
+
+    public string CatchMember { get; } = catchMember;
+}

+ 1 - 1
src/PixiEditor/Models/IO/Exporter.cs

@@ -125,7 +125,7 @@ internal class Exporter
         {
             job?.Finish();
             Console.WriteLine(e);
-            CrashHelper.SendExceptionInfoToWebhook(e);
+            CrashHelper.SendExceptionInfo(e);
             return SaveResult.UnknownError;
         }
     }

+ 1 - 1
src/PixiEditor/Models/Palettes/LocalPalettesFetcher.cs

@@ -242,7 +242,7 @@ internal class LocalPalettesFetcher : PaletteListDataSource
             }
             catch (Exception e)
             {
-                await CrashHelper.SendExceptionInfoToWebhookAsync(e);
+                await CrashHelper.SendExceptionInfoAsync(e);
             }
 
             return;

+ 1 - 0
src/PixiEditor/ViewModels/CrashReportViewModel.cs

@@ -39,6 +39,7 @@ internal partial class CrashReportViewModel : Window
 
         if (!IsDebugBuild)
             _ = CrashHelper.SendReportTextToWebhookAsync(report);
+        _ = CrashHelper.SendReportToAnalyticsApiAsync(report);
     }
 
     [RelayCommand(CanExecute = nameof(CanRecoverDocuments))]

+ 15 - 0
src/PixiEditor/ViewModels/SubViewModels/DebugViewModel.cs

@@ -13,6 +13,7 @@ using PixiEditor.Models.Commands.Attributes.Evaluators;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.CommonApi.UserPreferences.Settings;
 using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
+using PixiEditor.Helpers;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Commands.Templates.Providers.Parsers;
 using PixiEditor.Models.Dialogs;
@@ -298,6 +299,20 @@ internal class DebugViewModel : SubViewModel<ViewModelMain>
         MenuItemPath = "DEBUG/CRASH", MenuItemOrder = 10, AnalyticsTrack = true)]
     public static void Crash() => throw new InvalidOperationException("User requested to crash :c");
 
+    [Command.Debug("PixiEditor.Debug.SendCatchedCrash", "Send catched crash", "Send catched crash")]
+    [Conditional("DEBUG")]
+    public static void SendCatchedCrash()
+    {
+        try
+        {
+            throw new InvalidOperationException("User requested to send catched exception");
+        }
+        catch (InvalidOperationException exception)
+        {
+            CrashHelper.SendExceptionInfo(exception);
+        }
+    }
+
     [Command.Debug("PixiEditor.Debug.DeleteUserPreferences", @"%appdata%\PixiEditor\user_preferences.json", "DELETE_USR_PREFS", "DELETE_USR_PREFS",
         MenuItemPath = "DEBUG/DELETE/USER_PREFS", MenuItemOrder = 11, AnalyticsTrack = true)]
     [Command.Debug("PixiEditor.Debug.DeleteShortcutFile", @"%appdata%\PixiEditor\shortcuts.json", "DELETE_SHORTCUT_FILE", "DELETE_SHORTCUT_FILE",

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -303,7 +303,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
         else
         {
-            CrashHelper.SendExceptionInfoToWebhook(new InvalidFileTypeException(default,
+            CrashHelper.SendExceptionInfo(new InvalidFileTypeException(default,
                 $"Invalid file type '{fileType}'"));
         }
     }

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/MiscViewModel.cs

@@ -33,7 +33,7 @@ internal class MiscViewModel : SubViewModel<ViewModelMain>
         }
         catch (Exception e)
         {
-            CrashHelper.SendExceptionInfoToWebhook(e);
+            CrashHelper.SendExceptionInfo(e);
             NoticeDialog.Show(title: "Error", message: $"Couldn't open the address {uri} in your default browser");
         }
     }

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/UpdateViewModel.cs

@@ -266,7 +266,7 @@ internal class UpdateViewModel : SubViewModel<ViewModelMain>
             }
             catch (Exception e)
             {
-                CrashHelper.SendExceptionInfoToWebhookAsync(e);
+                CrashHelper.SendExceptionInfoAsync(e);
                 NoticeDialog.Show("COULD_NOT_CHECK_FOR_UPDATES", "UPDATE_CHECK_FAILED");
             }
 

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

@@ -98,7 +98,7 @@ internal partial class MainWindow : Window
             }
             catch (Exception e)
             {
-                CrashHelper.SendExceptionInfoToWebhook(e);
+                CrashHelper.SendExceptionInfo(e);
             }
         }
 
@@ -116,7 +116,7 @@ internal partial class MainWindow : Window
             }
             catch (Exception e)
             {
-                CrashHelper.SendExceptionInfoToWebhook(e, true);
+                CrashHelper.SendExceptionInfo(e, true);
                 throw;
             }
         }

+ 1 - 1
src/PixiEditor/Views/Windows/HelloTherePopup.axaml.cs

@@ -277,7 +277,7 @@ internal partial class HelloTherePopup : PixiEditorPopup
         {
             IsFetchingNews = false;
             FailedFetchingNews = true;
-            await CrashHelper.SendExceptionInfoToWebhookAsync(ex);
+            await CrashHelper.SendExceptionInfoAsync(ex);
         }
     }
 }