diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c1d50d8022..2aa006d89a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -91,6 +91,8 @@ jobs: env: UNIGETUI_GITHUB_CLIENT_ID: ${{ secrets.UNIGETUI_GITHUB_CLIENT_ID }} UNIGETUI_GITHUB_CLIENT_SECRET: ${{ secrets.UNIGETUI_GITHUB_CLIENT_SECRET }} + UNIGETUI_OPENSEARCH_USERNAME: ${{ secrets.UNIGETUI_OPENSEARCH_USERNAME }} + UNIGETUI_OPENSEARCH_PASSWORD: ${{ secrets.UNIGETUI_OPENSEARCH_PASSWORD }} NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages strategy: fail-fast: false @@ -318,6 +320,8 @@ jobs: env: UNIGETUI_GITHUB_CLIENT_ID: ${{ secrets.UNIGETUI_GITHUB_CLIENT_ID }} UNIGETUI_GITHUB_CLIENT_SECRET: ${{ secrets.UNIGETUI_GITHUB_CLIENT_SECRET }} + UNIGETUI_OPENSEARCH_USERNAME: ${{ secrets.UNIGETUI_OPENSEARCH_USERNAME }} + UNIGETUI_OPENSEARCH_PASSWORD: ${{ secrets.UNIGETUI_OPENSEARCH_PASSWORD }} NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages strategy: fail-fast: false diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 8ea526ca4a..59d9c1431e 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -47,7 +47,7 @@ public override void OnFrameworkInitializationCompleted() ApplyTheme(CoreSettings.GetValue(CoreSettings.K.PreferredTheme)); var mainWindow = new MainWindow(); desktop.MainWindow = mainWindow; - _ = Task.Run(PEInterface.LoadManagers); + _ = AvaloniaBootstrapper.InitializeAsync(); } base.OnFrameworkInitializationCompleted(); diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index 60eef1c97f..b8d1876c9a 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -53,6 +53,9 @@ private static Task InitializeSharedServicesAsync() CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + TelemetryHandler.Configure( + Secrets.GetOpenSearchUsername(), + Secrets.GetOpenSearchPassword()); _ = TelemetryHandler.InitializeAsync() .ContinueWith( t => Logger.Error(t.Exception!), @@ -82,7 +85,7 @@ private static Task InitializeSharedServicesAsync() private static async Task InitializePackageEngineAsync() { - await Task.Run(PEInterface.LoadLoaders); + // LoadLoaders is called synchronously in App.axaml.cs before MainWindow creation await Task.Run(PEInterface.LoadManagers); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs index 7e60a11607..5ad40d1adb 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaPackageOperationHelper.cs @@ -1,5 +1,6 @@ using UniGetUI.Core.Logging; using UniGetUI.Interface.Enums; +using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Operations; using UniGetUI.PackageEngine.PackageClasses; @@ -20,6 +21,8 @@ public static async Task UpdateAllAsync() if (pkg.Tag is PackageTag.BeingProcessed or PackageTag.OnQueue) continue; var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg); var op = new UpdatePackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } @@ -34,6 +37,8 @@ public static async Task UpdateAllForManagerAsync(string managerName) if (pkg.Tag is PackageTag.BeingProcessed or PackageTag.OnQueue) continue; var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg); var op = new UpdatePackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } @@ -50,6 +55,8 @@ public static async Task UpdateForIdAsync(string packageId) var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg); var op = new UpdatePackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs b/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs index 2e38224e35..a050b5ca05 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs @@ -8,5 +8,7 @@ internal static partial class Secrets * Seeing errors? Build the project (maybe twice) */ public static partial string GetGitHubClientId(); + public static partial string GetOpenSearchUsername(); + public static partial string GetOpenSearchPassword(); /* ------------------------------------------------------------------------ */ } diff --git a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 index 6860fc41f6..53fc0d0d3b 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 +++ b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 @@ -12,8 +12,12 @@ if (-not (Test-Path -Path $generatedDir)) { } $clientId = $env:UNIGETUI_GITHUB_CLIENT_ID +$openSearchUsername = $env:UNIGETUI_OPENSEARCH_USERNAME +$openSearchPassword = $env:UNIGETUI_OPENSEARCH_PASSWORD if (-not $clientId) { $clientId = "CLIENT_ID_UNSET" } +if (-not $openSearchUsername) { $openSearchUsername = "OPENSEARCH_USERNAME_UNSET" } +if (-not $openSearchPassword) { $openSearchPassword = "OPENSEARCH_PASSWORD_UNSET" } @" // Auto-generated file - do not modify @@ -22,6 +26,8 @@ namespace UniGetUI.Avalonia.Infrastructure internal static partial class Secrets { public static partial string GetGitHubClientId() => `"$clientId`"; + public static partial string GetOpenSearchUsername() => `"$openSearchUsername`"; + public static partial string GetOpenSearchPassword() => `"$openSearchPassword`"; } } "@ | Set-Content -Encoding UTF8 "Generated Files\Secrets.Generated.cs" diff --git a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh index d70a305f9c..59583f81bb 100755 --- a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh +++ b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh @@ -5,8 +5,12 @@ if [ ! -d "Generated Files" ]; then mkdir -p "Generated Files"; fi if [ ! -d "${OUTPUT_PATH}Generated Files" ]; then mkdir -p "${OUTPUT_PATH}Generated Files"; fi CLIENT_ID="${UNIGETUI_GITHUB_CLIENT_ID}" +OPENSEARCH_USERNAME="${UNIGETUI_OPENSEARCH_USERNAME}" +OPENSEARCH_PASSWORD="${UNIGETUI_OPENSEARCH_PASSWORD}" if [ -z "$CLIENT_ID" ]; then CLIENT_ID="CLIENT_ID_UNSET"; fi +if [ -z "$OPENSEARCH_USERNAME" ]; then OPENSEARCH_USERNAME="OPENSEARCH_USERNAME_UNSET"; fi +if [ -z "$OPENSEARCH_PASSWORD" ]; then OPENSEARCH_PASSWORD="OPENSEARCH_PASSWORD_UNSET"; fi cat > "Generated Files/Secrets.Generated.cs" << CSEOF // Auto-generated file - do not modify @@ -15,6 +19,8 @@ namespace UniGetUI.Avalonia.Infrastructure internal static partial class Secrets { public static partial string GetGitHubClientId() => "$CLIENT_ID"; + public static partial string GetOpenSearchUsername() => "$OPENSEARCH_USERNAME"; + public static partial string GetOpenSearchPassword() => "$OPENSEARCH_PASSWORD"; } } CSEOF diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs index 69c8ea8dc8..6af392f7ee 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -15,6 +15,7 @@ using UniGetUI.Avalonia.Views.Controls; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; +using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; @@ -784,6 +785,8 @@ public static async Task LaunchInstall( var opts = await InstallOptionsFactory.LoadApplicableAsync( pkg, elevated: elevated, interactive: interactive, no_integrity: no_integrity); var op = new InstallPackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.SUCCESS, TEL_InstallReferral.DIRECT_SEARCH); + op.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.FAILED, TEL_InstallReferral.DIRECT_SEARCH); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs index 27b07b8245..fb6a764a4c 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; @@ -35,6 +36,7 @@ protected override void OnOpened(EventArgs e) { base.OnOpened(e); _ = _vm.LoadDetailsAsync(); + TelemetryHandler.PackageDetails(_vm.Package, _vm.OperationRole.ToString()); } private MenuFlyout BuildActionFlyout() @@ -110,6 +112,22 @@ private async Task LaunchAndClose( _ => throw new ArgumentOutOfRangeException(nameof(role)), }; + switch (role) + { + case OperationType.Install: + op.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.SUCCESS, TEL_InstallReferral.DIRECT_SEARCH); + op.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.FAILED, TEL_InstallReferral.DIRECT_SEARCH); + break; + case OperationType.Update: + op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); + break; + case OperationType.Uninstall: + op.OperationSucceeded += (_, _) => TelemetryHandler.UninstallPackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UninstallPackage(pkg, TEL_OP_RESULT.FAILED); + break; + } + AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs index 3855d829c0..04b28814c3 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs @@ -7,6 +7,7 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; +using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine.Classes.Manager.Classes; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -320,6 +321,8 @@ private static async Task LaunchUninstall( var opts = await InstallOptionsFactory.LoadApplicableAsync( pkg, elevated: elevated, interactive: interactive, remove_data: remove_data); var op = new UninstallPackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.UninstallPackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UninstallPackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } @@ -330,6 +333,8 @@ private static async Task LaunchReinstall(IPackage? package) if (package is null || package.Source.IsVirtualManager) return; var opts = await InstallOptionsFactory.LoadApplicableAsync(package); var op = new InstallPackageOperation(package, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(package, TEL_OP_RESULT.SUCCESS, TEL_InstallReferral.ALREADY_INSTALLED); + op.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(package, TEL_OP_RESULT.FAILED, TEL_InstallReferral.ALREADY_INSTALLED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } @@ -340,7 +345,11 @@ private static async Task LaunchUninstallThenReinstall(IPackage? package) var uninstallOpts = await InstallOptionsFactory.LoadApplicableAsync(package); var reinstallOpts = await InstallOptionsFactory.LoadApplicableAsync(package); var uninstallOp = new UninstallPackageOperation(package, uninstallOpts); + uninstallOp.OperationSucceeded += (_, _) => TelemetryHandler.UninstallPackage(package, TEL_OP_RESULT.SUCCESS); + uninstallOp.OperationFailed += (_, _) => TelemetryHandler.UninstallPackage(package, TEL_OP_RESULT.FAILED); var reinstallOp = new InstallPackageOperation(package, reinstallOpts, req: uninstallOp); + reinstallOp.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(package, TEL_OP_RESULT.SUCCESS, TEL_InstallReferral.ALREADY_INSTALLED); + reinstallOp.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(package, TEL_OP_RESULT.FAILED, TEL_InstallReferral.ALREADY_INSTALLED); AvaloniaOperationRegistry.Add(uninstallOp); AvaloniaOperationRegistry.Add(reinstallOp); _ = uninstallOp.MainThread(); diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs index 71da3e9245..2821e900be 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs @@ -282,6 +282,8 @@ public async Task ImportAndInstallPackage( var opts = await InstallOptionsFactory.LoadApplicableAsync( pkg, elevated: elevated, interactive: interactive, no_integrity: skiphash); var op = new InstallPackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.SUCCESS, TEL_InstallReferral.FROM_BUNDLE); + op.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.FAILED, TEL_InstallReferral.FROM_BUNDLE); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs index abed54b85c..d9c01a396a 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs @@ -5,6 +5,7 @@ using UniGetUI.Avalonia.Views; using UniGetUI.Core.Logging; using UniGetUI.Core.Tools; +using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine.Classes.Manager.Classes; using UniGetUI.PackageEngine.Classes.Packages.Classes; using UniGetUI.PackageEngine.Enums; @@ -339,6 +340,8 @@ private static async Task LaunchUpdate( var opts = await InstallOptionsFactory.LoadApplicableAsync( pkg, elevated: elevated, interactive: interactive, no_integrity: no_integrity); var op = new UpdatePackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } @@ -350,6 +353,8 @@ private static async Task LaunchUninstallFromUpdates(IEnumerable packa { var opts = await InstallOptionsFactory.LoadApplicableAsync(pkg); var op = new UninstallPackageOperation(pkg, opts); + op.OperationSucceeded += (_, _) => TelemetryHandler.UninstallPackage(pkg, TEL_OP_RESULT.SUCCESS); + op.OperationFailed += (_, _) => TelemetryHandler.UninstallPackage(pkg, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(op); _ = op.MainThread(); } @@ -361,7 +366,11 @@ private static async Task LaunchUninstallThenUpdate(IPackage? package) var uninstallOpts = await InstallOptionsFactory.LoadApplicableAsync(package); var updateOpts = await InstallOptionsFactory.LoadApplicableAsync(package); var uninstallOp = new UninstallPackageOperation(package, uninstallOpts); + uninstallOp.OperationSucceeded += (_, _) => TelemetryHandler.UninstallPackage(package, TEL_OP_RESULT.SUCCESS); + uninstallOp.OperationFailed += (_, _) => TelemetryHandler.UninstallPackage(package, TEL_OP_RESULT.FAILED); var updateOp = new UpdatePackageOperation(package, updateOpts, req: uninstallOp); + updateOp.OperationSucceeded += (_, _) => TelemetryHandler.UpdatePackage(package, TEL_OP_RESULT.SUCCESS); + updateOp.OperationFailed += (_, _) => TelemetryHandler.UpdatePackage(package, TEL_OP_RESULT.FAILED); AvaloniaOperationRegistry.Add(uninstallOp); AvaloniaOperationRegistry.Add(updateOp); _ = uninstallOp.MainThread(); diff --git a/src/UniGetUI.Interface.Telemetry/Events/TelemetryEventBase.cs b/src/UniGetUI.Interface.Telemetry/Events/TelemetryEventBase.cs new file mode 100644 index 0000000000..731fb28a23 --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry/Events/TelemetryEventBase.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.Interface.Telemetry; + +/// +/// Common fields shared by all UniGetUI telemetry events. +/// Mirrors the field names expected by the Devolutions OpenSearch schema. +/// +public abstract class TelemetryEventBase +{ + protected TelemetryEventBase() + { + EventID = Guid.NewGuid().ToString(); + EventDate = DateTime.UtcNow; + } + + [JsonPropertyName("eventID")] + public string EventID { get; set; } + + [JsonPropertyName("eventDate")] + public DateTime EventDate { get; set; } + + [JsonPropertyName("installID")] + public required string InstallID { get; set; } + + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + [JsonPropertyName("application")] + public required TelemetryApplicationInfo Application { get; set; } + + [JsonPropertyName("platform")] + public TelemetryPlatformInfo? Platform { get; set; } +} + +public sealed class TelemetryApplicationInfo +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("version")] + public required string Version { get; set; } + + [JsonPropertyName("dataSource")] + public required string DataSource { get; set; } + + [JsonPropertyName("pricing")] + public required string Pricing { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("architectureType")] + public string? ArchitectureType { get; set; } +} + +public sealed class TelemetryPlatformInfo +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; set; } +} diff --git a/src/UniGetUI.Interface.Telemetry/Events/UniGetUIActivityEvent.cs b/src/UniGetUI.Interface.Telemetry/Events/UniGetUIActivityEvent.cs new file mode 100644 index 0000000000..7c5154521e --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry/Events/UniGetUIActivityEvent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.Interface.Telemetry; + +public sealed class UniGetUIActivityEvent : TelemetryEventBase +{ + [JsonPropertyName("enabledManagers")] + public string[] EnabledManagers { get; set; } = []; + + [JsonPropertyName("foundManagers")] + public string[] FoundManagers { get; set; } = []; + + [JsonPropertyName("activeSettings")] + public int ActiveSettings { get; set; } +} diff --git a/src/UniGetUI.Interface.Telemetry/Events/UniGetUIBundleEvent.cs b/src/UniGetUI.Interface.Telemetry/Events/UniGetUIBundleEvent.cs new file mode 100644 index 0000000000..7d54bc2502 --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry/Events/UniGetUIBundleEvent.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.Interface.Telemetry; + +public sealed class UniGetUIBundleEvent : TelemetryEventBase +{ + [JsonPropertyName("operation")] + public required string Operation { get; set; } + + [JsonPropertyName("bundleType")] + public required string BundleType { get; set; } +} diff --git a/src/UniGetUI.Interface.Telemetry/Events/UniGetUIPackageEvent.cs b/src/UniGetUI.Interface.Telemetry/Events/UniGetUIPackageEvent.cs new file mode 100644 index 0000000000..7b5f35b1d4 --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry/Events/UniGetUIPackageEvent.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.Interface.Telemetry; + +public sealed class UniGetUIPackageEvent : TelemetryEventBase +{ + [JsonPropertyName("operation")] + public required string Operation { get; set; } + + [JsonPropertyName("packageId")] + public required string PackageId { get; set; } + + [JsonPropertyName("managerName")] + public required string ManagerName { get; set; } + + [JsonPropertyName("sourceName")] + public required string SourceName { get; set; } + + [JsonPropertyName("operationResult")] + public string? OperationResult { get; set; } + + [JsonPropertyName("eventSource")] + public string? EventSource { get; set; } +} diff --git a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs index cf17d40aa4..a63caa4ab5 100644 --- a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs +++ b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs @@ -1,3 +1,9 @@ +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using UniGetUI.Core.Data; using UniGetUI.Core.Language; using UniGetUI.Core.Logging; @@ -26,12 +32,56 @@ public enum TEL_OP_RESULT public static class TelemetryHandler { + private const string OpenSearchUrl = "https://telemetry2.devolutions.net:9200"; + private static string _openSearchUsername = ""; + private static string _openSearchPassword = ""; + private static bool _credentialsWarningLogged; + + public static void Configure(string username, string password) + { + _openSearchUsername = username; + _openSearchPassword = password; + } + + private static bool CredentialsConfigured() + { + if (!string.IsNullOrEmpty(_openSearchUsername) + && !_openSearchUsername.EndsWith("_UNSET") + && !string.IsNullOrEmpty(_openSearchPassword) + && !_openSearchPassword.EndsWith("_UNSET")) + return true; + + if (!_credentialsWarningLogged) + { + Logger.Warn("[Telemetry] OpenSearch credentials are not configured — telemetry is disabled for this build."); + _credentialsWarningLogged = true; + } + + return false; + } + + // Index names — to be created on the OpenSearch server + private const string IndexActivity = "unigetui_activity_events"; + private const string IndexPackage = "unigetui_package_events"; + private const string IndexBundle = "unigetui_bundle_events"; + #if DEBUG - private const string HOST = "http://localhost:3000"; + private const string IndexPrefix = "dev-"; #else - private const string HOST = "https://marticliment.com/unigetui/statistics"; + private const string IndexPrefix = ""; #endif + private static readonly HttpClient _httpClient; + + static TelemetryHandler() + { + _httpClient = new HttpClient(CoreTools.GenericHttpClientParameters) + { + Timeout = TimeSpan.FromSeconds(30), + }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); + } + private static readonly Settings.K[] SettingsToSend = [ Settings.K.DisableAutoUpdateWingetUI, @@ -56,85 +106,67 @@ public static async Task InitializeAsync() { if (Settings.Get(Settings.K.DisableTelemetry)) return; - await CoreTools.WaitForInternetConnection(); - string ID = GetRandomizedId(); - int mask = 0x1; - int ManagerMagicValue = 0; + await CoreTools.WaitForInternetConnection(); - foreach (var manager in PEInterface.Managers) - { - if (manager.IsEnabled()) - ManagerMagicValue |= mask; - mask = mask << 1; - if (manager.IsEnabled() && manager.Status.Found) - ManagerMagicValue |= mask; - mask = mask << 1; + string[] enabledManagers = PEInterface.Managers + .Where(m => m.IsEnabled()) + .Select(m => m.Name) + .ToArray(); - if (mask == 0x1) - throw new OverflowException(); - } + string[] foundManagers = PEInterface.Managers + .Where(m => m.IsEnabled() && m.Status.Found) + .Select(m => m.Name) + .ToArray(); - int SettingsMagicValue = 0; - mask = 0x1; + int settingsMagicValue = 0; + int mask = 0x1; foreach (var setting in SettingsToSend) { bool enabled = Settings.Get( key: setting, - invert: Settings.ResolveKey(setting).StartsWith("Disable") - ); + invert: Settings.ResolveKey(setting).StartsWith("Disable")); if (enabled) - SettingsMagicValue |= mask; - mask = mask << 1; + settingsMagicValue |= mask; + mask <<= 1; if (mask == 0x1) throw new OverflowException(); } - foreach (var setting in new[] { "SP1", "SP2" }) + foreach (var sp in new[] { "SP1", "SP2" }) { - bool enabled; - if (setting == "SP1") - enabled = File.Exists("ForceUniGetUIPortable"); - else if (setting == "SP2") - enabled = CoreData.WasDaemon; - else - throw new NotImplementedException(); + bool enabled = sp switch + { + "SP1" => File.Exists("ForceUniGetUIPortable"), + "SP2" => CoreData.WasDaemon, + _ => throw new NotImplementedException(), + }; if (enabled) - SettingsMagicValue |= mask; - mask = mask << 1; + settingsMagicValue |= mask; + mask <<= 1; if (mask == 0x1) throw new OverflowException(); } - var request = new HttpRequestMessage(HttpMethod.Post, $"{HOST}/activity"); - - request.Headers.Add("clientId", ID); - request.Headers.Add("clientVersion", CoreData.VersionName); - request.Headers.Add("activeManagers", ManagerMagicValue.ToString()); - request.Headers.Add("activeSettings", SettingsMagicValue.ToString()); - request.Headers.Add("language", LanguageEngine.SelectedLocale); - - HttpClient _httpClient = new(CoreTools.GenericHttpClientParameters); - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); - HttpResponseMessage response = await _httpClient.SendAsync(request); - - if (response.IsSuccessStatusCode) + var ev = new UniGetUIActivityEvent { - Logger.Debug("[Telemetry] Call to /activity succeeded"); - } - else - { - Logger.Warn( - $"[Telemetry] Call to /activity failed with error code {response.StatusCode}" - ); - } + InstallID = GetRandomizedId(), + Locale = LanguageEngine.SelectedLocale, + EnabledManagers = enabledManagers, + FoundManagers = foundManagers, + ActiveSettings = settingsMagicValue, + Application = BuildApplicationInfo(), + Platform = BuildPlatformInfo(), + }; + + await PostToOpenSearchAsync(IndexActivity, ev, TelemetrySerializerContext.Trimming.UniGetUIActivityEvent); } catch (Exception ex) { - Logger.Error("[Telemetry] Hard crash when calling /activity"); + Logger.Error("[Telemetry] Hard crash in InitializeAsync"); Logger.Error(ex); } } @@ -145,32 +177,31 @@ public static void InstallPackage( IPackage package, TEL_OP_RESULT status, TEL_InstallReferral source - ) => PackageEndpoint(package, "install", status, source.ToString()); + ) => _ = TrackPackageEventAsync(package, "install", status, source.ToString()); public static void UpdatePackage(IPackage package, TEL_OP_RESULT status) => - PackageEndpoint(package, "update", status); + _ = TrackPackageEventAsync(package, "update", status); public static void DownloadPackage( IPackage package, TEL_OP_RESULT status, TEL_InstallReferral source - ) => PackageEndpoint(package, "download", status, source.ToString()); + ) => _ = TrackPackageEventAsync(package, "download", status, source.ToString()); public static void UninstallPackage(IPackage package, TEL_OP_RESULT status) => - PackageEndpoint(package, "uninstall", status); + _ = TrackPackageEventAsync(package, "uninstall", status); public static void PackageDetails(IPackage package, string eventSource) => - PackageEndpoint(package, "details", eventSource: eventSource); + _ = TrackPackageEventAsync(package, "details", eventSource: eventSource); public static void SharedPackage(IPackage package, string eventSource) => - PackageEndpoint(package, "share", eventSource: eventSource); + _ = TrackPackageEventAsync(package, "share", eventSource: eventSource); - private static async void PackageEndpoint( + private static async Task TrackPackageEventAsync( IPackage package, - string endpoint, + string operation, TEL_OP_RESULT? result = null, - string? eventSource = null - ) + string? eventSource = null) { try { @@ -178,39 +209,28 @@ private static async void PackageEndpoint( throw new ArgumentException("result and eventSource cannot both be null"); if (Settings.Get(Settings.K.DisableTelemetry)) return; + await CoreTools.WaitForInternetConnection(); - string ID = GetRandomizedId(); - - var request = new HttpRequestMessage(HttpMethod.Post, $"{HOST}/package/{endpoint}"); - - request.Headers.Add("clientId", ID); - request.Headers.Add("clientVersion", CoreData.VersionName); - request.Headers.Add("packageId", package.Id); - request.Headers.Add("managerName", package.Manager.Name); - request.Headers.Add("sourceName", package.Source.Name); - if (result is not null) - request.Headers.Add("operationResult", result.ToString()); - if (eventSource is not null) - request.Headers.Add("eventSource", eventSource); - - HttpClient _httpClient = new(CoreTools.GenericHttpClientParameters); - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); - HttpResponseMessage response = await _httpClient.SendAsync(request); - if (response.IsSuccessStatusCode) + var ev = new UniGetUIPackageEvent { - Logger.Debug($"[Telemetry] Call to /package/{endpoint} succeeded"); - } - else - { - Logger.Warn( - $"[Telemetry] Call to /package/{endpoint} failed with error code {response.StatusCode}" - ); - } + InstallID = GetRandomizedId(), + Locale = LanguageEngine.SelectedLocale, + Application = BuildApplicationInfo(), + Platform = BuildPlatformInfo(), + Operation = operation, + PackageId = package.Id, + ManagerName = package.Manager.Name, + SourceName = package.Source.Name, + OperationResult = result?.ToString(), + EventSource = eventSource, + }; + + await PostToOpenSearchAsync(IndexPackage, ev, TelemetrySerializerContext.Trimming.UniGetUIPackageEvent); } catch (Exception ex) { - Logger.Error($"[Telemetry] Hard crash when calling /package/{endpoint}"); + Logger.Error($"[Telemetry] Hard crash in TrackPackageEventAsync ({operation})"); Logger.Error(ex); } } @@ -218,59 +238,129 @@ private static async void PackageEndpoint( // ------------------------------------------------------------------------- public static void ImportBundle(BundleFormatType type) => - BundlesEndpoint("import", type.ToString()); + _ = TrackBundleEventAsync("import", type.ToString()); public static void ExportBundle(BundleFormatType type) => - BundlesEndpoint("export", type.ToString()); + _ = TrackBundleEventAsync("export", type.ToString()); - public static void ExportBatch() => BundlesEndpoint("export", "PS1_SCRIPT"); + public static void ExportBatch() => + _ = TrackBundleEventAsync("export", "PS1_SCRIPT"); - private static async void BundlesEndpoint(string endpoint, string type) + private static async Task TrackBundleEventAsync(string operation, string bundleType) { try { if (Settings.Get(Settings.K.DisableTelemetry)) return; + await CoreTools.WaitForInternetConnection(); - string ID = GetRandomizedId(); - var request = new HttpRequestMessage(HttpMethod.Post, $"{HOST}/bundles/{endpoint}"); + var ev = new UniGetUIBundleEvent + { + InstallID = GetRandomizedId(), + Locale = LanguageEngine.SelectedLocale, + Application = BuildApplicationInfo(), + Platform = BuildPlatformInfo(), + Operation = operation, + BundleType = bundleType, + }; + + await PostToOpenSearchAsync(IndexBundle, ev, TelemetrySerializerContext.Trimming.UniGetUIBundleEvent); + } + catch (Exception ex) + { + Logger.Error($"[Telemetry] Hard crash in TrackBundleEventAsync ({operation})"); + Logger.Error(ex); + } + } + + // ─── OpenSearch HTTP ────────────────────────────────────────────────────── - request.Headers.Add("clientId", ID); - request.Headers.Add("clientVersion", CoreData.VersionName); - request.Headers.Add("bundleType", type); + private static async Task PostToOpenSearchAsync(string indexName, T eventData, JsonTypeInfo typeInfo) + { + if (!CredentialsConfigured()) + return; + + try + { + string fullIndex = IndexPrefix + indexName; + string json = JsonSerializer.Serialize(eventData, typeInfo); + + string credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_openSearchUsername}:{_openSearchPassword}")); + + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{OpenSearchUrl}/{fullIndex}/_doc") + { + Content = content, + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); - HttpClient _httpClient = new(CoreTools.GenericHttpClientParameters); - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); HttpResponseMessage response = await _httpClient.SendAsync(request); if (response.IsSuccessStatusCode) - { - Logger.Debug($"[Telemetry] Call to /bundles/{endpoint} succeeded"); - } + Logger.Debug($"[Telemetry] Sent to {fullIndex}"); else - { - Logger.Warn( - $"[Telemetry] Call to /bundles/{endpoint} failed with error code {response.StatusCode}" - ); - } + Logger.Warn($"[Telemetry] {fullIndex} returned {(int)response.StatusCode}"); } catch (Exception ex) { - Logger.Error($"[Telemetry] Hard crash when calling /bundles/{endpoint}"); + Logger.Error($"[Telemetry] Hard crash posting to {indexName}"); Logger.Error(ex); } } + // ─── Helpers ───────────────────────────────────────────────────────────── + private static string GetRandomizedId() { - string ID = Settings.GetValue(Settings.K.TelemetryClientToken); - if (ID.Length != 64) + string id = Settings.GetValue(Settings.K.TelemetryClientToken); + if (id.Length != 64) { - ID = CoreTools.RandomString(64); - Settings.SetValue(Settings.K.TelemetryClientToken, ID); + id = CoreTools.RandomString(64); + Settings.SetValue(Settings.K.TelemetryClientToken, id); } + return id; + } - return ID; + private static TelemetryApplicationInfo BuildApplicationInfo() => + new() + { + Name = "UniGetUI", + Version = CoreData.VersionName, + DataSource = "NotApplicable", + Pricing = "Free", + Language = LanguageEngine.SelectedLocale, + ArchitectureType = RuntimeInformation.ProcessArchitecture.ToString(), + }; + + private static TelemetryPlatformInfo BuildPlatformInfo() => + new() + { + Name = GetPlatformName(), + Version = Environment.OSVersion.VersionString, + Architecture = RuntimeInformation.OSArchitecture.ToString(), + }; + + private static string GetPlatformName() + { + if (OperatingSystem.IsWindows()) return "Windows"; + if (OperatingSystem.IsMacOS()) return "Mac"; + return "Linux"; } } + +// Source-generated JSON context — required for AOT/trimmed builds (WinUI). +// Reflection-based serialization is disabled in that configuration. +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(UniGetUIActivityEvent))] +[JsonSerializable(typeof(UniGetUIPackageEvent))] +[JsonSerializable(typeof(UniGetUIBundleEvent))] +internal partial class TelemetrySerializerContext : JsonSerializerContext +{ + internal static readonly TelemetrySerializerContext Trimming = + new(new JsonSerializerOptions(SerializationHelpers.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }); +} diff --git a/src/UniGetUI/App.xaml.cs b/src/UniGetUI/App.xaml.cs index dfb70bb391..970f5826cb 100644 --- a/src/UniGetUI/App.xaml.cs +++ b/src/UniGetUI/App.xaml.cs @@ -18,6 +18,7 @@ using UniGetUI.PackageEngine.Classes.Manager.Classes; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.Pages.DialogPages; +using UniGetUI.Services; using Windows.ApplicationModel.Activation; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; @@ -346,6 +347,9 @@ private async Task LoadComponentsAsync() await Task.WhenAll(iniTasks); // Load non-essential components + TelemetryHandler.Configure( + Secrets.GetOpenSearchUsername(), + Secrets.GetOpenSearchPassword()); _ = TelemetryHandler.InitializeAsync(); _ = IconDatabase.Instance.LoadIconAndScreenshotsDatabaseAsync(); diff --git a/src/UniGetUI/Services/Secrets.cs b/src/UniGetUI/Services/Secrets.cs index 675ecab94e..fd77d1af9b 100644 --- a/src/UniGetUI/Services/Secrets.cs +++ b/src/UniGetUI/Services/Secrets.cs @@ -10,6 +10,10 @@ internal static partial class Secrets public static partial string GetGitHubClientId(); public static partial string GetGitHubClientSecret(); + + public static partial string GetOpenSearchUsername(); + + public static partial string GetOpenSearchPassword(); /* ------------------------------------------------------------------------ */ } } diff --git a/src/UniGetUI/Services/generate-secrets.ps1 b/src/UniGetUI/Services/generate-secrets.ps1 index c6ce3180a5..8681eef882 100644 --- a/src/UniGetUI/Services/generate-secrets.ps1 +++ b/src/UniGetUI/Services/generate-secrets.ps1 @@ -14,9 +14,13 @@ if (-not (Test-Path -Path "Generated Files")) { $clientId = $env:UNIGETUI_GITHUB_CLIENT_ID $clientSecret = $env:UNIGETUI_GITHUB_CLIENT_SECRET +$openSearchUsername = $env:UNIGETUI_OPENSEARCH_USERNAME +$openSearchPassword = $env:UNIGETUI_OPENSEARCH_PASSWORD if (-not $clientId) { $clientId = "CLIENT_ID_UNSET" } if (-not $clientSecret) { $clientSecret = "CLIENT_SECRET_UNSET" } +if (-not $openSearchUsername) { $openSearchUsername = "OPENSEARCH_USERNAME_UNSET" } +if (-not $openSearchPassword) { $openSearchPassword = "OPENSEARCH_PASSWORD_UNSET" } @" // Auto-generated file - do not modify @@ -26,6 +30,8 @@ namespace UniGetUI.Services { public static partial string GetGitHubClientId() => `"$clientId`"; public static partial string GetGitHubClientSecret() => `"$clientSecret`"; + public static partial string GetOpenSearchUsername() => `"$openSearchUsername`"; + public static partial string GetOpenSearchPassword() => `"$openSearchPassword`"; } } "@ | Set-Content -Encoding UTF8 "Generated Files\Secrets.Generated.cs"