Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

File system persistence for sessions #1105

Merged
merged 20 commits into from
Jul 14, 2021
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- ASP.NET Core: fix handled not being set for Handled exceptions ([#1111](https://github.com/getsentry/sentry-dotnet/pull/1111))

### Changed
bruno-garcia marked this conversation as resolved.
Show resolved Hide resolved

- File system persistence for sessions ([#1105](https://github.com/getsentry/sentry-dotnet/pull/1105))

## 3.7.0

### Features
Expand Down
244 changes: 228 additions & 16 deletions src/Sentry/GlobalSessionManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.NetworkInformation;
Expand All @@ -13,13 +14,18 @@ namespace Sentry
// AKA client mode
internal class GlobalSessionManager : ISessionManager
{
private const string PersistedSessionFileName = ".session";

private readonly object _installationIdLock = new();

private readonly ISystemClock _clock;
private readonly SentryOptions _options;

private string? _cachedInstallationId;
private readonly string? _persistenceDirectoryPath;

private string? _resolvedInstallationId;
private Session? _currentSession;
private DateTimeOffset? _lastPauseTimestamp;

// Internal for testing
internal Session? CurrentSession => _currentSession;
Expand All @@ -30,6 +36,10 @@ public GlobalSessionManager(SentryOptions options, ISystemClock clock)
{
_options = options;
_clock = clock;

// TODO: session file should really be process-isolated, but we
// don't have a proper mechanism for that right now.
_persistenceDirectoryPath = options.TryGetDsnSpecificCacheDirectoryPath();
}

public GlobalSessionManager(SentryOptions options)
Expand All @@ -41,14 +51,13 @@ public GlobalSessionManager(SentryOptions options)
{
try
{
var directoryPath = Path.Combine(
// Store in cache directory or fall back to appdata
!string.IsNullOrWhiteSpace(_options.CacheDirectoryPath)
? _options.CacheDirectoryPath
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
// Put under "Sentry" subdirectory
"Sentry"
);
var directoryPath =
_persistenceDirectoryPath
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Sentry",
_options.Dsn!.GetHashString()
);

Directory.CreateDirectory(directoryPath);

Expand Down Expand Up @@ -139,9 +148,9 @@ public GlobalSessionManager(SentryOptions options)
private string? TryGetInstallationId()
{
// Installation ID could have already been resolved by this point
if (!string.IsNullOrWhiteSpace(_cachedInstallationId))
if (!string.IsNullOrWhiteSpace(_resolvedInstallationId))
{
return _cachedInstallationId;
return _resolvedInstallationId;
}

// Resolve installation ID in a locked manner to guarantee consistency because ID can be non-deterministic.
Expand All @@ -150,9 +159,9 @@ public GlobalSessionManager(SentryOptions options)
{
// We may have acquired the lock after another thread has already resolved
// installation ID, so check the cache one more time before proceeding with I/O.
if (!string.IsNullOrWhiteSpace(_cachedInstallationId))
if (!string.IsNullOrWhiteSpace(_resolvedInstallationId))
{
return _cachedInstallationId;
return _resolvedInstallationId;
}

var id =
Expand All @@ -174,7 +183,141 @@ public GlobalSessionManager(SentryOptions options)
);
}

return _cachedInstallationId = id;
return _resolvedInstallationId = id;
}
}

// Take pause timestamp directly instead of referencing _lastPauseTimestamp to avoid
// potential race conditions.
private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp = null)
{
_options.DiagnosticLogger?.LogDebug("Persisting session (SID: '{0}') to a file.", update.Id);

if (string.IsNullOrWhiteSpace(_persistenceDirectoryPath))
{
_options.DiagnosticLogger?.LogDebug("Persistence directory is not set, returning.");
return;
}

try
{
Directory.CreateDirectory(_persistenceDirectoryPath);

_options.DiagnosticLogger?.LogDebug(
"Created persistence directory for session file '{0}'.",
_persistenceDirectoryPath
);

var filePath = Path.Combine(_persistenceDirectoryPath, PersistedSessionFileName);

var persistedSessionUpdate = new PersistedSessionUpdate(update, pauseTimestamp);
persistedSessionUpdate.WriteToFile(filePath);

_options.DiagnosticLogger?.LogInfo(
"Persisted session to a file '{0}'.",
filePath
);
}
catch (Exception ex)
{
_options.DiagnosticLogger?.LogError(
"Failed to persist session on the file system.",
ex
);
}
}

private void DeletePersistedSession()
{
_options.DiagnosticLogger?.LogDebug("Deleting persisted session file.");

if (string.IsNullOrWhiteSpace(_persistenceDirectoryPath))
{
_options.DiagnosticLogger?.LogDebug("Persistence directory is not set, returning.");
return;
}

try
{
var filePath = Path.Combine(_persistenceDirectoryPath, PersistedSessionFileName);
bruno-garcia marked this conversation as resolved.
Show resolved Hide resolved

// Try to log the contents of the session file before we delete it
if (_options.DiagnosticLogger?.IsEnabled(SentryLevel.Debug) ?? false)
{
try
{
_options.DiagnosticLogger?.LogDebug(
"Deleting persisted session file with contents: {0}",
File.ReadAllText(filePath)
);
}
catch (Exception ex)
{
_options.DiagnosticLogger?.LogError(
"Failed to read the contents of persisted session file '{0}'.",
ex,
filePath
);
}
}

File.Delete(filePath);

_options.DiagnosticLogger?.LogInfo(
"Deleted persisted session file '{0}'.",
filePath
);
}
catch (Exception ex)
{
_options.DiagnosticLogger?.LogError(
"Failed to delete persisted session from the file system.",
ex
bruno-garcia marked this conversation as resolved.
Show resolved Hide resolved
);
}
}

public SessionUpdate? TryRecoverPersistedSession()
{
_options.DiagnosticLogger?.LogDebug("Attempting to recover persisted session from file.");

if (string.IsNullOrWhiteSpace(_persistenceDirectoryPath))
{
_options.DiagnosticLogger?.LogDebug("Persistence directory is not set, returning.");
return null;
Tyrrrz marked this conversation as resolved.
Show resolved Hide resolved
}

try
{
var filePath = Path.Combine(_persistenceDirectoryPath, PersistedSessionFileName);
bruno-garcia marked this conversation as resolved.
Show resolved Hide resolved
var recoveredUpdate = PersistedSessionUpdate.FromJson(Json.Load(filePath));

// Create a session update to end the recovered session
return new SessionUpdate(
recoveredUpdate.Update,
// We're recovering an ongoing session, so this can never be initial
false,
// If the session was paused, then use that as timestamp, otherwise use current timestamp
recoveredUpdate.PauseTimestamp ?? _clock.GetUtcNow(),
// Increment sequence number
recoveredUpdate.Update.SequenceNumber + 1,
// If the session was paused then end normally, otherwise abnormal or crashed
_options.CrashedLastRun switch
{
_ when recoveredUpdate.PauseTimestamp is not null => SessionEndStatus.Exited,
{ } crashedLastRun => crashedLastRun() ? SessionEndStatus.Crashed : SessionEndStatus.Abnormal,
_ => SessionEndStatus.Abnormal
}
);
}
catch (Exception ex)
{
_options.DiagnosticLogger?.LogError(
"Failed to recover persisted session from the file system",
ex
);

return null;
}
}

Expand Down Expand Up @@ -216,7 +359,11 @@ public GlobalSessionManager(SentryOptions options)
session.Id, session.DistinctId
);

return session.CreateUpdate(true, _clock.GetUtcNow());
var update = session.CreateUpdate(true, _clock.GetUtcNow());

PersistSession(update);

return update;
}

private SessionUpdate EndSession(Session session, DateTimeOffset timestamp, SessionEndStatus status)
Expand All @@ -226,7 +373,11 @@ private SessionUpdate EndSession(Session session, DateTimeOffset timestamp, Sess
session.Id, session.DistinctId, status
);

return session.CreateUpdate(false, timestamp, status);
var update = session.CreateUpdate(false, timestamp, status);

DeletePersistedSession();

return update;
}

