Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/adr/0003-fatal-exceptions-end-the-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ that the application failed and need something they can pass to support.

- No work survives a fatal exception; a crash is visible, never silent.
- The report file is overwritten by the next crash: it serves the dialog that shows it, not as a history.
- Exceptions raised before the handler is installed are not covered; see
- The handler is installed before logging and telemetry exist: a fatal exception during startup gets the report and
the dialog, but no log entry or telemetry; see
[diagnostics](../design/diagnostics.md#startup-coverage-and-fatal-reporting).
- Code on the finalizer thread, where unobserved task exceptions arrive, must never show UI or wait.
5 changes: 3 additions & 2 deletions docs/adr/0005-policy-failures-stop-startup.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ key that cannot be read.

## Consequences

- A policy registry key that cannot be read ends the process before the global exception handler exists: there is
no report and no dialog, only the runtime's Event Log entry. See
- A policy registry key that cannot be read ends the process through the global exception handler: the user sees
the crash dialog and the report is saved, but nothing is logged or tracked, because the bootstrap services are
not attached to the handler yet. See
[diagnostics](../design/diagnostics.md#startup-coverage-and-fatal-reporting).
- Defaults replace user settings only for the current session; how a corrupt settings file is kept and reported to
the user is outside this decision.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ after the bootstrap services and the host are built, so a secondary process stil

| Resource | Lifetime owner | Constraint for changes |
| --- | --- | --- |
| Bootstrap telemetry and logger factory | [BootstrapContext](../../src/Host/BootstrapContext.cs) | Keep them available until host disposal completes; telemetry is disposed before the logger factory |
| Bootstrap telemetry and logger factory | [BootstrapContext](../../src/Host/BootstrapContext.cs) | Keep them available until host disposal completes; the exception handler is detached first, then telemetry is disposed before the logger factory |
| Bootstrap policy service and user settings | Bootstrap creates or obtains them; the container receives those instances | Preserve the shared instances so consumers do not silently use different configuration snapshots |
| Application instance and its pipe server | The host container owns `IBravoApplicationInstance`; that instance owns the pipe server | Releasing the pipe ends ownership of the application instance |
| Main window | `BravoApplication.Run` | The window is created through DI but is not registered as a container-owned service |
Expand Down
25 changes: 17 additions & 8 deletions docs/design/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Exception reporting in the .NET host. The frontend has a separate

| Origin | Process outcome | Current reporting |
| --- | --- | --- |
| `AppDomain.UnhandledException`, including WinForms exceptions routed through `ThrowException` | Runtime terminates after the handler returns | Error log; telemetry subject to consent; saved report and crash dialog |
| `AppDomain.UnhandledException`, including WinForms exceptions routed through `ThrowException` | Runtime terminates after the handler returns | Saved report and crash dialog; after the bootstrap, also error log and telemetry subject to consent |
| `TaskScheduler.UnobservedTaskException` | Continues | Error log and telemetry subject to consent; no dialog or shutdown flush |
| Exception caught by an operation that can recover or return an error | Continues | Reporting belongs to the operation or its API boundary; see [handled exceptions](#handled-exceptions) |

Expand All @@ -18,13 +18,17 @@ ends the process: [ADR 0003](../adr/0003-fatal-exceptions-end-the-process.md).

## Startup coverage and fatal reporting

[Program](../../src/Program.cs) installs global handling after `BravoApplicationConfiguration.Initialize` and the
bootstrap, before building the host. Host construction and everything after it are covered. Directory and proxy
setup, the WebView2 runtime check and bootstrap creation are not: an exception there ends the process with only the
runtime's own Event Log entry, without a report or a dialog.
[Program](../../src/Program.cs) installs global handling right after `ApplicationConfiguration.Initialize`, which
enables the visual styles the task dialog needs, and before every other startup step. Logging and telemetry do not
exist yet: until `BootstrapContextFactory` attaches them to the handler, a fatal exception is
saved to the report and shown in the dialog, without a log entry from Bravo, without telemetry and without the Send
button. The runtime still writes its own Event Log entry when it ends the process. This covers directory and proxy
setup, the WebView2 runtime check and bootstrap creation. `BootstrapContext` detaches the handler before it disposes
the services, so an exception raised during that disposal is reported the same way and never through disposed
services.
[BootstrapContextFactory](../../src/Host/BootstrapContextFactory.cs) degrades to default settings and disabled
telemetry when they cannot be created, but a policy read failure propagates on purpose, so that administrative
restrictions are never bypassed: a policy registry key that cannot be read ends the process this way
restrictions are never bypassed: a policy registry key that cannot be read ends the process through this path
([ADR 0005](../adr/0005-policy-failures-stop-startup.md)).

[UnhandledExceptionHandler](../../src/Infrastructure/Diagnostics/UnhandledExceptionHandler.cs) logs and tracks each
Expand All @@ -33,7 +37,11 @@ other. For a fatal exception, a marker in `Exception.Data` suppresses a repeated
suppress the earlier log and telemetry calls.

[ErrorReport](../../src/Infrastructure/Diagnostics/ErrorReport.cs) saves environment diagnostics and the exception to
`ErrorReport.txt` under the application data folder. A failed save does not prevent the dialog.
`ErrorReport.txt` under the application data folder; the folder is resolved only when a report is saved, because
`AppEnvironment` initialization can be the failure being reported. A failed save does not prevent the dialog. If the
task dialog fails, for example because a type it reads failed to initialize, a plain message box shows the error and
the report path instead. The dialog failure is logged once the bootstrap services are attached, and it never prevents
the shutdown flush.
[ErrorReportTaskDialog](../../src/Infrastructure/Diagnostics/ErrorReportTaskDialog.cs) runs on a dedicated STA thread
and offers copying, closing and, when host telemetry is disabled, sending the exception through telemetry. Automatic
tracking is an attempt, not confirmation of delivery.
Expand Down Expand Up @@ -83,7 +91,8 @@ The `TelemetryEnabled` setter assigns the value it receives, so every caller res
| Crash dialog Send | Temporarily enables host telemetry, tracks the exception and awaits one flush, then restores the previous value |

A disabled service drops new tracked exceptions. If bootstrap supplied `NullTelemetryService`, enabling it or
requesting a flush still cannot send a report.
requesting a flush still cannot send a report, so the crash handler treats it as no telemetry and the dialog does not
offer Send.

Never add personal data to telemetry messages or properties. The single-instance pipe name includes the user SID and
must not be recorded. [DefaultTelemetryProcessor](../../src/Infrastructure/Telemetry/DefaultTelemetryProcessor.cs)
Expand Down
7 changes: 6 additions & 1 deletion src/Host/BootstrapContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,21 @@ namespace Sqlbi.Bravo.Host;
/// </remarks>
internal sealed class BootstrapContext : IDisposable
{
private readonly IDisposable? _exceptionHandlerAttachment;
private bool _disposed;

internal BootstrapContext(
ILoggerFactory loggerFactory,
IPolicyService policyService,
IUserSettings settings,
ITelemetryService telemetry)
ITelemetryService telemetry,
IDisposable? exceptionHandlerAttachment = null)
{
LoggerFactory = loggerFactory;
PolicyService = policyService;
Settings = settings;
Telemetry = telemetry;
_exceptionHandlerAttachment = exceptionHandlerAttachment;
}

public ILoggerFactory LoggerFactory { get; }
Expand All @@ -40,6 +43,8 @@ public void Dispose()

_disposed = true;

// Detached first, so that an exception raised while the services are disposed is not reported through them.
_exceptionHandlerAttachment?.Dispose();
Telemetry.Dispose();
// LoggerFactory is disposed last so all other services can safely log during their disposal.
LoggerFactory.Dispose();
Expand Down
16 changes: 12 additions & 4 deletions src/Host/BootstrapContextFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ internal sealed partial class BootstrapContextFactory(
ILoggerFactory loggerFactory,
IPolicyServiceFactory policyFactory,
IUserSettingsServiceFactory settingsFactory,
ITelemetryServiceFactory telemetryFactory)
ITelemetryServiceFactory telemetryFactory,
UnhandledExceptionHandler exceptionHandler)
{
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly IPolicyServiceFactory _policyFactory = policyFactory;
private readonly IUserSettingsServiceFactory _settingsFactory = settingsFactory;
private readonly ITelemetryServiceFactory _telemetryFactory = telemetryFactory;
private readonly UnhandledExceptionHandler _exceptionHandler = exceptionHandler;

public static BootstrapContext CreateDefault()
public static BootstrapContext CreateDefault(UnhandledExceptionHandler exceptionHandler)
{
var loggerFactory = CreateLoggerFactory();
try
Expand All @@ -29,7 +31,8 @@ public static BootstrapContext CreateDefault()
loggerFactory,
new PolicyServiceFactory(),
new UserSettingsServiceFactory(),
new TelemetryServiceFactory());
new TelemetryServiceFactory(),
exceptionHandler);

return factory.Create();
}
Expand All @@ -40,15 +43,20 @@ public static BootstrapContext CreateDefault()
}
}

