sentry-dotnet icon indicating copy to clipboard operation
sentry-dotnet copied to clipboard

Unhandled exceptions in generic host don't have breadcrumbs

Open Mapiarz opened this issue 3 years ago • 5 comments
trafficstars

Package

Sentry.Serilog

.NET Flavor

.NET

.NET Version

6.0.7

OS

Linux

SDK Version

3.20.0

Self-Hosted Sentry Version

No response

Steps to Reproduce

I'm integrating Sentry into a dotnet C# console app that uses Serilog and generic host.

To reproduce:

  1. See snippet below for a simple example app
  2. Set up your DSN in code
  3. Install dependencies, run it.

Here's relevant code that shows how app is all hooked up:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Sentry;
using Serilog;

using (var host = CreateHostBuilder().Build())
{
    await host.StartAsync();

    var lifetime = host.Services.GetRequiredService<IHostApplicationLifetime>();

    // Start Martini CLI
    var martiniCLI = host.Services.GetRequiredService<MartiniCLI>();
    await martiniCLI.RunAsync(lifetime.ApplicationStopping);

    // Once completed, automatically stop the application
    lifetime.StopApplication();

    await host.WaitForShutdownAsync();
}

static IHostBuilder CreateHostBuilder() =>
    new HostBuilder()
        .UseContentRoot(Directory.GetCurrentDirectory())
        .UseConsoleLifetime()
        .ConfigureHostConfiguration(hostConfigBuilder => hostConfigBuilder.AddEnvironmentVariables(prefix: "DOTNET_"))
        .ConfigureServices(services => services.AddTransient<MartiniCLI>())
        .ConfigureLogging((_, logging) =>
        {
            logging.AddSimpleConsole().SetMinimumLevel(LogLevel.Debug);

            // Configure sentry logger
            var sentryLogger = new LoggerConfiguration()
                .MinimumLevel.Debug()
                .WriteTo.Sentry(debug: true,
                                minimumBreadcrumbLevel: Serilog.Events.LogEventLevel.Debug,
                                environment: "Development",
                                dsn: "")
                .CreateLogger();
            logging.AddSerilog(sentryLogger);
        });

class MartiniCLI
{
    readonly ILogger<MartiniCLI> _logger;

    public MartiniCLI(ILogger<MartiniCLI> logger)
    {
        _logger = logger;
    }

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Start");
        _logger.LogDebug("Debug log");
        SentrySdk.AddBreadcrumb("Manually added breadcrumb", "info", level: BreadcrumbLevel.Debug);
        _logger.LogInformation("Delay for 5 seconds");
        await Task.Delay(5000, cancellationToken);
        _logger.LogInformation("Delay finished");
        throw new Exception("Unhandled exception!");
        // _logger.LogError("Fake error!");
    }
}

Expected Result

I would expect errors (both unhandled exceptions and _logger.LogError) to be logged in Sentry with breadcrumbs.

Actual Result

Errors logged manually using _logger.LogError are logged in Sentry with breadcrumbs. Unhandled exceptions are logged in Sentry but without breadcrumbs.

Mapiarz avatar Jul 25 '22 15:07 Mapiarz

Thanks for the report and the repro. We'll look into this and get back to you asap. Thanks.

mattjohnsonpint avatar Jul 26 '22 15:07 mattjohnsonpint

I can reproduce this using the code you provided. I'm still looking into the cause. Thanks for your patience.

mattjohnsonpint avatar Jul 26 '22 21:07 mattjohnsonpint

Still investigating, but I have eliminated Serilog and can reproduce with just a generic worker host.

<Project Sdk="Microsoft.NET.Sdk.Worker">
    <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
        <PackageReference Include="Sentry.Extensions.Logging" Version="3.20.1" />
    </ItemGroup>
</Project>
using MyWorkerService;

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services => { services.AddHostedService<Worker>(); })
    .ConfigureLogging(builder =>
    {
        builder.AddSentry(o =>
        {
            o.Debug = true;
            o.Dsn = "...";
        });
    })
    .Build();

await host.RunAsync();
using Sentry;

namespace MyWorkerService;

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;
    private readonly IHub _hub;

    public Worker(ILogger<Worker> logger, IHub hub)
    {
        _logger = logger;
        _hub = hub;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Logger generated breadcrumb");
        _hub.AddBreadcrumb("Manually added breadcrumb");

        throw new Exception("Unhandled exception in worker");
        // _hub.CaptureException(new Exception("Manually captured exception in worker"));
    }
}

The unhandled exception comes through, but not the breadcrumbs.

Swap the last lines to capture an exception instead, and the breadcrumbs are present.

The same thing in a plain console app without the generic host works fine.

mattjohnsonpint avatar Jul 26 '22 23:07 mattjohnsonpint

Just to add a workaround, you can try/catch around the entire worker logic and manually capture the exception. If doing so, you'll want to flag it as "unhandled" like this:

    using Sentry.Protocol;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            await DoExecuteAsync(stoppingToken);
        }
        catch (Exception ex)
        {
            ex.Data[Mechanism.HandledKey] = false;
            ex.Data[Mechanism.MechanismKey] = "Worker.UnhandledException";
            _hub.CaptureException(ex);
        }
    }
    
    private Task DoExecuteAsync(CancellationToken stoppingToken)
    {
        // ... the actual worker code
    }

Or in the originally reported code without changing anything else:

    using Sentry.Protocol;

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        try
        {
            await DoRunAsync(cancellationToken);
        }
        catch (Exception ex)
        {
            ex.Data[Mechanism.HandledKey] = false;
            ex.Data[Mechanism.MechanismKey] = "Worker.UnhandledException";
            SentrySdk.CaptureException(ex);
        }
    }
    
    private async Task DoRunAsync(CancellationToken cancellationToken)
    {
        // ... the actual worker code
    }

mattjohnsonpint avatar Jul 26 '22 23:07 mattjohnsonpint

Since there's a viable workaround, I suggest using that for now until we can get a better fix in place.

Thanks.

mattjohnsonpint avatar Jul 26 '22 23:07 mattjohnsonpint