public SessionUpdate? EndSession(DateTimeOffset timestamp, SessionEndStatus status)
Expand All @@ -246,6 +397,67 @@ private SessionUpdate EndSession(Session session, DateTimeOffset timestamp, Sess

public SessionUpdate? EndSession(SessionEndStatus status) => EndSession(_clock.GetUtcNow(), status);

public void PauseSession()
{
if (_currentSession is { } session)
{
var now = _clock.GetUtcNow();
_lastPauseTimestamp = now;
PersistSession(session.CreateUpdate(false, now), now);
}
}

public IReadOnlyList<SessionUpdate> ResumeSession()
{
// Ensure a session has been paused before
if (_lastPauseTimestamp is not { } sessionPauseTimestamp)
{
_options.DiagnosticLogger?.LogDebug(
"Attempted to resume a session, but the current session hasn't been paused."
);

return Array.Empty<SessionUpdate>();
}

// Reset the pause timestamp since the session is about to be resumed
_lastPauseTimestamp = null;

// If the pause duration exceeded tracking interval, start a new session
// (otherwise do nothing)
var pauseDuration = (_clock.GetUtcNow() - sessionPauseTimestamp).Duration();
if (pauseDuration >= _options.AutoSessionTrackingInterval)
{
_options.DiagnosticLogger?.LogDebug(
"Paused session has been paused for {0}, which is longer than the configured timeout. " +
"Starting a new session instead of resuming this one.",
pauseDuration
);

var updates = new List<SessionUpdate>(2);

// End current session
if (EndSession(sessionPauseTimestamp, SessionEndStatus.Exited) is { } endUpdate)
{
updates.Add(endUpdate);
}

// Start a new session
if (StartSession() is { } startUpdate)
{
updates.Add(startUpdate);
}

return updates;
}
Tyrrrz marked this conversation as resolved.
Show resolved Hide resolved

_options.DiagnosticLogger?.LogDebug(
"Paused session has been paused for {0}, which is shorter than the configured timeout.",
pauseDuration
);

return Array.Empty<SessionUpdate>();
}

public SessionUpdate? ReportError()
{
if (_currentSession is { } session)
Expand Down
13 changes: 13 additions & 0 deletions src/Sentry/IJsonSerializable.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Text.Json;

namespace Sentry
Expand All @@ -16,4 +17,16 @@ public interface IJsonSerializable
/// </remarks>
void WriteTo(Utf8JsonWriter writer);
}

internal static class JsonSerializableExtensions
{
public static void WriteToFile(this IJsonSerializable serializable, string filePath)
{
using var file = File.Create(filePath);
using var writer = new Utf8JsonWriter(file);

serializable.WriteTo(writer);
writer.Flush();
}
}
}
7 changes: 7 additions & 0 deletions src/Sentry/ISessionManager.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
using System;
using System.Collections.Generic;

namespace Sentry
{
internal interface ISessionManager
{
bool IsSessionActive { get; }

SessionUpdate? TryRecoverPersistedSession();

SessionUpdate? StartSession();

SessionUpdate? EndSession(DateTimeOffset timestamp, SessionEndStatus status);

SessionUpdate? EndSession(SessionEndStatus status);

void PauseSession();

IReadOnlyList<SessionUpdate> ResumeSession();

SessionUpdate? ReportError();
}
}
Loading