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