Post

Integrating Sentry in .NET MAUI with Local File Logging

Integrating Sentry in .NET MAUI with Local File Logging

Sentry is a powerful tool for error tracking and diagnostics, and in my TwistReader MAUI app, I wanted to go beyond just capturing errors in production. The goal: use Sentry for all logging, but persist the data locally unless the user explicitly allows cloud reporting.

This post walks you through the full implementation — from capturing events and enriching them with device context, to storing Sentry event data in structured JSON using the NReco.Logging.File package for proper file handling.

Why combine Sentry and local logging?

In real-world mobile apps, especially privacy-conscious ones, we often can’t (or don’t want to) send every crash and log event to the cloud. But we still need diagnostics — especially when users report problems manually.

Sentry already collects an amazing amount of context:

  • Device and OS info
  • App version
  • Network requests
  • Breadcrumbs (aka event history)

So the idea was simple: reuse this rich Sentry event data for all application diagnostics - no matter if the user allows sending the data to the cloud or not.

In my case, the trigger was a classic support situation: a user reported a bug I couldn’t reproduce myself. Without logs, debugging was pure speculation — with locally stored Sentry events, I was able to trace the issue exactly, even though the user had disabled cloud reporting. This is where this setup really shines.

Setup

The Nuget packages

First, we need to install some Nuget packages:

  • dotnet add package Sentry.Maui
  • dotnet add package NReco.Logging.File

I chose NReco.Logging.File because it’s easy to configure, works reliably across platforms, and doesn’t introduce platform-specific quirks.

Configure the File Logger

Let’s setup our file logger next. Here is my configuration in the MAUI application builder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
builder.Services.AddLogging(loggingBuilder =>
{
    var logFilePath = System.IO.Path.Combine(FileSystem.Current.CacheDirectory, "twistReader.log");

    loggingBuilder.AddFile(logFilePath, fileLoggerOpts =>
    {
        fileLoggerOpts.FormatLogFileName = new Func<string, string>(fName => string.Format(fName, DateTime.UtcNow));
        fileLoggerOpts.MaxRollingFiles = 5;
        fileLoggerOpts.Append = true;
        fileLoggerOpts.FileSizeLimitBytes = 10_000_000;
        fileLoggerOpts.RollingFilesConvention = FileLoggerOptions.FileRollingConvention.Descending;
        fileLoggerOpts.MinLevel = LogLevel.Information;
    });
});

This configuration ensures that our log files do not grow exponentially. I’m using the CacheDirectory here to ensure temporary but persistent logging during the app’s runtime lifecycle. Depending on your use case, storing logs in AppDataDirectory might be a better fit — especially if you want them to survive app reinstalls.

Logging Interface

My app already uses ILogger<T>, so I wrapped the logic in a ISentryEventLogger abstraction:

1
2
3
4
public interface ISentryEventLogger
{
    void LogEvent(SentryEvent sentryEvent);
}

Implementation

Here’s the concrete implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public class SentryEventLogger : ISentryEventLogger
{
    private readonly ILogger<SentryEventLogger> _logger;
    private readonly ISettingsService _settingsService;
    private readonly string _appVersion;
    private readonly string _platform;

    public SentryEventLogger(ILogger<SentryEventLogger> logger, ISettingsService settingsService)
    {
        _logger = logger;
        _settingsService = settingsService;
        _appVersion = AppInfo.VersionString;

#if ANDROID
        _platform = "Android";
#elif IOS
        _platform = "iOS";
#elif MACCATALYST
        _platform = "MacCatalyst";
#else
        _platform = "Unknown";
#endif
    }

    public void LogEvent(SentryEvent sentryEvent)
    {
        if (sentryEvent == null) return;

        sentryEvent.SetTag("app.version", _appVersion);
        sentryEvent.SetTag("platform", _platform);
        sentryEvent.SetTag("cloud.logging.allowed", _settingsService.ShouldAllowCloudLogging.ToString());

        var logObject = new
        {
            Timestamp = sentryEvent.Timestamp,
            Level = sentryEvent.Level?.ToString(),
            Logger = sentryEvent.Logger,
            Message = sentryEvent.Message?.Message,
            Exception = sentryEvent.Exception?.ToString(),
            User = sentryEvent.User,
            Tags = sentryEvent.Tags,
            Contexts = sentryEvent.Contexts,
            Extra = sentryEvent.Extra,
            Request = sentryEvent.Request != null ? new
            {
                sentryEvent.Request.Method,
                sentryEvent.Request.Url,
                sentryEvent.Request.QueryString,
                sentryEvent.Request.Headers,
                sentryEvent.Request.Cookies,
                sentryEvent.Request.Data
            } : null,
            Breadcrumbs = sentryEvent.Breadcrumbs?.Select(b => new
            {
                b.Timestamp,
                b.Level,
                b.Category,
                b.Type,
                b.Message,
                b.Data
            }).ToList()
        };

        var json = JsonSerializer.Serialize(logObject, new JsonSerializerOptions
        {
            WriteIndented = false,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
        });

        var level = MapSentryLevelToLogLevel(sentryEvent.Level ?? SentryLevel.Error);
        _logger.Log(level, "[Sentry] {Json}", json);
    }

    private static LogLevel MapSentryLevelToLogLevel(SentryLevel level)
    {
        return level switch
        {
            SentryLevel.Debug => LogLevel.Debug,
            SentryLevel.Info => LogLevel.Information,
            SentryLevel.Warning => LogLevel.Warning,
            SentryLevel.Error => LogLevel.Error,
            SentryLevel.Fatal => LogLevel.Critical,
            _ => LogLevel.Error
        };
    }
}

This ensures all captured events — even if not sent to Sentry — are logged in a clean, structured way locally.

Optional cloud forwarding

In my Sentry options, I use the SetBeforeSend event to check whether cloud reporting is allowed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
builder.UseSentry(options =>
{
    options.Dsn = "your dsn here";

    options.SetBeforeSend(sentryEvent =>
    {
        try
        {
            var isInternalLoop =
                sentryEvent.Logger?.Contains(nameof(SentryEventLogger)) == true;

            if (isInternalLoop)
                return null;
            
            var serviceProvider = builder.Services.BuildServiceProvider();
            var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
            var sentryEventLogger = serviceProvider.GetRequiredService<ISentryEventLogger>();

            sentryEventLogger.LogEvent(sentryEvent);

            return settingsService.AllowCloudLogging ? sentryEvent : null;
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Failed to process Sentry event: {ex.Message}");
            return null;
        }
    });
});

Replace "your dsn here" with your actual Sentry DSN.

You may have noticed the isInternalLoop check. This is needed to suppress log entries coming from the SentryEventLogger class itself. If you do not filter these events, you will run into a StackOverflowException pretty fast. Trust me, I “tested” that for you already.

Now all that is left is to register the service with the ServiceProvider:

1
builder.Services.AddSingleton<ISentryEventLogger, SentryEventLogger>();

Conclusion

If you’re building a .NET MAUI app and need flexible error tracking — I highly recommend this approach. You get the full power of Sentry’s event model, but stay in control of how and where the data goes. This setup works great as a foundation for a consent-based logging system — especially useful when building cross-platform MAUI apps with privacy in mind.

As always, I hope this blog post is helpful for some of you.

Until the next post, happy coding, everyone!


The title image is generated with AI (ChatGPT) based on my prompts.

This post is licensed under CC BY 4.0 by the author.