/// <summary>
/// Creates the bootstrap services and attaches them to the exception handler, which reports through them until
/// the context is disposed.
/// </summary>
public BootstrapContext Create()
{
var policies = CreatePolicies();
var settings = CreateSettings();

var consent = TelemetryConsent.Resolve(policies.Current, settings);
var telemetry = CreateTelemetry(consent.IsEnabled);
var exceptionHandlerAttachment = _exceptionHandler.AttachServices(_loggerFactory, telemetry);

return new BootstrapContext(_loggerFactory, policies, settings, telemetry);
return new BootstrapContext(_loggerFactory, policies, settings, telemetry, exceptionHandlerAttachment);
}

private static ILoggerFactory CreateLoggerFactory()
Expand Down
13 changes: 7 additions & 6 deletions src/Host/BravoGlobalExceptionHandling.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
using Sqlbi.Bravo.Infrastructure.Diagnostics;
using Sqlbi.Bravo.Infrastructure;
using Sqlbi.Bravo.Infrastructure.Diagnostics;

namespace Sqlbi.Bravo.Host;

internal static class BravoGlobalExceptionHandling
{
public static void Configure(BootstrapContext context)
public static UnhandledExceptionHandler Configure()
{
var dialog = new ErrorReportTaskDialog(context.Telemetry);

var handler = new UnhandledExceptionHandler(
dialog, context.Telemetry, context.LoggerFactory);
// The path is read on a crash: AppEnvironment initialization can itself be the failure being reported.
var handler = new UnhandledExceptionHandler(new ErrorReportTaskDialog(), () => AppEnvironment.ApplicationDataPath);

GlobalExceptionHandling.Configure(handler.Handle);

#if DEBUG
CrashTest.RunIfRequested();
#endif

return handler;
}
}
4 changes: 2 additions & 2 deletions src/Infrastructure/Diagnostics/ErrorReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ private ErrorReport(Exception exception, EnvironmentDiagnostics diagnostics)
public string Text => _text.Value;
public string? FilePath { get; private set; }

