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
1 change: 1 addition & 0 deletions src/Bravo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<PackageReference Include="LargeXlsx" Version="2.0.1" />
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.23.0" />
<PackageReference Include="Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel" Version="2.23.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2739.15" />
<PackageReference Include="Microsoft.Identity.Client.Desktop" Version="4.73.1" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.363" PrivateAssets="all" />
Expand Down
7 changes: 5 additions & 2 deletions src/Controllers/ApplicationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Extensions;
using Sqlbi.Bravo.Infrastructure.Helpers;
using Sqlbi.Bravo.Infrastructure.Policies;
using Sqlbi.Bravo.Infrastructure.Security;
using Sqlbi.Bravo.Infrastructure.Telemetry;
using Sqlbi.Bravo.Models;
Expand All @@ -27,10 +28,12 @@ namespace Sqlbi.Bravo.Controllers;
public class ApplicationController : ControllerBase
{
private readonly ITelemetryService _telemetryService;
private readonly IPolicyService _policyService;

public ApplicationController(ITelemetryService telemetryService)
public ApplicationController(ITelemetryService telemetryService, IPolicyService policyService)
{
_telemetryService = telemetryService;
_policyService = policyService;
}

/// <summary>
Expand Down Expand Up @@ -82,7 +85,7 @@ public IActionResult UpdateOptions(BravoOptions options)
return BadRequest(problem);
}

_telemetryService.TelemetryEnabled = UserPreferences.Current.TelemetryEnabled;
_telemetryService.TelemetryEnabled = TelemetryConsent.Resolve(_policyService.Current, UserPreferences.Current).IsEnabled;

return Ok();
}
Expand Down
2 changes: 2 additions & 0 deletions src/Host/BravoApplicationInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Sqlbi.Bravo.Infrastructure;
using Sqlbi.Bravo.Infrastructure.Helpers;
using Sqlbi.Bravo.Infrastructure.Services;
using Sqlbi.Bravo.Infrastructure.Telemetry;

namespace Sqlbi.Bravo.Host;

Expand Down Expand Up @@ -40,6 +41,7 @@ private static void ConfigureDirectories()
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
Directory.CreateDirectory(AppEnvironment.ApplicationDataPath);
Directory.CreateDirectory(AppEnvironment.ApplicationTempPath);
Directory.CreateDirectory(TelemetryService.DefaultStorageFolder);
}

private static void ConfigureRuntimeOptimization()
Expand Down
3 changes: 3 additions & 0 deletions src/Infrastructure/AppEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ static AppEnvironment()

ApplicationDataPath = Path.Combine(Environment.GetFolderPath(DeploymentMode == AppDeploymentMode.Packaged ? Environment.SpecialFolder.UserProfile : Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify), ApplicationName);
ApplicationTempPath = Path.Combine(ApplicationDataPath, ".temp");
ApplicationCachePath = Path.Combine(ApplicationDataPath, ".cache");
UserSettingsFilePath = Path.Combine(ApplicationDataPath, "usersettings.json");
MsalTokenCacheFilePath = Path.Combine(ApplicationDataPath, ".msalcache");
WebView2VersionInfo = WebView2Helper.GetRuntimeVersionInfo();
Expand Down Expand Up @@ -136,6 +137,8 @@ public static RegistryKey? ApplicationInstallerRegistryHKey

public static string ApplicationTempPath { get; }

public static string ApplicationCachePath { get; }

public static string UserSettingsFilePath { get; }

public static string MsalTokenCacheFilePath { get; }
Expand Down
26 changes: 26 additions & 0 deletions src/Infrastructure/Telemetry/TelemetryConsent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Policies;

namespace Sqlbi.Bravo.Infrastructure.Telemetry;

internal enum TelemetryConsentSource
{
UserSettings,
Policy,
}

/// <summary>
/// The effective decision on whether telemetry is collected, and where that decision comes from.
/// </summary>
internal readonly record struct TelemetryConsent(bool IsEnabled, TelemetryConsentSource Source)
{
/// <remarks>
/// A configured policy overrides the user preference: the source tells the user which one applies.
/// </remarks>
public static TelemetryConsent Resolve(PolicySnapshot policies, IUserSettings settings)
{
return policies.TelemetryEnabled is bool policyValue
? new TelemetryConsent(policyValue, TelemetryConsentSource.Policy)
: new TelemetryConsent(settings.TelemetryEnabled, TelemetryConsentSource.UserSettings);
}
}
152 changes: 113 additions & 39 deletions src/Infrastructure/Telemetry/TelemetryService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel;
using Sqlbi.Bravo.Infrastructure.Configuration;
using Sqlbi.Bravo.Infrastructure.Policies;

