diff --git a/Mindscape.Raygun4Net.NetCore.Common/Offline/CrashReportStoreEntry.cs b/Mindscape.Raygun4Net.NetCore.Common/Offline/CrashReportStoreEntry.cs new file mode 100644 index 00000000..9d19c917 --- /dev/null +++ b/Mindscape.Raygun4Net.NetCore.Common/Offline/CrashReportStoreEntry.cs @@ -0,0 +1,21 @@ +using System; + +namespace Mindscape.Raygun4Net.Offline; + +public sealed class CrashReportStoreEntry +{ + /// + /// Unique ID for the record + /// + public Guid Id { get; set; } + + /// + /// The application api key that the payload was intended for + /// + public string ApiKey { get; set; } + + /// + /// The JSON serialized payload of the error + /// + public string MessagePayload { get; set; } +} \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Common/Offline/IBackgroundSendStrategy.cs b/Mindscape.Raygun4Net.NetCore.Common/Offline/IBackgroundSendStrategy.cs new file mode 100644 index 00000000..8dc4196b --- /dev/null +++ b/Mindscape.Raygun4Net.NetCore.Common/Offline/IBackgroundSendStrategy.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; + +namespace Mindscape.Raygun4Net.Offline; + +public interface IBackgroundSendStrategy : IDisposable +{ + public event Func OnSendAsync; + public void Start(); + public void Stop(); +} \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Common/Offline/OfflineStoreBase.cs b/Mindscape.Raygun4Net.NetCore.Common/Offline/OfflineStoreBase.cs new file mode 100644 index 00000000..13359e7a --- /dev/null +++ b/Mindscape.Raygun4Net.NetCore.Common/Offline/OfflineStoreBase.cs @@ -0,0 +1,61 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Mindscape.Raygun4Net.Offline; + +public delegate Task SendHandler(string messagePayload, string apiKey, CancellationToken cancellationToken); + +public abstract class OfflineStoreBase +{ + private readonly IBackgroundSendStrategy _backgroundSendStrategy; + protected SendHandler? SendCallback { get; set; } + + protected OfflineStoreBase(IBackgroundSendStrategy backgroundSendStrategy) + { + _backgroundSendStrategy = backgroundSendStrategy ?? throw new ArgumentNullException(nameof(backgroundSendStrategy)); + _backgroundSendStrategy.OnSendAsync += ProcessOfflineCrashReports; + } + + public virtual void SetSendCallback(SendHandler sendHandler) + { + SendCallback = sendHandler; + } + + protected virtual async Task ProcessOfflineCrashReports() + { + if (SendCallback is null) + { + return; + } + + try + { + var cachedCrashReports = await GetAll(CancellationToken.None); + foreach (var crashReport in cachedCrashReports) + { + try + { + await SendCallback(crashReport.MessagePayload, crashReport.ApiKey, CancellationToken.None); + await Remove(crashReport.Id, CancellationToken.None); + } + catch (Exception ex) + { + Debug.WriteLine($"Exception sending offline error [{crashReport.Id}]: {ex}"); + throw; + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Exception sending offline errors: {ex}"); + } + } + + public abstract Task> GetAll(CancellationToken cancellationToken); + public abstract Task Save(string crashPayload, string apiKey, CancellationToken cancellationToken); + public abstract Task Remove(Guid cacheEntryId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Common/Offline/TimerBasedSendStrategy.cs b/Mindscape.Raygun4Net.NetCore.Common/Offline/TimerBasedSendStrategy.cs new file mode 100644 index 00000000..3f6340a8 --- /dev/null +++ b/Mindscape.Raygun4Net.NetCore.Common/Offline/TimerBasedSendStrategy.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Mindscape.Raygun4Net.Offline; + +public class TimerBasedSendStrategy : IBackgroundSendStrategy +{ + private static readonly TimeSpan DefaultInternal = TimeSpan.FromSeconds(30); + + private readonly Timer _backgroundTimer; + public event Func OnSendAsync; + + public TimeSpan Interval { get; } + + public TimerBasedSendStrategy(TimeSpan? interval = null) + { + Interval = interval ?? DefaultInternal; + _backgroundTimer = new Timer(SendOfflineErrors); + Start(); + } + + ~TimerBasedSendStrategy() + { + Dispose(); + } + + private async void SendOfflineErrors(object state) + { + try + { + var invocationList = OnSendAsync?.GetInvocationList(); + if (invocationList != null) + { + var tasks = invocationList.OfType>().Select(handler => handler()); + await Task.WhenAll(tasks); + } + } + finally + { + Start(); + } + } + + public void Start() + { + // This sets the timer to trigger once at the interval, and then "never again". + // This inherently prevents the timer from being re-entrant + _backgroundTimer.Change(Interval, TimeSpan.FromMilliseconds(int.MaxValue)); + } + + public void Stop() + { + _backgroundTimer.Change(Timeout.Infinite, 0); + } + + public void Dispose() + { + _backgroundTimer?.Dispose(); + } +} \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Common/RaygunClientBase.cs b/Mindscape.Raygun4Net.NetCore.Common/RaygunClientBase.cs index ffa7c45b..a29da438 100644 --- a/Mindscape.Raygun4Net.NetCore.Common/RaygunClientBase.cs +++ b/Mindscape.Raygun4Net.NetCore.Common/RaygunClientBase.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Net; using System.Net.Http; using System.Reflection; using System.Text; @@ -19,7 +20,7 @@ public abstract class RaygunClientBase /// /// If no HttpClient is provided to the constructor, this will be used. /// - private static readonly HttpClient DefaultClient = new () + private static readonly HttpClient DefaultClient = new() { // The default timeout is 100 seconds for the HttpClient, Timeout = TimeSpan.FromSeconds(30) @@ -39,16 +40,17 @@ public abstract class RaygunClientBase private readonly ThrottledBackgroundMessageProcessor _backgroundMessageProcessor; private readonly IRaygunUserProvider _userProvider; protected internal const string SentKey = "AlreadySentByRaygun"; - + /// - /// Store a strong reference to the OnApplicationUnhandledException delegate so it does not get garbage collected while + /// Store a strong reference to the OnApplicationUnhandledException delegate, so it does not get garbage collected while /// the client is still alive /// + // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable private readonly UnhandledExceptionBridge.UnhandledExceptionHandler _onUnhandledExceptionDelegate; /// - /// Raised just before a message is sent. This can be used to make final adjustments to the , or to cancel the send. + /// Raised just before a message is sent. This can be used to make final adjustments to the or to cancel the send. /// public event EventHandler SendingMessage; @@ -102,16 +104,19 @@ private void OnApplicationUnhandledException(Exception exception, bool isTermina Send(exception, UnhandledExceptionTags); } - protected RaygunClientBase(RaygunSettingsBase settings) : this(settings, DefaultClient, null) + protected RaygunClientBase(RaygunSettingsBase settings) + : this(settings, DefaultClient, null) { } // ReSharper disable once IntroduceOptionalParameters.Global - protected RaygunClientBase(RaygunSettingsBase settings, HttpClient client) : this(settings, client, null) + protected RaygunClientBase(RaygunSettingsBase settings, HttpClient client) + : this(settings, client, null) { } - protected RaygunClientBase(RaygunSettingsBase settings, IRaygunUserProvider userProvider) : this(settings, DefaultClient, userProvider) + protected RaygunClientBase(RaygunSettingsBase settings, IRaygunUserProvider userProvider) + : this(settings, DefaultClient, userProvider) { } @@ -123,10 +128,15 @@ protected RaygunClientBase(RaygunSettingsBase settings, HttpClient client, IRayg _userProvider = userProvider; _wrapperExceptions.Add(typeof(TargetInvocationException)); - + _onUnhandledExceptionDelegate = OnApplicationUnhandledException; - + UnhandledExceptionBridge.OnUnhandledException(_onUnhandledExceptionDelegate); + + if (settings.OfflineStore != null) + { + settings.OfflineStore.SetSendCallback(SendOfflinePayloadAsync); + } } /// @@ -160,6 +170,7 @@ public void RemoveWrapperExceptions(params Type[] wrapperExceptions) _wrapperExceptions.Remove(wrapper); } } + protected virtual bool CanSend(Exception exception) { return exception?.Data == null || !exception.Data.Contains(SentKey) || @@ -317,6 +328,15 @@ public void Send(Exception exception, IList tags, IDictionary userCustom } } + /// + /// Transmits an exception to Raygun asynchronously. + /// + /// The exception to deliver. + public Task SendAsync(Exception exception) + { + return SendAsync(exception, null, null); + } + /// /// Transmits an exception to Raygun asynchronously. /// @@ -363,10 +383,10 @@ public virtual Task SendInBackground(Exception exception, IList tags = n Debug.WriteLine("Could not add message to background queue. Dropping exception: {0}", ex); } } - + FlagAsSent(exception); } - + return Task.CompletedTask; } @@ -381,17 +401,16 @@ public Task SendInBackground(RaygunMessage raygunMessage) { Debug.WriteLine("Could not add message to background queue. Dropping message: {0}", raygunMessage); } - + return Task.CompletedTask; } - protected async Task BuildMessage(Exception exception, - IList tags, - IDictionary userCustomData = null, - RaygunIdentifierMessage userInfo = null, - Action customiseMessage = null) + protected async Task BuildMessage(Exception exception, + IList tags, + IDictionary userCustomData = null, + RaygunIdentifierMessage userInfo = null, + Action customiseMessage = null) { - var message = RaygunMessageBuilder.New(_settings) .SetEnvironmentDetails() .SetMachineName(Environment.MachineName) @@ -415,8 +434,7 @@ protected async Task BuildMessage(Exception exception, return message; } - protected virtual async Task StripAndSend(Exception exception, IList tags, IDictionary userCustomData, - RaygunIdentifierMessage userInfo) + protected virtual async Task StripAndSend(Exception exception, IList tags, IDictionary userCustomData, RaygunIdentifierMessage userInfo) { foreach (var e in StripWrapperExceptions(exception)) { @@ -426,8 +444,7 @@ protected virtual async Task StripAndSend(Exception exception, IList tag protected virtual IEnumerable StripWrapperExceptions(Exception exception) { - if (exception != null && _wrapperExceptions.Any(wrapperException => - exception.GetType() == wrapperException && exception.InnerException != null)) + if (exception != null && _wrapperExceptions.Any(wrapperException => exception.GetType() == wrapperException && exception.InnerException != null)) { var aggregate = exception as AggregateException; @@ -485,35 +502,66 @@ public async Task Send(RaygunMessage raygunMessage, CancellationToken cancellati return; } - var requestMessage = new HttpRequestMessage(HttpMethod.Post, _settings.ApiEndpoint); - requestMessage.Headers.Add("X-ApiKey", _settings.ApiKey); - try { - var message = SimpleJson.SerializeObject(raygunMessage); - requestMessage.Content = new StringContent(message, Encoding.UTF8, "application/json"); + var messagePayload = SimpleJson.SerializeObject(raygunMessage); + await SendPayloadAsync(messagePayload, _settings.ApiKey, true, cancellationToken); + } + catch (Exception ex) + { + if (_settings.ThrowOnError) + { + throw; + } + } + } - var result = await _client.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + private async Task SendOfflinePayloadAsync(string payload, string apiKey, CancellationToken cancellationToken) + { + await SendPayloadAsync(payload, apiKey, false, cancellationToken); + } - if (!result.IsSuccessStatusCode) - { - Debug.WriteLine($"Error Logging Exception to Raygun {result.ReasonPhrase}"); + private async Task SendPayloadAsync(string payload, string apiKey, bool useOfflineStore, CancellationToken cancellationToken) + { + HttpResponseMessage response = null; + var requestMessage = new HttpRequestMessage(HttpMethod.Post, _settings.ApiEndpoint); + requestMessage.Headers.Add("X-ApiKey", apiKey); + requestMessage.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - if (_settings.ThrowOnError) - { - throw new Exception("Could not log to Raygun"); - } - } + try + { + response = await _client.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + + // Raises an HttpRequestException if the request was unsuccessful + response.EnsureSuccessStatusCode(); } catch (Exception ex) { - Debug.WriteLine($"Error Logging Exception to Raygun {ex.Message}"); + Debug.WriteLine($"Error Logging Exception to Raygun: {ex.Message}"); - if (_settings.ThrowOnError) + // If we got no response or an unexpected server error then add it to offline storage to send later + // we get no response if the send call fails for any other reason (network etc.) + // checking that response.StatusCode >= 500, is an efficient check for any server errors + var shouldStoreMessage = response is null || response.StatusCode >= HttpStatusCode.InternalServerError; + + if (useOfflineStore && shouldStoreMessage) { - throw; + await SaveMessageToOfflineCache(payload, apiKey, cancellationToken); } + + throw; + } + } + + private async Task SaveMessageToOfflineCache(string messagePayload, string apiKey, CancellationToken cancellationToken) + { + // Can't store it anywhere + if (_settings.OfflineStore is null) + { + return false; } + + return await _settings.OfflineStore.Save(messagePayload, apiKey, cancellationToken); } } } \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Common/RaygunSettings.cs b/Mindscape.Raygun4Net.NetCore.Common/RaygunSettingsBase.cs similarity index 93% rename from Mindscape.Raygun4Net.NetCore.Common/RaygunSettings.cs rename to Mindscape.Raygun4Net.NetCore.Common/RaygunSettingsBase.cs index ce92128f..ac39ed31 100644 --- a/Mindscape.Raygun4Net.NetCore.Common/RaygunSettings.cs +++ b/Mindscape.Raygun4Net.NetCore.Common/RaygunSettingsBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Mindscape.Raygun4Net.Offline; namespace Mindscape.Raygun4Net; @@ -23,8 +24,8 @@ public RaygunSettingsBase() /// Raygun Application API Key, can be found in the Raygun application dashboard by clicking the "Application settings" button /// public string ApiKey { get; set; } - - public Uri ApiEndpoint { get; set; } = new (DefaultApiEndPoint); + + public Uri ApiEndpoint { get; set; } = new(DefaultApiEndPoint); public bool ThrowOnError { get; set; } @@ -62,4 +63,6 @@ public RaygunSettingsBase() /// Passing in * will be ignored as we do not want to support collecting 'all' environment variables for security reasons. /// public IList EnvironmentVariables { get; set; } = new List(); + + public OfflineStoreBase OfflineStore { get; set; } } \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Tests/Model/FakeRaygunClient.cs b/Mindscape.Raygun4Net.NetCore.Tests/Model/FakeRaygunClient.cs index 801d1b4e..cd2002ae 100644 --- a/Mindscape.Raygun4Net.NetCore.Tests/Model/FakeRaygunClient.cs +++ b/Mindscape.Raygun4Net.NetCore.Tests/Model/FakeRaygunClient.cs @@ -5,52 +5,52 @@ namespace Mindscape.Raygun4Net.NetCore.Tests { - public class FakeRaygunClient : RaygunClient - { - public FakeRaygunClient() : base(new RaygunSettings { ApiKey = string.Empty}, null, null) - { - } - - public FakeRaygunClient(string apiKey) : base(new RaygunSettings { ApiKey = apiKey}, null, null) - { - } - - public FakeRaygunClient(RaygunSettings settings) : base(settings, null, null) - { - } - - public RaygunMessage ExposeBuildMessage(Exception exception, [Optional] IList tags, [Optional] IDictionary userCustomData, [Optional] RaygunIdentifierMessage user) - { - var task = BuildMessage(exception, tags, userCustomData, user); - - task.Wait(); - - return task.Result; - } - - public bool ExposeValidateApiKey() - { - return ValidateApiKey(); - } - - public bool ExposeOnSendingMessage(RaygunMessage raygunMessage) - { - return OnSendingMessage(raygunMessage); - } - - public bool ExposeCanSend(Exception exception) - { - return CanSend(exception); - } - - public void ExposeFlagAsSent(Exception exception) - { - FlagAsSent(exception); - } - - public IEnumerable ExposeStripWrapperExceptions(Exception exception) - { - return StripWrapperExceptions(exception); - } - } -} + public class FakeRaygunClient : RaygunClient + { + public FakeRaygunClient() : base(new RaygunSettings { ApiKey = string.Empty }, null, null) + { + } + + public FakeRaygunClient(string apiKey) : base(new RaygunSettings { ApiKey = apiKey }, null, null) + { + } + + public FakeRaygunClient(RaygunSettings settings) : base(settings, null, null) + { + } + + public RaygunMessage ExposeBuildMessage(Exception exception, [Optional] IList tags, [Optional] IDictionary userCustomData, [Optional] RaygunIdentifierMessage user) + { + var task = BuildMessage(exception, tags, userCustomData, user); + + task.Wait(); + + return task.Result; + } + + public bool ExposeValidateApiKey() + { + return ValidateApiKey(); + } + + public bool ExposeOnSendingMessage(RaygunMessage raygunMessage) + { + return OnSendingMessage(raygunMessage); + } + + public bool ExposeCanSend(Exception exception) + { + return CanSend(exception); + } + + public void ExposeFlagAsSent(Exception exception) + { + FlagAsSent(exception); + } + + public IEnumerable ExposeStripWrapperExceptions(Exception exception) + { + return StripWrapperExceptions(exception); + } + } +} \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore.Tests/RaygunMessageBuilderTests.cs b/Mindscape.Raygun4Net.NetCore.Tests/RaygunMessageBuilderTests.cs index 81b02e8f..89dada8f 100644 --- a/Mindscape.Raygun4Net.NetCore.Tests/RaygunMessageBuilderTests.cs +++ b/Mindscape.Raygun4Net.NetCore.Tests/RaygunMessageBuilderTests.cs @@ -212,6 +212,8 @@ public void SetEnvironmentDetails_WithEnvironmentVariables_Contains() "*_Banana*" } }; + + RaygunEnvironmentMessageBuilder.LastUpdate = DateTime.MinValue; var builder = RaygunMessageBuilder.New(settings) .SetEnvironmentDetails(); @@ -242,6 +244,8 @@ public void SetEnvironmentDetails_WithEnvironmentVariables_Star_ShouldReturnNoth "* *", } }; + + RaygunEnvironmentMessageBuilder.LastUpdate = DateTime.MinValue; var builder = RaygunMessageBuilder.New(settings) .SetEnvironmentDetails(); @@ -269,6 +273,8 @@ public void SetEnvironmentDetails_WithEnvironmentVariablesWithDifferentCasing_Sh search } }; + + RaygunEnvironmentMessageBuilder.LastUpdate = DateTime.MinValue; var builder = RaygunMessageBuilder.New(settings) .SetEnvironmentDetails(); diff --git a/Mindscape.Raygun4Net.NetCore/RaygunClient.cs b/Mindscape.Raygun4Net.NetCore/RaygunClient.cs index 7c31704b..8005318a 100644 --- a/Mindscape.Raygun4Net.NetCore/RaygunClient.cs +++ b/Mindscape.Raygun4Net.NetCore/RaygunClient.cs @@ -9,19 +9,19 @@ public class RaygunClient : RaygunClientBase public RaygunClient(string apiKey) : base(new RaygunSettings { ApiKey = apiKey }) { } - + [Obsolete("Use the RaygunClient(RaygunSettings, HttpClient) constructor instead")] public RaygunClient(string apiKey, HttpClient httpClient) : base(new RaygunSettings { ApiKey = apiKey }, httpClient) { } - + // ReSharper disable MemberCanBeProtected.Global // ReSharper disable SuggestBaseTypeForParameterInConstructor // ReSharper disable UnusedMember.Global public RaygunClient(RaygunSettings settings) : base(settings) { } - + public RaygunClient(RaygunSettings settings, HttpClient httpClient) : base(settings, httpClient) { } @@ -29,10 +29,11 @@ public RaygunClient(RaygunSettings settings, HttpClient httpClient) : base(setti public RaygunClient(RaygunSettings settings, IRaygunUserProvider userProvider) : base(settings, userProvider) { } - + public RaygunClient(RaygunSettings settings, HttpClient httpClient, IRaygunUserProvider userProvider) : base(settings, httpClient, userProvider) { } + // ReSharper restore MemberCanBeProtected.Global // ReSharper restore SuggestBaseTypeForParameterInConstructor // ReSharper restore UnusedMember.Global diff --git a/Mindscape.Raygun4Net.NetCore/Storage/FileSystemCrashReportStore.cs b/Mindscape.Raygun4Net.NetCore/Storage/FileSystemCrashReportStore.cs new file mode 100644 index 00000000..6f5b1178 --- /dev/null +++ b/Mindscape.Raygun4Net.NetCore/Storage/FileSystemCrashReportStore.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Mindscape.Raygun4Net.Offline; + +namespace Mindscape.Raygun4Net.Storage; + +public class FileSystemCrashReportStore : OfflineStoreBase +{ + private const string CacheFileExtension = "rgcrash"; + private readonly string _storageDirectory; + private readonly int _maxOfflineFiles; + private readonly ConcurrentDictionary _cacheLocationMap = new(); + + public FileSystemCrashReportStore(IBackgroundSendStrategy backgroundSendStrategy, string storageDirectory, int maxOfflineFiles = 50) + : base(backgroundSendStrategy) + { + _storageDirectory = storageDirectory; + _maxOfflineFiles = maxOfflineFiles; + } + + public override async Task> GetAll(CancellationToken cancellationToken) + { + var crashFiles = Directory.GetFiles(_storageDirectory, $"*.{CacheFileExtension}"); + var errorRecords = new List(); + + foreach (var crashFile in crashFiles) + { + try + { + using var fileStream = new FileStream(crashFile, FileMode.Open, FileAccess.Read); + using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + using var reader = new StreamReader(gzipStream, Encoding.UTF8); + + Trace.WriteLine($"Attempting to load offline crash at {crashFile}"); + var jsonString = await reader.ReadToEndAsync(); + var errorRecord = SimpleJson.DeserializeObject(jsonString); + + errorRecords.Add(errorRecord); + _cacheLocationMap[errorRecord.Id] = crashFile; + } + catch (Exception ex) + { + Debug.WriteLine("Error deserializing offline crash: {0}", ex.ToString()); + File.Move(crashFile, $"{crashFile}.failed"); + } + } + + return errorRecords; + } + + public override async Task Save(string payload, string apiKey, CancellationToken cancellationToken) + { + var cacheEntryId = Guid.NewGuid(); + try + { + Directory.CreateDirectory(_storageDirectory); + + var crashFiles = Directory.GetFiles(_storageDirectory, $"*.{CacheFileExtension}"); + if (crashFiles.Length >= _maxOfflineFiles) + { + Debug.WriteLine($"Maximum offline files of [{_maxOfflineFiles}] has been reached"); + return false; + } + + var cacheEntry = new CrashReportStoreEntry + { + Id = cacheEntryId, + ApiKey = apiKey, + MessagePayload = payload + }; + var filePath = GetFilePathForCacheEntry(cacheEntryId); + var jsonContent = SimpleJson.SerializeObject(cacheEntry); + + using var fileStream = new FileStream(filePath, FileMode.Create); + using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal); + using var writer = new StreamWriter(gzipStream, Encoding.UTF8); + + Trace.WriteLine($"Saving crash {cacheEntry.Id} to {filePath}"); + await writer.WriteAsync(jsonContent); + await writer.FlushAsync(); + + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Error adding crash [{cacheEntryId}] to store: {ex}"); + return false; + } + } + + public override Task Remove(Guid cacheId, CancellationToken cancellationToken) + { + try + { + if (_cacheLocationMap.TryGetValue(cacheId, out var filePath)) + { + var result = RemoveFile(filePath); + _cacheLocationMap.TryRemove(cacheId, out _); + return Task.FromResult(result); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error remove crash [{cacheId}] from store: {ex}"); + } + + return Task.FromResult(false); + } + + private static bool RemoveFile(string filePath) + { + if (!File.Exists(filePath)) + { + return false; + } + + File.Delete(filePath); + return true; + } + + private string GetFilePathForCacheEntry(Guid cacheId) + { + return Path.Combine(_storageDirectory, $"{cacheId:N}.{CacheFileExtension}"); + } +} \ No newline at end of file diff --git a/Mindscape.Raygun4Net.NetCore/Storage/LocalApplicationDataCrashReportStore.cs b/Mindscape.Raygun4Net.NetCore/Storage/LocalApplicationDataCrashReportStore.cs new file mode 100644 index 00000000..49a28709 --- /dev/null +++ b/Mindscape.Raygun4Net.NetCore/Storage/LocalApplicationDataCrashReportStore.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Mindscape.Raygun4Net.Offline; + +namespace Mindscape.Raygun4Net.Storage; + +/// +/// Stores a cached copy of crash reports that failed to send in Local App Data +/// Creates a directory if specified, otherwise creates a unique directory based off the location of the application +/// +public sealed class LocalApplicationDataCrashReportStore : FileSystemCrashReportStore +{ + public LocalApplicationDataCrashReportStore(IBackgroundSendStrategy backgroundSendStrategy, string directoryName = null, int maxOfflineFiles = 50) + : base(backgroundSendStrategy, GetLocalAppDirectory(directoryName), maxOfflineFiles) + { + } + + private static string GetLocalAppDirectory(string directoryName) + { + directoryName ??= CreateUniqueDirectoryName(); + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), directoryName); + } + + private static string CreateUniqueDirectoryName() + { + // Try to generate a unique id, from the executable location + var uniqueId = Assembly.GetEntryAssembly()?.Location ?? throw new ApplicationException("Cannot determine unique application id"); + + var uniqueIdHash = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(uniqueId)); + return BitConverter.ToString(uniqueIdHash).Replace("-", "").ToLowerInvariant(); + } +} \ No newline at end of file