public bool TrySave()
public bool TrySave(string directoryPath)
{
try
{
var directory = Directory.CreateDirectory(AppEnvironment.ApplicationDataPath);
var directory = Directory.CreateDirectory(directoryPath);
var filePath = Path.Combine(directory.FullName, "ErrorReport.txt");

File.WriteAllText(filePath, Text, Encoding.UTF8);
Expand Down
66 changes: 50 additions & 16 deletions src/Infrastructure/Diagnostics/ErrorReportTaskDialog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,68 @@ namespace Sqlbi.Bravo.Infrastructure.Diagnostics;

internal interface IErrorReportDialog
{
void Show(ErrorReport report);
void Show(ErrorReport report, ITelemetryService? telemetry);
}

/// <summary>
/// Implements an error report dialog to display error information to the user.
/// </summary>
internal sealed class ErrorReportTaskDialog(ITelemetryService telemetry) : IErrorReportDialog
internal sealed class ErrorReportTaskDialog : IErrorReportDialog
{
private readonly ITelemetryService _telemetry = telemetry;

public void Show(ErrorReport report)
public void Show(ErrorReport report, ITelemetryService? telemetry)
{
// Always run on a dedicated STA thread: TaskDialog and Clipboard require STA,
// while the caller could be executing on any arbitrary background thread.
ProcessHelper.RunOnSTAThread(() => ShowImpl(report));
ProcessHelper.RunOnSTAThread(() =>
{
try
{
ShowImpl(report, telemetry);
}
catch
{
// The task dialog uses application types that can be the cause of the crash.
ShowFallback(report);
throw;
}
});
}

private static void ShowFallback(ErrorReport report)
{
try
{
var text = new StringBuilder();
text.AppendLine("Bravo encountered an unexpected error and will close.");

if (report.FilePath is not null)
{
text.AppendLine();
text.AppendLine("A crash report has been saved:");
text.AppendLine(report.FilePath);
}

text.AppendLine();
text.Append(report.Exception.GetType().Name).Append(": ").Append(report.Exception.Message);

MessageBox.Show(text.ToString(), "Bravo for Power BI", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
catch { /* Ignore failures */ }
}

private void ShowImpl(ErrorReport report)
private static void ShowImpl(ErrorReport report, ITelemetryService? telemetry)
{
// Ensure a WindowsFormsSynchronizationContext exists on this STA thread so that async continuations
// post back to this thread's message loop, where the modal dialog can safely process UI updates.
if (SynchronizationContext.Current is not WindowsFormsSynchronizationContext)
SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());

// When telemetry is enabled, reports are automatically submitted; offer Send only when it is disabled.
var sendTelemetry = telemetry is { TelemetryEnabled: false } ? telemetry : null;
var sendButton = new TaskDialogCommandLinkButton("&Send report", "Sends the crash report to help diagnose the problem.")
{
AllowCloseDialog = false,
// When telemetry is enabled, reports are automatically submitted; hide this redundant button.
Visible = !_telemetry.TelemetryEnabled,
Visible = sendTelemetry is not null,
};
var copyButton = new TaskDialogCommandLinkButton("&Copy to clipboard", "Copies the crash report details to the clipboard.")
{
Expand All @@ -66,27 +99,28 @@ private void ShowImpl(ErrorReport report)
var page = builder.Build();

copyButton.Click += (_, _) => CopyToClipboard(page, text, report);
sendButton.Click += async (_, _) => await SendAsync(page, sendButton, text, report);
if (sendTelemetry is not null)
sendButton.Click += async (_, _) => await SendAsync(sendTelemetry, page, sendButton, text, report);

builder.Show();
}

private async Task SendAsync(TaskDialogPage page, TaskDialogButton sendButton, string text, ErrorReport report)
private static async Task SendAsync(ITelemetryService telemetry, TaskDialogPage page, TaskDialogButton sendButton, string text, ErrorReport report)
{
var previousTelemetryEnabled = _telemetry.TelemetryEnabled;
var previousTelemetryEnabled = telemetry.TelemetryEnabled;
try
{
sendButton.Enabled = false;
SetStatus(page, text, "Sending the report...");

_telemetry.TelemetryEnabled = true;
_telemetry.TrackException(report.Exception, properties: new Dictionary<string, string>
telemetry.TelemetryEnabled = true;
telemetry.TrackException(report.Exception, properties: new Dictionary<string, string>
{
["ExceptionKind"] = "Unhandled"
});

using var timeout = new CancellationTokenSource(30_000);
var submitted = await _telemetry.FlushAsync(timeout.Token);
var submitted = await telemetry.FlushAsync(timeout.Token);

SetStatus(page, text, submitted
? "The report has been submitted. Thank you."
Expand All @@ -98,7 +132,7 @@ private async Task SendAsync(TaskDialogPage page, TaskDialogButton sendButton, s
}
finally
{
_telemetry.TelemetryEnabled = previousTelemetryEnabled;
telemetry.TelemetryEnabled = previousTelemetryEnabled;
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/Infrastructure/Diagnostics/UnhandledExceptionHandler.Log.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;
using Microsoft.Extensions.Logging;

namespace Sqlbi.Bravo.Infrastructure.Diagnostics;

internal sealed partial class UnhandledExceptionHandler
{
[LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "An unhandled exception occurred. Origin: {Origin}")]
private static partial void LogUnhandledException(ILogger logger, Exception exception, ExceptionOrigin origin);

[LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "The error report dialog could not be shown.")]
private static partial void LogDialogFailed(ILogger logger, Exception exception);
}
Loading
Loading