Expand All @@ -10,72 +16,140 @@ namespace Sqlbi.Bravo.Infrastructure.Telemetry;
public interface ITelemetryService : IDisposable
{
bool TelemetryEnabled { get; set; }
void TrackException(Exception exception);
void TrackException(Exception exception, IDictionary<string, string>? properties = null);
Task<bool> FlushAsync(CancellationToken cancellationToken = default);
bool TryFlushBeforeShutdown(int timeoutMilliseconds);
}

internal sealed class TelemetryService : ITelemetryService
{
public static readonly string DefaultStorageFolder =
Path.Combine(AppEnvironment.ApplicationCachePath, ".telemetry");

private static readonly Lazy<TelemetryService> s_instance = new(CreateInstance, isThreadSafe: true);

private readonly TelemetryConfiguration _configuration;
private readonly ITelemetryChannel _channel;
private readonly TelemetryClient _client;
private readonly PolicySnapshot _policies;
private bool _disposed;

private static readonly Lazy<TelemetryService> _instance = new(CreateInstance, isThreadSafe: true);
private TelemetryService(TelemetryConfiguration configuration)
{
_configuration = configuration;
_channel = configuration.TelemetryChannel;
_client = new TelemetryClient(configuration);
}

public static TelemetryService Instance => _instance.Value;
/// <summary>
/// Gets the process-wide instance, created on first use with the consent resolved from policies
/// and user settings.
/// </summary>
public static TelemetryService Instance => s_instance.Value;

private static TelemetryService CreateInstance()
/// <summary>
/// Disposes the process-wide instance, if it was created.
/// </summary>
public static void DisposeInstance()
{
// Use parameterless constructor to avoid default processors (e.g. adaptive sampling)
// that TelemetryConfiguration.CreateDefault() would register.
var configuration = new TelemetryConfiguration();

// Remark: ensure no sampling is applied — every exception must be recorded
var processorChain = configuration.DefaultTelemetrySink.TelemetryProcessorChainBuilder;
processorChain.Use((next) => new DefaultTelemetryProcessor(next));
processorChain.Build();
if (s_instance.IsValueCreated)
s_instance.Value.Dispose();
}

configuration.TelemetryInitializers.Add(new DefaultTelemetryInitializer());
configuration.ConnectionString = TelemetrySessionInfo.ConnectionString;
public bool TelemetryEnabled
{
get => _configuration.DisableTelemetry == false;
set => _configuration.DisableTelemetry = !value;
}

var policies = new PolicyServiceFactory().Create().Current;
public void TrackException(Exception exception, IDictionary<string, string>? properties = null)
{
if (exception is AggregateException aggregateException)
exception = aggregateException.GetBaseException();

// Determine whether telemetry is enabled based on policies and user settings.
var telemetryEnabled = policies.TelemetryEnabled ?? UserPreferences.Current.TelemetryEnabled;
configuration.DisableTelemetry = !telemetryEnabled;
#if DEBUG
configuration.TelemetryChannel.DeveloperMode = Debugger.IsAttached;
#endif
return new TelemetryService(configuration, policies);
_client.TrackException(exception, properties);
// With ServerTelemetryChannel, Flush moves telemetry out of the in-memory telemetry
// buffer and into the transmission pipeline without waiting for delivery. Calling it
// after TrackException improves the chances of preserving the exception if the process
// terminates before the next scheduled flush, while keeping this path non-blocking.
// It does not guarantee that the telemetry has been sent or persisted to local storage.
_client.Flush();
}

private TelemetryService(TelemetryConfiguration configuration, PolicySnapshot policies)
public Task<bool> FlushAsync(CancellationToken cancellationToken = default)
{
_configuration = configuration;
_client = new TelemetryClient(configuration);
_policies = policies;
return _client.FlushAsync(cancellationToken);
}

public bool TelemetryEnabled
public bool TryFlushBeforeShutdown(int timeoutMilliseconds)
{
get => !_configuration.DisableTelemetry;
set
// At shutdown, wait for ServerTelemetryChannel to flush telemetry that could
// otherwise be lost when the process exits. FlushAsync completes once pending
// telemetry has been sent or persisted to local storage. The wait is bounded
// because telemetry must never prevent the application from terminating.
using var cts = new CancellationTokenSource(timeoutMilliseconds);
try
{
var telemetryEnabled = _policies.TelemetryEnabled ?? value;
_configuration.DisableTelemetry = !telemetryEnabled;
return _client.FlushAsync(cts.Token).GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
return false;
}
}

public void Dispose()
{
if (_disposed)
return;

_disposed = true;

_ = TryFlushBeforeShutdown(5_000);

_configuration.Dispose();

// A channel assigned to the sink is not owned by it.
_channel.Dispose();
}

public void TrackException(Exception exception)
public static TelemetryService Create(string connectionString, bool telemetryEnabled, string storageFolder)
{
if (exception is AggregateException aex)
exception = aex.GetBaseException();
var channel = new ServerTelemetryChannel
{
// Remark: folder must exist beforehand as the channel does not auto-create it.
// If missing, local storage remains disabled and unsent telemetry is lost.
StorageFolder = storageFolder
};

_client.TrackException(exception);
_client.Flush();
#if DEBUG
channel.DeveloperMode = Debugger.IsAttached;
#endif

// Avoid CreateDefault() to prevent registering default processors (e.g., adaptive sampling)
var configuration = new TelemetryConfiguration
{
ConnectionString = connectionString,
DisableTelemetry = !telemetryEnabled,
TelemetryChannel = channel
};

configuration.TelemetryInitializers.Add(new DefaultTelemetryInitializer());

// Build clean processor chain with DefaultTelemetryProcessor (disables sampling/filtering).
var processorChain = configuration.DefaultTelemetrySink.TelemetryProcessorChainBuilder;
processorChain.Use(next => new DefaultTelemetryProcessor(next));
processorChain.Build();

channel.Initialize(configuration);

return new TelemetryService(configuration);
}

public void Dispose()
private static TelemetryService CreateInstance()
{
_configuration.Dispose();
var policies = new PolicyServiceFactory().Create().Current;
var consent = TelemetryConsent.Resolve(policies, UserPreferences.Current);

return Create(TelemetrySessionInfo.ConnectionString, consent.IsEnabled, DefaultStorageFolder);
}
}
7 changes: 7 additions & 0 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ private static void Main()
report.TrySave();
ErrorReportDialog.Show(report);

