diff --git a/README.md b/README.md index a90d1de..3b326cd 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,95 @@ This method accepts the following arguments: - `category`: A custom value used to arbitrarily group this Breadcrumb. - `customData`: Any custom data you want to record about application state when the Breadcrumb was recorded. +### Stack traces and portable debug data + +Raygun for Blazor attaches both stack traces and the necessary info for portable debug data automatically. + +#### Blazor stack traces + +Exceptions originating within the Blazor environment should contain a stack trace attached. +For details, check the "Raw Data" tab on the Raygun error report. +The stack trace is contained inside `error.stackTrace` property. + +For example: + +```json +"error": { + "className": "System.DivideByZeroException", + "message": "Attempted to divide by zero.", + "stackTrace": [ + { + "className": "Raygun.Samples.Blazor.Server.Components.Pages.Sample", + "columnNumber": 75, + "fileName": "...\\src\\Raygun.Samples.Blazor.Server\\Components\\Pages\\Sample.razor", + "ilOffset": 5, + "lineNumber": 13, + "methodName": "", + "methodToken": 100663352 + }, + // ... + ] +}, +``` + +This also works for exceptions originating in Blazor WebAssembly applications. +For example, this exception captured on WebAssembly: + +```json +"error": { + "className": "System.DivideByZeroException", + "message": "Attempted to divide by zero.", + "stackTrace": [ + { + "className": "Raygun.Samples.Blazor.WebAssembly.ViewModels.CounterViewModel", + "columnNumber": 17, + "fileName": "...\\src\\Raygun.Samples.Blazor.WebAssembly\\ViewModels\\CounterViewModel.cs", + "ilOffset": 34, + "lineNumber": 48, + "methodName": "IncrementCountAsync", + "methodToken": 100663343 + }, + // ... + ] +}, +``` + +#### JavaScript stack traces + +Exceptions happening in the JavaScript side of a Blazor application should contain a stack trace referring to the JavaScript code that caused the error. + +For example: + +``` +ReferenceError: undefinedfunction3 is not defined +at causeErrors (https://localhost:7254/myfunctions.js:10:9) +at window.onmessage (https://localhost:7254/:21:17) +``` + +#### Portable debug data (PDB) + +Raygun for Blazor supports debugging reports using PDB files when running on Blazor Server and MAUI applications. +The necessary image information will be attached automatically to error reports. + +The debug data can be found in the `error.images` property: + +```json +"error": { + "className": "System.DivideByZeroException", + "images": [ + { + "signature": "a93d65be-ba53-4743-a1a5-4743716b7a42", + "checksum": "SHA256:BE653DA953BA4337A1A54743716B7A42A678F57568949BE3C375DB0BABF8EC35", + "file": "...\\src\\Raygun.Samples.Blazor.Server\\obj\\Debug\\net8.0\\Raygun.Samples.Blazor.Server.pdb", + "timestamp": "A1E84548" + }, + // ... + ], +}, +``` + +You can learn more about Portable PDB Support [on Raygun's .Net Framework documentation](https://raygun.com/documentation/language-guides/dotnet/crash-reporting/net-framework/#portable-pdb-support). + ### Environment details Raygun for Blazor captures environment details differently depending on the platform where the error originated. diff --git a/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs b/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs index 434bb10..8f03d3d 100644 --- a/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs +++ b/src/Raygun.Blazor.Maui/Control/RaygunErrorBoundary.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Components; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Options; diff --git a/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs b/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs index 8f31b4d..e30323f 100644 --- a/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs +++ b/src/Raygun.Blazor.Maui/Extensions/MauiExtensions.cs @@ -1,14 +1,9 @@ using KristofferStrube.Blazor.Window; using Microsoft.Extensions.Options; -using Raygun.Blazor; -using Raygun.Blazor.Interfaces; -using Raygun.Blazor.Offline; -using Raygun.Blazor.Offline.SendStrategy; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Hosting; +using Raygun.Blazor.Models; namespace Raygun.Blazor.Maui { @@ -16,6 +11,11 @@ public static class MauiExtensions { public static MauiAppBuilder UseRaygunBlazorMaui(this MauiAppBuilder builder, string configSectionName = "Raygun") { +#if ANDROID + // Replace default AssemblyReaderProvider with the Android Assembly reader from Raygun4Maui + ErrorDetails.AssemblyReaderProvider = AndroidUtilities.CreateAssemblyReader()!.TryGetReader; +#endif + builder.Services.Configure(builder.Configuration.GetSection(configSectionName)); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs new file mode 100644 index 0000000..3781526 --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AndroidUtilities.cs @@ -0,0 +1,84 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text; + +namespace Raygun.Blazor.Maui; + +internal static class AndroidUtilities +{ + /// + /// Factory method to create the correct assembly reader for the current application + /// + /// + public static IAssemblyReader? CreateAssemblyReader() + { + var apkPath = Android.App.Application.Context.ApplicationInfo?.SourceDir; + var supportedAbis = new List(); + + if (Android.OS.Build.SupportedAbis != null) + { + supportedAbis.AddRange(Android.OS.Build.SupportedAbis); + } + + if (!File.Exists(apkPath)) + { + // No apk, so return nothing + return null; + } + + if (!IsAndroidArchive(apkPath)) + { + // Not a valid android archive so nothing to return + return null; + } + + // Open the apk file, and see if it has a manifest, if it does, + // we are using the new assembly store method, + // else it's just a normal zip with assemblies as archive entries + using var zipArchive = ZipFile.Open(apkPath, ZipArchiveMode.Read); + + if (zipArchive.GetEntry("assemblies/assemblies.manifest") != null) + { + return new AssemblyBlobStoreReader(zipArchive, supportedAbis); + } + + return new AssemblyZipEntryReader(zipArchive, supportedAbis); + } + + public static bool IsAndroidArchive(string filePath) + { + return filePath.EndsWith(".aab", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + } + + public static ReadOnlyMemory AsReadOnlyMemory(this Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + return new ReadOnlyMemory(memoryStream.ToArray()); + } + + public static byte[] ToArray(this ReadOnlyMemory memory) + { + if (!MemoryMarshal.TryGetArray(memory, out var segment)) + { + throw new InvalidOperationException("Could not get array segment from ReadOnlyMemory."); + } + + return segment.Array!; + } + + public static BinaryReader GetBinaryReader(this ReadOnlyMemory memory, Encoding? encoding = null) + { + return new BinaryReader(memory.AsStream(), encoding ?? Encoding.UTF8, false); + } + + + public static MemoryStream AsStream(this ReadOnlyMemory memory) + { + return new MemoryStream(memory.ToArray(), writable: false); + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs new file mode 100644 index 0000000..a749ccd --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyBlobStoreReader.cs @@ -0,0 +1,465 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Compression; +using System.Reflection.PortableExecutable; +using System.Text; + +namespace Raygun.Blazor.Maui; + +internal class AssemblyBlobStoreReader : AssemblyReader +{ + private readonly AssemblyStoreExplorer _assemblyStoreExplorer; + + public AssemblyBlobStoreReader(ZipArchive zipArchive, List supportedAbis) + { + ArgumentNullException.ThrowIfNull(zipArchive); + + _assemblyStoreExplorer = new AssemblyStoreExplorer(zipArchive, supportedAbis); + } + + public override PEReader? TryGetReader(string moduleName) + { + var assembly = TryFindAssembly(moduleName); + + if (assembly is null) + { + return null; + } + + var bytes = assembly.GetImageData(); + + return CreatePEReader(bytes); + } + + private AssemblyStoreAssembly? TryFindAssembly(string name) + { + if (_assemblyStoreExplorer.AssembliesByName.TryGetValue(name, out var assembly)) + { + return assembly; + } + + // Try get the assembly without the module extension + var pathLessExtension = Path.GetFileNameWithoutExtension(name); + if (_assemblyStoreExplorer.AssembliesByName.TryGetValue(pathLessExtension, out assembly)) + { + return assembly; + } + + return null; + } + + public override void Dispose() + { + } + + + /// + /// https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/assembly-store-reader/AssemblyStoreExplorer.cs + /// + private class AssemblyStoreExplorer + { + private readonly AssemblyStoreManifestReader _manifest; + private AssemblyStore? _indexStore; + + public SortedDictionary AssembliesByName { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary AssembliesByHash32 { get; } = new(); + + public Dictionary AssembliesByHash64 { get; } = new(); + + public SortedDictionary> Stores { get; } = new(); + + public AssemblyStoreExplorer(ZipArchive zipArchive, List supportedAbis) + { + using var manifestStream = zipArchive.GetEntry("assemblies/assemblies.manifest")!.Open(); + _manifest = new AssemblyStoreManifestReader(manifestStream); + + TryAddStore(zipArchive, null); + foreach (var abi in supportedAbis) + { + if (!string.IsNullOrEmpty(abi)) + { + TryAddStore(zipArchive, abi); + } + } + + ProcessStores(); + } + + private void TryAddStore(ZipArchive zipArchive, string? abi) + { + var storeLocation = abi is null + ? "assemblies/assemblies.blob" + : $"assemblies/assemblies.{abi}.blob"; + + if (zipArchive.GetEntry(storeLocation) is { } zipEntry) + { + using var entry = zipEntry.Open(); + var store = new AssemblyStore(entry.AsReadOnlyMemory(), abi); + + if (store.HasGlobalIndex) + { + _indexStore = store; + } + + if (!Stores.TryGetValue(store.StoreId, out var storeList)) + { + storeList = new List(); + Stores.Add(store.StoreId, storeList); + } + + storeList.Add(store); + } + } + + // This is lifted directly from the xamarin tools code base + // Effectively this code re-processes all the stores, finds the assemblies by index + // and attempts to add them to the hash and name dictionaries for faster lookups + private void ProcessStores() + { + if (Stores.Count == 0 || _indexStore == null) + { + return; + } + + ProcessIndex(_indexStore.GlobalIndex32, (he, assembly) => + { + assembly.Hash32 = (uint)he.Hash; + assembly.RuntimeIndex = he.MappingIndex; + + if (_manifest.EntriesByHash32.TryGetValue(assembly.Hash32, out var me)) + { + assembly.Name = me.Name; + } + + if (!AssembliesByHash32.ContainsKey(assembly.Hash32)) + { + AssembliesByHash32.Add(assembly.Hash32, assembly); + } + }); + + ProcessIndex(_indexStore.GlobalIndex64, (he, assembly) => + { + assembly.Hash64 = he.Hash; + if (assembly.RuntimeIndex != he.MappingIndex) + { + Debug.WriteLine($"assembly with hashes 0x{assembly.Hash32} and 0x{assembly.Hash64} has a different 32-bit runtime index ({assembly.RuntimeIndex}) than the 64-bit runtime index({he.MappingIndex})"); + } + + if (_manifest.EntriesByHash64.TryGetValue(assembly.Hash64, out var me)) + { + if (string.IsNullOrEmpty(assembly.Name)) + { + Debug.WriteLine($"32-bit hash 0x{assembly.Hash32:x} did not match any assembly name in the manifest"); + assembly.Name = me.Name; + if (string.IsNullOrEmpty(assembly.Name)) + { + Debug.WriteLine($"64-bit hash 0x{assembly.Hash64:x} did not match any assembly name in the manifest"); + } + } + else if (!string.Equals(assembly.Name, me.Name, StringComparison.Ordinal)) + { + Debug.WriteLine($"32-bit hash 0x{assembly.Hash32:x} maps to assembly name '{assembly.Name}', however 64-bit hash 0x{assembly.Hash64:x} for the same entry matches assembly name '{me.Name}'"); + } + } + + if (!AssembliesByHash64.ContainsKey(assembly.Hash64)) + { + AssembliesByHash64.Add(assembly.Hash64, assembly); + } + }); + + foreach (var kvp in Stores) + { + var list = kvp.Value; + if (list.Count < 2) + { + continue; + } + + var template = list[0]; + for (var i = 1; i < list.Count; i++) + { + var other = list[i]; + if (!template.HasIdenticalContent(other)) + { + throw new Exception($"Store ID {template.StoreId} for architecture {other.Arch} is not identical to other stores with the same ID"); + } + } + } + + return; + + void ProcessIndex(List index, Action assemblyHandler) + { + foreach (var he in index) + { + if (!Stores.TryGetValue(he.StoreId, out var storeList)) + { + continue; + } + + foreach (var store in storeList) + { + if (he.LocalStoreIndex >= (uint)store.Assemblies.Count) + { + continue; + } + + var assembly = store.Assemblies[(int)he.LocalStoreIndex]; + assemblyHandler(he, assembly); + + AssembliesByName.TryAdd(assembly.Name, assembly); + } + } + } + } + } + + // https://github.com/xamarin/xamarin-android/blob/main/tools/assembly-store-reader/AssemblyStoreManifestReader.cs + private class AssemblyStoreManifestReader + { + public List Entries { get; } = new(); + + public Dictionary EntriesByHash32 { get; } = new(); + + public Dictionary EntriesByHash64 { get; } = new(); + + public AssemblyStoreManifestReader(Stream manifest) + { + using var sr = new StreamReader(manifest, Encoding.UTF8, detectEncodingFromByteOrderMarks: false); + ReadManifest(sr); + } + + private void ReadManifest(StreamReader reader) + { + // First line is ignored, it contains headers + reader.ReadLine(); + + // Each subsequent line consists of fields separated with any number of spaces (for the pleasure of a human being reading the manifest) + while (!reader.EndOfStream) + { + var fields = reader.ReadLine()?.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields == null) + { + continue; + } + + var entry = new AssemblyStoreManifestEntry(fields); + Entries.Add(entry); + if (entry.Hash32 != 0) + { + EntriesByHash32.Add(entry.Hash32, entry); + } + + if (entry.Hash64 != 0) + { + EntriesByHash64.Add(entry.Hash64, entry); + } + } + } + } + + /// + /// https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/assembly-store-reader/AssemblyStoreHashEntry.cs + /// + private class AssemblyStoreHashEntry + { + public bool Is32Bit { get; } + public ulong Hash { get; } + public uint MappingIndex { get; } + public uint LocalStoreIndex { get; } + public uint StoreId { get; } + + internal AssemblyStoreHashEntry(BinaryReader reader, bool is32Bit) + { + Is32Bit = is32Bit; + + Hash = reader.ReadUInt64(); + MappingIndex = reader.ReadUInt32(); + LocalStoreIndex = reader.ReadUInt32(); + StoreId = reader.ReadUInt32(); + } + } + + /// + /// https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/assembly-store-reader/AssemblyStoreManifestEntry.cs + /// + private class AssemblyStoreManifestEntry + { + // Fields are: + // Hash 32 | Hash 64 | Store ID | Store idx | Name + private const int NumberOfFields = 5; + private const int Hash32FieldIndex = 0; + private const int Hash64FieldIndex = 1; + private const int StoreIdFieldIndex = 2; + private const int StoreIndexFieldIndex = 3; + private const int NameFieldIndex = 4; + + public uint Hash32 { get; } + public ulong Hash64 { get; } + public uint StoreId { get; } + public uint IndexInStore { get; } + public string Name { get; } + + public AssemblyStoreManifestEntry(string[] fields) + { + if (fields.Length != NumberOfFields) + { + throw new ArgumentOutOfRangeException(nameof(fields), "Invalid number of fields"); + } + + Hash32 = GetUInt32(fields[Hash32FieldIndex]); + Hash64 = GetUInt64(fields[Hash64FieldIndex]); + StoreId = GetUInt32(fields[StoreIdFieldIndex]); + IndexInStore = GetUInt32(fields[StoreIndexFieldIndex]); + Name = fields[NameFieldIndex].Trim(); + } + + private static uint GetUInt32(string value) + { + return uint.TryParse(PrepHexValue(value), NumberStyles.HexNumber, null, out uint hash) ? hash : 0; + } + + private static ulong GetUInt64(string value) + { + return ulong.TryParse(PrepHexValue(value), NumberStyles.HexNumber, null, out ulong hash) ? hash : 0; + } + + private static string PrepHexValue(string value) + { + return value.StartsWith("0x", StringComparison.Ordinal) ? value[2..] : value; + } + } + + // https://github.com/xamarin/xamarin-android/blob/main/tools/assembly-store-reader/AssemblyStoreAssembly.cs + private class AssemblyStoreAssembly + { + private readonly Func> _storeDataFunc; + public uint DataOffset { get; } + public uint DataSize { get; } + public uint DebugDataOffset { get; } + public uint DebugDataSize { get; } + public uint ConfigDataOffset { get; } + public uint ConfigDataSize { get; } + + public uint Hash32 { get; set; } + public ulong Hash64 { get; set; } + public string Name { get; set; } = String.Empty; + public uint RuntimeIndex { get; set; } + + public AssemblyStoreAssembly(BinaryReader reader, Func> storeDataFunc) + { + _storeDataFunc = storeDataFunc; + DataOffset = reader.ReadUInt32(); + DataSize = reader.ReadUInt32(); + DebugDataOffset = reader.ReadUInt32(); + DebugDataSize = reader.ReadUInt32(); + ConfigDataOffset = reader.ReadUInt32(); + ConfigDataSize = reader.ReadUInt32(); + } + + public ReadOnlyMemory GetImageData() + { + return _storeDataFunc(DataOffset, DataSize); + } + } + + // https://github.com/xamarin/xamarin-android/blob/main/tools/assembly-store-reader/AssemblyStoreReader.cs + private sealed class AssemblyStore + { + // These two constants must be identical to the native ones in src/monodroid/jni/xamarin-app.hh + private const uint AssemblyStoreMagic = 0x41424158; // 'XABA', little-endian + private const uint AssemblyStoreFormatVersion = 1; + + private readonly ReadOnlyMemory _storeMemory; + + public uint Version { get; private set; } + public uint LocalEntryCount { get; private set; } + public uint GlobalEntryCount { get; private set; } + public uint StoreId { get; private set; } + + public bool HasGlobalIndex => StoreId == 0; + public List Assemblies { get; } = new(); + public List GlobalIndex32 { get; } = new(); + public List GlobalIndex64 { get; } = new(); + + public string Arch { get; } + + public AssemblyStore(ReadOnlyMemory storeMemory, string? arch) + { + _storeMemory = storeMemory; + Arch = arch ?? string.Empty; + + // Need to use a reader here, because the reader advances through the memory + // and the structure is based off the current position + using var reader = _storeMemory.GetBinaryReader(); + ReadHeader(reader); + LoadAssemblies(reader); + + if (HasGlobalIndex) + { + ReadGlobalIndex(reader); + } + } + + private void ReadGlobalIndex(BinaryReader reader) + { + // Read 32 bit index + for (uint i = 0; i < GlobalEntryCount; i++) + { + GlobalIndex32.Add(new AssemblyStoreHashEntry(reader, true)); + } + + // Read 64 bit index + for (uint i = 0; i < GlobalEntryCount; i++) + { + GlobalIndex64.Add(new AssemblyStoreHashEntry(reader, false)); + } + } + + private void ReadHeader(BinaryReader reader) + { + if (reader.ReadUInt32() != AssemblyStoreMagic) + { + throw new InvalidOperationException("Invalid header magic number"); + } + + Version = reader.ReadUInt32(); + if (Version != AssemblyStoreFormatVersion) + { + throw new InvalidOperationException("Invalid Store Version"); + } + + LocalEntryCount = reader.ReadUInt32(); + GlobalEntryCount = reader.ReadUInt32(); + StoreId = reader.ReadUInt32(); + } + + private void LoadAssemblies(BinaryReader reader) + { + for (uint i = 0; i < LocalEntryCount; i++) + { + var assembly = new AssemblyStoreAssembly(reader, GetMemorySlice); + Assemblies.Add(assembly); + } + } + + private ReadOnlyMemory GetMemorySlice(uint offset, uint length) + { + return _storeMemory.Slice((int)offset, (int)length); + } + + public bool HasIdenticalContent(AssemblyStore other) + { + return + other.Version == Version && + other.LocalEntryCount == LocalEntryCount && + other.GlobalEntryCount == GlobalEntryCount && + other.StoreId == StoreId && + other.Assemblies.Count == Assemblies.Count && + other.GlobalIndex32.Count == GlobalIndex32.Count && + other.GlobalIndex64.Count == GlobalIndex64.Count; + } + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs new file mode 100644 index 0000000..660ea55 --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyReader.cs @@ -0,0 +1,63 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.Reflection.PortableExecutable; +using K4os.Compression.LZ4; + +namespace Raygun.Blazor.Maui; + +internal abstract class AssemblyReader : IAssemblyReader +{ + private const uint CompressedDataMagic = 0x5A4C4158; // 'XALZ', little-endian + + public virtual void Dispose() + { + } + + public abstract PEReader? TryGetReader(string moduleName); + + protected PEReader CreatePEReader(ReadOnlyMemory rawBytes) + { + // Try to decompress the array from LZ4 as it could be LZ4 compressed + var actualBytes = TryDecompressLZ4AssemblyBytes(rawBytes, out var decompressedBytes) + ? decompressedBytes + : rawBytes; + + return new PEReader(actualBytes.Span.ToImmutableArray()); + } + + // Decompression reference : https://github.com/xamarin/xamarin-android/blob/c92702619f5fabcff0ed88e09160baf9edd70f41/tools/decompress-assemblies/main.cs + private bool TryDecompressLZ4AssemblyBytes(ReadOnlyMemory encodedAssembly, out ReadOnlyMemory decompressedBytes) + { + var magicHeader = BinaryPrimitives.ReadUInt32LittleEndian(encodedAssembly.Span[..4]); + var decompressedLength = BinaryPrimitives.ReadInt32LittleEndian(encodedAssembly.Span[8..12]); + + if (magicHeader != CompressedDataMagic) + { + decompressedBytes = ReadOnlyMemory.Empty; + return false; + } + + // Skip the header and move to the compressed bytes + var assemblyBytes = encodedAssembly.Slice(12); + var decompressedArray = new byte[decompressedLength]; + + try + { + var actualDecompressedSize = LZ4Codec.Decode(assemblyBytes.Span, decompressedArray); + + if (actualDecompressedSize != decompressedLength) + { + throw new Exception($"Could not decompress bytes. Lengths do not match, expected {decompressedLength} but got {actualDecompressedSize}"); + } + + decompressedBytes = new ReadOnlyMemory(decompressedArray); + return true; + } + catch + { + decompressedBytes = ReadOnlyMemory.Empty; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs new file mode 100644 index 0000000..66f085d --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/AssemblyZipEntryReader.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO.Compression; +using System.Reflection.PortableExecutable; + +namespace Raygun.Blazor.Maui; + +internal class AssemblyZipEntryReader : AssemblyReader +{ + private readonly ZipArchive _zipArchive; + private readonly List _supportedAbis; + + public AssemblyZipEntryReader(ZipArchive? zipArchive, List supportedAbis) + { + _zipArchive = zipArchive ?? throw new ArgumentNullException(nameof(zipArchive)); + _supportedAbis = supportedAbis; + } + + public override void Dispose() + { + _zipArchive.Dispose(); + } + + public override PEReader? TryGetReader(string moduleName) + { + // Check the file system first + if (File.Exists(moduleName)) + { + var peBytes = File.ReadAllBytes(moduleName).ToImmutableArray(); + return new PEReader(peBytes); + } + + var zipEntry = FindZipEntryForModule(moduleName); + if (zipEntry is null) + { + Debug.WriteLine("Cannot find module {0}", moduleName); + return null; + } + + using var zipStream = zipEntry.Open(); + return CreatePEReader(zipStream.AsReadOnlyMemory()); + } + + private ZipArchiveEntry? FindZipEntryForModule(string moduleName) + { + var zipEntry = _zipArchive.GetEntry($"assemblies/{moduleName}"); + + if (zipEntry is not null) + return zipEntry; + + foreach (var abi in _supportedAbis) + { + zipEntry = _zipArchive.GetEntry($"assemblies/{abi}/{moduleName}"); + if (zipEntry is not null) + { + return zipEntry; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs b/src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs new file mode 100644 index 0000000..539a26a --- /dev/null +++ b/src/Raygun.Blazor.Maui/Platforms/Android/IAssemblyReader.cs @@ -0,0 +1,13 @@ +using System.Reflection.PortableExecutable; + +namespace Raygun.Blazor.Maui; + +internal interface IAssemblyReader : IDisposable +{ + /// + /// Try to find the module, and create PEReader for the module if it exists + /// + /// + /// A reader for the PE or null + PEReader? TryGetReader(string moduleName); +} \ No newline at end of file diff --git a/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj b/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj index 8ccc39c..90d2fdf 100644 --- a/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj +++ b/src/Raygun.Blazor.Maui/Raygun.Blazor.Maui.csproj @@ -46,6 +46,7 @@ + diff --git a/src/Raygun.Blazor/Models/ErrorDetails.cs b/src/Raygun.Blazor/Models/ErrorDetails.cs index 49703ff..65d3b94 100644 --- a/src/Raygun.Blazor/Models/ErrorDetails.cs +++ b/src/Raygun.Blazor/Models/ErrorDetails.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Reflection.PortableExecutable; using System.Text.Json.Serialization; using KristofferStrube.Blazor.WebIDL.Exceptions; using Raygun.Blazor.Extensions; @@ -128,9 +130,68 @@ internal ErrorDetails(Exception ex) { InnerError = new ErrorDetails(betterEx.InnerException); } + + Images = GetDebugInfoForStackFrames(new EnhancedStackTrace(ex).GetExternalFrames()); } } #endregion + + #region Private Properties + + private static readonly ConcurrentDictionary DebugInformationCache = new(); + + internal static Func AssemblyReaderProvider { get; set; } = + PortableExecutableReaderExtensions.GetFileSystemPEReader; + + #endregion + + #region Privatate Methods + + private static List GetDebugInfoForStackFrames(IEnumerable frames) + { + var imageSet = new HashSet(); + + foreach (var stackFrame in frames) + { + var methodBase = stackFrame.GetMethod(); + if (methodBase == null) continue; + var debugInformation = TryGetDebugInformation(methodBase.Module.FullyQualifiedName); + if (debugInformation != null) + { + imageSet.Add(debugInformation); + } + } + + return imageSet.ToList(); + } + + internal static PEDebugDetails? TryGetDebugInformation(string moduleName) + { + if (DebugInformationCache.TryGetValue(moduleName, out var cachedInfo)) + { + return cachedInfo; + } + + try + { + // Attempt to read out the Debug Info from the PE + var peReader = AssemblyReaderProvider(moduleName); + + // If we got this far, the assembly/module exists, so whatever the result + // put it in the cache to prevent reading the disk over and over + peReader.TryGetDebugInformation(out var debugInfo); + DebugInformationCache.TryAdd(moduleName, debugInfo); + return debugInfo; + } + catch (Exception ex) + { + Debug.WriteLine($"Could not load debug information: {ex}"); + } + + return null; + } + + #endregion } } \ No newline at end of file diff --git a/src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs b/src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs new file mode 100644 index 0000000..25aa96c --- /dev/null +++ b/src/Raygun.Blazor/Models/PortableExecutableReaderExtensions.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection.PortableExecutable; + +namespace Raygun.Blazor.Models; + +internal static class PortableExecutableReaderExtensions +{ + // ReSharper disable once InconsistentNaming + public static PEReader? GetFileSystemPEReader(string moduleName) + { + try + { + // Read into memory to avoid any premature stream closures + var bytes = ImmutableArray.Create(File.ReadAllBytes(moduleName)); + return new PEReader(bytes); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not open module [{moduleName}] from disk: {ex}"); + return null; + } + } + + public static bool TryGetDebugInformation(this PEReader? peReader, out PEDebugDetails? debugInformation) + { + if (peReader is null) + { + debugInformation = null; + return false; + } + + try + { + debugInformation = GetDebugInformation(peReader); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Error reading PE Debug Data: {ex}"); + } + + debugInformation = null; + return false; + } + + private static PEDebugDetails GetDebugInformation(this PEReader peReader) + { + var debugInfo = new PEDebugDetails + { + Timestamp = $"{peReader.PEHeaders.CoffHeader.TimeDateStamp:X8}" + }; + + foreach (var entry in peReader.ReadDebugDirectory()) + { + if (entry.Type == DebugDirectoryEntryType.CodeView) + { + // Read the CodeView data + var codeViewData = peReader.ReadCodeViewDebugDirectoryData(entry); + + debugInfo.File = codeViewData.Path; + debugInfo.Signature = codeViewData.Guid.ToString(); + } + + if (entry.Type == DebugDirectoryEntryType.PdbChecksum) + { + var checksumEntry = peReader.ReadPdbChecksumDebugDirectoryData(entry); + var checksumHex = BitConverter.ToString(checksumEntry.Checksum.ToArray()).Replace("-", "").ToUpperInvariant(); + debugInfo.Checksum = $"{checksumEntry.AlgorithmName}:{checksumHex}"; + } + } + + return debugInfo; + } +} \ No newline at end of file diff --git a/src/Raygun.Blazor/Models/StackTraceDetails.cs b/src/Raygun.Blazor/Models/StackTraceDetails.cs index 4749152..a68ff37 100644 --- a/src/Raygun.Blazor/Models/StackTraceDetails.cs +++ b/src/Raygun.Blazor/Models/StackTraceDetails.cs @@ -87,10 +87,22 @@ internal StackTraceDetails(StackFrame frame) ColumnNumber = frame.GetFileColumnNumber(); FileName = frame.GetFileName(); ILOffset = frame.GetILOffset(); - //ImageSignature = frame.ImageSignature; LineNumber = frame.GetFileLineNumber(); MethodName = names.MethodName; MethodToken = frame.GetMethod()?.MetadataToken; + + // Attempt to get the image signature from the debug information + var method = frame.GetMethod(); + if (method != null) + { + // Debug information gets cached + var debugInfo = ErrorDetails.TryGetDebugInformation(method.Module.FullyQualifiedName); + if (debugInfo != null) + { + ImageSignature = debugInfo.Signature; + } + } + } /// diff --git a/src/Raygun.Blazor/Raygun.Blazor.csproj b/src/Raygun.Blazor/Raygun.Blazor.csproj index 964dc31..353e9b2 100644 --- a/src/Raygun.Blazor/Raygun.Blazor.csproj +++ b/src/Raygun.Blazor/Raygun.Blazor.csproj @@ -66,6 +66,12 @@ + + + <_Parameter1>Raygun.Blazor.Maui, $(StrongNamePublicKey) + + + <_Parameter1>Raygun.Tests.Blazor, $(StrongNamePublicKey) diff --git a/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs b/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs index f1d552a..724d3f9 100644 --- a/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs +++ b/src/Raygun.Tests.Blazor.Server/RaygunExceptionCatcherTests.cs @@ -9,6 +9,8 @@ using Raygun.Tests.Blazor.Server.Components; using Raygun.Blazor.Models; using FluentAssertions; +using Raygun.Tests.Blazor.Server.Components.Pages; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; namespace Raygun.Tests.Blazor.Server { @@ -108,6 +110,23 @@ public async Task RaygunExceptionCatcher_WhenExceptionIsThrown_ExceptionIsCaught // Check error details raygunMsg.Details!.Error!.Message.Should().Be("Captured error!"); + + // Check stacktrace + raygunMsg.Details!.Error!.StackTrace.Should().NotBeNullOrEmpty(); + + // First frame should be the exception origin + var frame = raygunMsg.Details!.Error!.StackTrace!.First(); + frame.ClassName.Should().Be("Raygun.Tests.Blazor.Server.Components.Pages.TestComponent"); + frame.ColumnNumber.Should().Be(9); + frame.LineNumber.Should().Be(11); + frame.MethodName.Should().Be("ThrowException"); + frame.FileName.Should().EndWith("TestComponent.razor"); + + // Check PE Debug Image data + raygunMsg.Details!.Error!.Images.Should().NotBeNullOrEmpty(); + + // First Image data should contain the path to the test server debug data + raygunMsg.Details.Error.Images!.First().File.Should().EndWith("Raygun.Tests.Blazor.Server.pdb"); } #endregion }