// An unhandled rethrow can end the process before the finally block below runs.
_ = TelemetryService.Instance.TryFlushBeforeShutdown(10_000);

throw;
}
finally
{
TelemetryService.DisposeInstance();
}
}
}
20 changes: 20 additions & 0 deletions src/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel": {
"type": "Direct",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "798Dudr4tkujslk1w+XcXOcCErmVsk+nhp+QCHLa3lcgi25vkAxBmzPUeQlRJVCNL/1f4x/YF+vQZ8RSuTXWCw==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"System.IO.FileSystem.AccessControl": "4.7.0"
}
},
"Microsoft.Identity.Client.Desktop": {
"type": "Direct",
"requested": "[4.73.1, )",
Expand Down Expand Up @@ -271,6 +281,11 @@
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "rJJony+jsxvpfJM9ZGVxjp0DVpalZv8cAhiMSLW6L2hgUWb7k5qPVuzQHWXtkT8lrG1hQ8vWeR+HUwgCQm9J3A=="
},
"System.IO.FileSystem.AccessControl": {
"type": "Transitive",
"resolved": "4.7.0",
"contentHash": "vMToiarpU81LR1/KZtnT7VDPvqAZfw9oOS5nY6pPP78nGYz3COLsQH3OfzbR+SjTgltd31R6KmKklz/zDpTmzw=="
}
},
"net10.0-windows7.0/win-x64": {
Expand Down Expand Up @@ -299,6 +314,11 @@
"type": "Transitive",
"resolved": "0.19.2",
"contentHash": "qXNtYJxZ15yKV5pnI+V0sRzIH++vNzmMOU3K8kyNGsMD8/q7+ZejQjm7gJI9MMEGDPQkGg3QaiMvrIgbH2ym2g=="
},
"System.IO.FileSystem.AccessControl": {
"type": "Transitive",
"resolved": "4.7.0",
"contentHash": "vMToiarpU81LR1/KZtnT7VDPvqAZfw9oOS5nY6pPP78nGYz3COLsQH3OfzbR+SjTgltd31R6KmKklz/zDpTmzw=="
}
}
}
Expand Down
9 changes: 8 additions & 1 deletion test/Bravo.Tests/Host/HostFakes.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Sqlbi.Bravo.Host;
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Policies;
Expand Down Expand Up @@ -38,10 +41,14 @@ internal sealed class FakeTelemetry : ITelemetryService

public bool TelemetryEnabled { get; set; }

public void TrackException(Exception exception)
public void TrackException(Exception exception, IDictionary<string, string>? properties = null)
{
}

public Task<bool> FlushAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);

public bool TryFlushBeforeShutdown(int timeoutMilliseconds) => true;

public void Dispose() => Disposed = true;
}

Expand Down
Loading
Loading