From e9db3aa3509fdd041826c386226ba59246ea2f17 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 14:45:49 +0000 Subject: [PATCH 01/12] feat(telemetry): integrate Sentry crash forensics and PostHog analytics pipeline Implement cross-platform telemetry, crash reporting, and anonymous usage analytics for GenHub. Sentry captures unhandled application exceptions and crash forensics with automated stack trace sanitization, while PostHog ingests structured lifecycle and game session events under strict user privacy preferences. Implemented with Gemini 3.7 Flash (High) via Antigravity CLI. --- .../Constants/TelemetryConstants.cs | 208 ++++++++++ .../Telemetry/ITelemetrySanitizer.cs | 30 ++ .../Interfaces/Telemetry/ITelemetryService.cs | 65 ++++ .../Interfaces/Telemetry/ITelemetrySink.cs | 39 ++ .../GenHub.Core/Models/Common/UserSettings.cs | 18 + .../Models/Enums/TelemetryLevel.cs | 22 ++ .../Models/Telemetry/Breadcrumb.cs | 30 ++ .../Models/Telemetry/CrashReport.cs | 45 +++ .../Models/Telemetry/TelemetryEvent.cs | 51 +++ .../Utilities/TelemetrySanitizer.cs | 172 +++++++++ .../Telemetry/AnalyticsTelemetrySinkTests.cs | 184 +++++++++ .../Telemetry/LoggingTelemetrySinkTests.cs | 80 ++++ .../Telemetry/SentryTelemetrySinkTests.cs | 190 ++++++++++ .../Telemetry/TelemetryConstantsTests.cs | 102 +++++ .../Telemetry/TelemetrySanitizerTests.cs | 132 +++++++ .../Telemetry/TelemetryServiceTests.cs | 180 +++++++++ .../Telemetry/UserSettingsTelemetryTests.cs | 73 ++++ GenHub/GenHub/App.axaml.cs | 31 ++ .../GenHub/Common/Services/DownloadService.cs | 18 +- .../Services/VelopackUpdateManager.cs | 22 +- .../Infrastructure/GameProcessManager.cs | 98 ++++- .../Settings/ViewModels/SettingsViewModel.cs | 11 + .../Telemetry/Services/TelemetryService.cs | 355 ++++++++++++++++++ .../Telemetry/Sinks/AnalyticsTelemetrySink.cs | 161 ++++++++ .../Telemetry/Sinks/LoggingTelemetrySink.cs | 69 ++++ .../Telemetry/Sinks/NullTelemetrySink.cs | 31 ++ .../Telemetry/Sinks/SentryTelemetrySink.cs | 236 ++++++++++++ .../DependencyInjection/AppServices.cs | 1 + .../DependencyInjection/TelemetryModule.cs | 53 +++ docs/dev/constants.md | 40 +- 30 files changed, 2731 insertions(+), 16 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/TelemetryConstants.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySanitizer.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetryService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySink.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/TelemetryLevel.cs create mode 100644 GenHub/GenHub.Core/Models/Telemetry/Breadcrumb.cs create mode 100644 GenHub/GenHub.Core/Models/Telemetry/CrashReport.cs create mode 100644 GenHub/GenHub.Core/Models/Telemetry/TelemetryEvent.cs create mode 100644 GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/UserSettingsTelemetryTests.cs create mode 100644 GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs create mode 100644 GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs create mode 100644 GenHub/GenHub/Features/Telemetry/Sinks/LoggingTelemetrySink.cs create mode 100644 GenHub/GenHub/Features/Telemetry/Sinks/NullTelemetrySink.cs create mode 100644 GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs diff --git a/GenHub/GenHub.Core/Constants/TelemetryConstants.cs b/GenHub/GenHub.Core/Constants/TelemetryConstants.cs new file mode 100644 index 000000000..cb2fa24ce --- /dev/null +++ b/GenHub/GenHub.Core/Constants/TelemetryConstants.cs @@ -0,0 +1,208 @@ +namespace GenHub.Core.Constants; + +/// +/// Centralized constants for telemetry event names, properties, and configuration values. +/// +public static class TelemetryConstants +{ + /// + /// Application name identifier for telemetry. + /// + public const string AppName = "GenHub"; + + /// + /// Default flush interval in seconds for background batching. + /// + public const int DefaultFlushIntervalSeconds = 30; + + /// + /// Maximum capacity of the in-memory bounded channel before dropping oldest events. + /// + public const int MaxQueueCapacity = 500; + + /// + /// Heartbeat interval in minutes for active game sessions. + /// + public const int SessionHeartbeatIntervalMinutes = 5; + + /// + /// Maximum number of breadcrumbs preserved in the circular buffer for crash forensics. + /// + public const int MaxBreadcrumbsCount = 50; + + /// + /// Mask string for sanitized sensitive data or user directories. + /// + public const string UserDirectoryMask = ""; + + /// + /// Mask string for sanitized workspace directories. + /// + public const string WorkspaceDirectoryMask = ""; + + /// + /// Mask string for sanitized Wine prefix directories. + /// + public const string WinePrefixMask = ""; + + /// + /// Mask string for sanitized IP addresses. + /// + public const string IpAddressMask = ""; + + /// + /// Mask string for sanitized tokens and secrets. + /// + public const string SecretTokenMask = ""; + + /// + /// Default Sentry DSN endpoint for crash reporting. + /// + public const string DefaultSentryDsn = "https://06a9269c6418a6917f0fec49e1589e44@o4511370888347648.ingest.de.sentry.io/4511943606927440"; + + /// + /// Default PostHog API project token for anonymous analytics. + /// + public const string DefaultPostHogApiKey = "phc_yJwFRxbvQ9HUge9kC3Lmt5DG3CpHt4DWnaJYK5YiK98g"; + + /// + /// Default PostHog host URL. + /// + public const string DefaultPostHogHost = "https://us.i.posthog.com"; + + /// + /// Default PostHog event capture endpoint. + /// + public const string DefaultPostHogCaptureEndpoint = "https://us.i.posthog.com/capture/"; + + /// + /// Default PostHog project identifier. + /// + public const string DefaultPostHogProjectId = "567732"; + + /// + /// Telemetry event names. + /// + public static class Events + { + /// Emitted when a game process starts. + public const string GameSessionStarted = "game_session_started"; + + /// Emitted periodically while a game process is running. + public const string GameSessionHeartbeat = "game_session_heartbeat"; + + /// Emitted when a game process exits. + public const string GameSessionEnded = "game_session_ended"; + + /// Emitted when a content or mod download completes. + public const string ContentDownloadCompleted = "content_download_completed"; + + /// Emitted when an application update check finishes. + public const string AppUpdateChecked = "app_update_checked"; + + /// Emitted when an application update is applied. + public const string AppUpdateApplied = "app_update_applied"; + + /// Emitted when CAS workspace reconciliation completes. + public const string CasReconcileCompleted = "cas_reconcile_completed"; + + /// Emitted when an unhandled application exception or crash occurs. + public const string AppCrash = "app_unhandled_crash"; + } + + /// + /// Telemetry event property keys. + /// + public static class Properties + { + /// Session identifier. + public const string SessionId = "session_id"; + + /// Game type (e.g. Generals, ZeroHour). + public const string GameType = "game_type"; + + /// Profile identifier. + public const string ProfileId = "profile_id"; + + /// Profile name. + public const string ProfileName = "profile_name"; + + /// Duration in seconds. + public const string DurationSeconds = "duration_seconds"; + + /// Process exit code. + public const string ExitCode = "exit_code"; + + /// Operating system platform. + public const string Platform = "platform"; + + /// Game runner or execution environment (Native, Wine, Proton, etc.). + public const string Runner = "runner"; + + /// Screen resolution. + public const string Resolution = "resolution"; + + /// Manifest identifier. + public const string ManifestId = "manifest_id"; + + /// Content type (e.g. Mod, Patch, Map). + public const string ContentType = "content_type"; + + /// Size in megabytes. + public const string SizeMb = "size_mb"; + + /// Average network speed in Mbps. + public const string SpeedMbps = "speed_mbps"; + + /// Source provider name. + public const string SourceProvider = "source_provider"; + + /// Retry attempt count. + public const string RetryCount = "retry_count"; + + /// Starting version for update. + public const string FromVersion = "from_version"; + + /// Target version for update. + public const string ToVersion = "to_version"; + + /// Update channel or branch. + public const string Channel = "channel"; + + /// Restart duration in milliseconds. + public const string RestartDurationMs = "restart_duration_ms"; + + /// Cache hit rate percentage. + public const string CacheHitRate = "cache_hit_rate"; + + /// Number of files reconciled. + public const string FileCount = "file_count"; + + /// Bytes reconciled. + public const string BytesReconciled = "bytes_reconciled"; + + /// Exception type name. + public const string ExceptionType = "exception_type"; + + /// Exception error message. + public const string ExceptionMessage = "exception_message"; + + /// Exception stack trace. + public const string StackTrace = "stack_trace"; + + /// Indicates whether the exception was fatal. + public const string IsFatal = "is_fatal"; + + /// Context or subsystem where exception occurred. + public const string Context = "context"; + + /// Installation identifier. + public const string InstallationId = "installation_id"; + + /// Application version. + public const string AppVersion = "app_version"; + + /// Executable path or name. + public const string ExecutablePath = "executable_path"; + } +} diff --git a/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySanitizer.cs b/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySanitizer.cs new file mode 100644 index 000000000..60054c385 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySanitizer.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace GenHub.Core.Interfaces.Telemetry; + +/// +/// Sanitizes sensitive user data, personal paths, usernames, IP addresses, and tokens from telemetry payloads. +/// +public interface ITelemetrySanitizer +{ + /// + /// Sanitizes an input string by removing sensitive usernames, home folders, and personal paths. + /// + /// The input string to sanitize. + /// The sanitized string with sensitive data masked. + string SanitizeString(string? input); + + /// + /// Sanitizes an exception stack trace. + /// + /// The raw stack trace string. + /// The sanitized stack trace. + string SanitizeStackTrace(string? stackTrace); + + /// + /// Recursively sanitizes a dictionary of properties. + /// + /// The raw properties dictionary. + /// A sanitized dictionary. + IReadOnlyDictionary SanitizeProperties(IReadOnlyDictionary? properties); +} diff --git a/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetryService.cs b/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetryService.cs new file mode 100644 index 000000000..c8a8fa891 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetryService.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; + +namespace GenHub.Core.Interfaces.Telemetry; + +/// +/// Core contract for recording and dispatching structured telemetry events, crashes, and diagnostics. +/// +public interface ITelemetryService +{ + /// + /// Gets the current active telemetry consent level. + /// + TelemetryLevel CurrentLevel { get; } + + /// + /// Checks if the specified telemetry level is permitted under current user settings. + /// + /// The telemetry level to check. + /// true if permitted; otherwise, false. + bool IsEnabled(TelemetryLevel level); + + /// + /// Tracks an anonymous structured telemetry event. + /// + /// The unique event name. + /// Optional structured properties. + /// Minimum required telemetry level (defaults to AnonymousMetrics). + void TrackEvent(string eventName, IReadOnlyDictionary? properties = null, TelemetryLevel level = TelemetryLevel.AnonymousMetrics); + + /// + /// Tracks an exception or crash diagnostics with sanitized stack trace and breadcrumbs. + /// + /// The exception to track. + /// Optional context or subsystem name. + /// Optional metadata properties. + /// Whether the exception caused a fatal crash. + void TrackException(Exception exception, string? context = null, IReadOnlyDictionary? properties = null, bool isFatal = false); + + /// + /// Adds a breadcrumb record to the in-memory circular buffer for crash investigation. + /// + /// The breadcrumb message. + /// The category (e.g. "ui", "game", "download"). + /// Optional structured data. + void AddBreadcrumb(string message, string? category = null, IReadOnlyDictionary? data = null); + + /// + /// Gets the recent breadcrumb history from the circular buffer. + /// + /// A snapshot of recent breadcrumbs. + IReadOnlyList GetRecentBreadcrumbs(); + + /// + /// Asynchronously flushes all queued telemetry events to registered sinks. + /// + /// Cancellation token. + /// An operation result indicating whether flush succeeded. + Task> FlushAsync(CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySink.cs b/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySink.cs new file mode 100644 index 000000000..871ba3ae7 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Telemetry/ITelemetrySink.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; + +namespace GenHub.Core.Interfaces.Telemetry; + +/// +/// Defines a pluggable destination sink for telemetry events. +/// +public interface ITelemetrySink +{ + /// + /// Gets the unique name identifier of the sink. + /// + string Name { get; } + + /// + /// Determines if this sink handles the given telemetry event. + /// + /// The telemetry event. + /// true if handled; otherwise, false. + bool CanHandle(TelemetryEvent telemetryEvent); + + /// + /// Emits a single telemetry event to the sink. + /// + /// The telemetry event to emit. + /// Cancellation token. + /// An operation result indicating success or failure. + Task> EmitAsync(TelemetryEvent telemetryEvent, CancellationToken cancellationToken = default); + + /// + /// Flushes any pending buffered events to the remote endpoint. + /// + /// Cancellation token. + /// An operation result indicating success or failure. + Task> FlushAsync(CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index c33263307..ae8a4878e 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -141,6 +141,21 @@ public bool IsExplicitlySet(string propertyName) /// public bool IsNotificationMuted { get; set; } + /// + /// Gets or sets the telemetry collection preference level. + /// + public TelemetryLevel TelemetryPreference { get; set; } = TelemetryLevel.AnonymousMetrics; + + /// + /// Gets or sets a value indicating whether the telemetry onboarding prompt has been shown. + /// + public bool EnableTelemetryPromptShown { get; set; } + + /// + /// Gets or sets the anonymous installation GUID used for aggregate metrics. + /// + public string? AnonymousInstallationId { get; set; } + /// Creates a deep copy of the current UserSettings instance. /// A new UserSettings instance with all properties deeply copied. public UserSettings Clone() @@ -170,6 +185,9 @@ public UserSettings Clone() ApplicationDataPath = ApplicationDataPath, HasSeenQuickStart = HasSeenQuickStart, IsNotificationMuted = IsNotificationMuted, + TelemetryPreference = TelemetryPreference, + EnableTelemetryPromptShown = EnableTelemetryPromptShown, + AnonymousInstallationId = AnonymousInstallationId, SubscribedPrNumber = SubscribedPrNumber, SubscribedBranch = SubscribedBranch, diff --git a/GenHub/GenHub.Core/Models/Enums/TelemetryLevel.cs b/GenHub/GenHub.Core/Models/Enums/TelemetryLevel.cs new file mode 100644 index 000000000..7c86ea3ac --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/TelemetryLevel.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Defines user consent levels for telemetry data collection and dispatch. +/// +public enum TelemetryLevel +{ + /// + /// Telemetry is completely disabled. No network transmission or external sinks. + /// + Disabled = 0, + + /// + /// Sends only unhandled exceptions and fatal crash diagnostics to crash reporting sinks. + /// + CrashReportsOnly = 1, + + /// + /// Sends anonymous usage metrics, game session durations, download counts, and update adoption metrics. + /// + AnonymousMetrics = 2, +} diff --git a/GenHub/GenHub.Core/Models/Telemetry/Breadcrumb.cs b/GenHub/GenHub.Core/Models/Telemetry/Breadcrumb.cs new file mode 100644 index 000000000..0b6f22364 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Telemetry/Breadcrumb.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace GenHub.Core.Models.Telemetry; + +/// +/// Represents a breadcrumb trail record leading up to an event or crash. +/// +public sealed class Breadcrumb +{ + /// + /// Gets the breadcrumb message. + /// + public string Message { get; init; } = string.Empty; + + /// + /// Gets the breadcrumb category. + /// + public string Category { get; init; } = "general"; + + /// + /// Gets the timestamp when the breadcrumb was added. + /// + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + /// + /// Gets optional structured data associated with the breadcrumb. + /// + public IReadOnlyDictionary? Data { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/Telemetry/CrashReport.cs b/GenHub/GenHub.Core/Models/Telemetry/CrashReport.cs new file mode 100644 index 000000000..6a7a940ee --- /dev/null +++ b/GenHub/GenHub.Core/Models/Telemetry/CrashReport.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace GenHub.Core.Models.Telemetry; + +/// +/// Represents a structured crash or unhandled exception report. +/// +public sealed class CrashReport +{ + /// + /// Gets the exception type name. + /// + public string ExceptionType { get; init; } = string.Empty; + + /// + /// Gets the sanitized exception message. + /// + public string Message { get; init; } = string.Empty; + + /// + /// Gets the sanitized stack trace. + /// + public string StackTrace { get; init; } = string.Empty; + + /// + /// Gets the timestamp when the crash occurred (UTC). + /// + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + /// + /// Gets the breadcrumb trail preceding the crash. + /// + public IReadOnlyList Breadcrumbs { get; init; } = []; + + /// + /// Gets additional structured metadata properties. + /// + public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); + + /// + /// Gets a value indicating whether the crash was fatal to the application process. + /// + public bool IsFatal { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/Telemetry/TelemetryEvent.cs b/GenHub/GenHub.Core/Models/Telemetry/TelemetryEvent.cs new file mode 100644 index 000000000..992bc9ad4 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Telemetry/TelemetryEvent.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Telemetry; + +/// +/// Represents an immutable structured telemetry event. +/// +public sealed class TelemetryEvent +{ + /// + /// Gets the unique event name identifier. + /// + public string EventName { get; init; } = string.Empty; + + /// + /// Gets the timestamp when the event was recorded (UTC). + /// + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + /// + /// Gets the minimum telemetry consent level required for this event. + /// + public TelemetryLevel Level { get; init; } = TelemetryLevel.AnonymousMetrics; + + /// + /// Gets the anonymous installation identifier. + /// + public string? InstallationId { get; init; } + + /// + /// Gets the session identifier if applicable. + /// + public string? SessionId { get; init; } + + /// + /// Gets the application version. + /// + public string AppVersion { get; init; } = string.Empty; + + /// + /// Gets the operating system platform description. + /// + public string Platform { get; init; } = string.Empty; + + /// + /// Gets the custom properties dictionary for the event. + /// + public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); +} diff --git a/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs b/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs new file mode 100644 index 000000000..2ab171623 --- /dev/null +++ b/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Telemetry; + +namespace GenHub.Core.Utilities; + +/// +/// Default implementation of that strips PII, usernames, home directories, +/// wine prefixes, IP addresses, and authorization tokens. +/// +public partial class TelemetrySanitizer : ITelemetrySanitizer +{ + [GeneratedRegex(@"\b(?:\d{1,3}\.){3}\d{1,3}\b", RegexOptions.Compiled)] + private static partial Regex Ipv4Regex(); + + [GeneratedRegex(@"\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b", RegexOptions.Compiled)] + private static partial Regex Ipv6Regex(); + + [GeneratedRegex(@"gh[pousr]_[A-Za-z0-9_]{20,}", RegexOptions.Compiled)] + private static partial Regex GitHubTokenRegex(); + + [GeneratedRegex(@"github_pat_[A-Za-z0-9_]{20,}", RegexOptions.Compiled)] + private static partial Regex GitHubFineGrainedTokenRegex(); + + [GeneratedRegex(@"(?i)bearer\s+[a-zA-Z0-9_\-\.]{20,}", RegexOptions.Compiled)] + private static partial Regex BearerTokenRegex(); + + [GeneratedRegex(@"[a-zA-Z]:\\(?:Users|Documents and Settings)\\[^\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex WindowsUserDirRegex(); + + [GeneratedRegex(@"/(?:home|Users)/[^/]+", RegexOptions.Compiled)] + private static partial Regex UnixUserDirRegex(); + + [GeneratedRegex(@"/[^\s""]+/\.wine(?:-[^\s""]+)?/drive_c", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex WinePrefixRegex(); + + private readonly string? _userProfilePath; + private readonly string? _userName; + + /// + /// Initializes a new instance of the class. + /// + public TelemetrySanitizer() + { + try + { + _userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _userName = Environment.UserName; + } + catch + { + _userProfilePath = null; + _userName = null; + } + } + + /// + public string SanitizeString(string? input) + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + var result = input; + + // Mask exact user profile path if available + if (!string.IsNullOrEmpty(_userProfilePath) && _userProfilePath.Length > 2) + { + result = result.Replace(_userProfilePath, TelemetryConstants.UserDirectoryMask, StringComparison.OrdinalIgnoreCase); + } + + // Mask generic Windows user directory patterns (e.g. C:\Users\john) + result = WindowsUserDirRegex().Replace(result, TelemetryConstants.UserDirectoryMask); + + // Mask generic Unix/macOS user directory patterns (e.g. /home/john or /Users/john) + result = UnixUserDirRegex().Replace(result, TelemetryConstants.UserDirectoryMask); + + // Mask Wine prefix paths + result = WinePrefixRegex().Replace(result, TelemetryConstants.WinePrefixMask); + + // Mask GitHub & authorization tokens + result = GitHubTokenRegex().Replace(result, TelemetryConstants.SecretTokenMask); + result = GitHubFineGrainedTokenRegex().Replace(result, TelemetryConstants.SecretTokenMask); + result = BearerTokenRegex().Replace(result, "Bearer " + TelemetryConstants.SecretTokenMask); + + // Mask IP addresses + result = Ipv4Regex().Replace(result, TelemetryConstants.IpAddressMask); + result = Ipv6Regex().Replace(result, TelemetryConstants.IpAddressMask); + + // Mask exact username if prominent + if (!string.IsNullOrEmpty(_userName) && _userName.Length > 2 && !_userName.Equals("user", StringComparison.OrdinalIgnoreCase)) + { + result = Regex.Replace(result, $@"\b{Regex.Escape(_userName)}\b", "", RegexOptions.IgnoreCase); + } + + return result; + } + + /// + public string SanitizeStackTrace(string? stackTrace) + { + if (string.IsNullOrEmpty(stackTrace)) + { + return string.Empty; + } + + return SanitizeString(stackTrace); + } + + /// + public IReadOnlyDictionary SanitizeProperties(IReadOnlyDictionary? properties) + { + if (properties == null || properties.Count == 0) + { + return new Dictionary(); + } + + var sanitized = new Dictionary(properties.Count); + + foreach (var (key, val) in properties) + { + sanitized[key] = SanitizeValue(val); + } + + return sanitized; + } + + private object? SanitizeValue(object? value) + { + if (value == null) + { + return null; + } + + if (value is string strValue) + { + return SanitizeString(strValue); + } + + if (value is IReadOnlyDictionary nestedDict) + { + return SanitizeProperties(nestedDict); + } + + if (value is IDictionary dict) + { + var newDict = new Dictionary(dict.Count); + foreach (var kvp in dict) + { + newDict[kvp.Key] = SanitizeValue(kvp.Value); + } + + return newDict; + } + + if (value is IEnumerable stringList) + { + var sanitizedList = new List(); + foreach (var item in stringList) + { + sanitizedList.Add(SanitizeString(item)); + } + + return sanitizedList; + } + + return value; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs new file mode 100644 index 000000000..a51c43871 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Telemetry; +using GenHub.Features.Telemetry.Sinks; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for . +/// +public class AnalyticsTelemetrySinkTests +{ + private readonly Mock> _loggerMock = new(); + private readonly AnalyticsTelemetrySink _sink; + + /// + /// Initializes a new instance of the class. + /// + public AnalyticsTelemetrySinkTests() + { + _sink = new AnalyticsTelemetrySink(_loggerMock.Object); + } + + /// + /// Verifies sink metadata and CanHandle predicate. + /// + [Fact] + public void CanHandle_OnlyHandlesAnonymousMetricsEvents() + { + var anonymousEvent = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + Level = TelemetryLevel.AnonymousMetrics, + }; + + var crashEvent = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + }; + + Assert.True(_sink.CanHandle(anonymousEvent)); + Assert.False(_sink.CanHandle(crashEvent)); + } + + /// + /// Verifies EndpointUrl and ApiKey properties default to configured PostHog constants. + /// + [Fact] + public void EndpointUrlAndApiKey_DefaultToPostHogConstants() + { + Assert.Equal(TelemetryConstants.DefaultPostHogCaptureEndpoint, _sink.EndpointUrl); + Assert.Equal(TelemetryConstants.DefaultPostHogApiKey, _sink.ApiKey); + } + + /// + /// Verifies EmitAsync succeeds and buffers locally when no HTTP client is configured. + /// + [Fact] + public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() + { + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.ContentDownloadCompleted, + Level = TelemetryLevel.AnonymousMetrics, + Properties = new Dictionary + { + [TelemetryConstants.Properties.SizeMb] = 450.0, + [TelemetryConstants.Properties.DurationSeconds] = 12.5, + }, + }; + + var result = await _sink.EmitAsync(ev); + Assert.True(result.Success); + } + + /// + /// Verifies EmitAsync sends request formatted for PostHog capture API. + /// + [Fact] + public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayload() + { + HttpRequestMessage? capturedRequest = null; + string? capturedBody = null; + + var handler = new TestHandler(async request => + { + capturedRequest = request; + if (request.Content != null) + { + capturedBody = await request.Content.ReadAsStringAsync(); + } + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + using var client = new HttpClient(handler); + var sink = new AnalyticsTelemetrySink(_loggerMock.Object, client); + + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + Level = TelemetryLevel.AnonymousMetrics, + InstallationId = "inst-9999", + SessionId = "sess-7777", + AppVersion = "1.0.0", + Platform = "Linux", + Properties = new Dictionary + { + [TelemetryConstants.Properties.GameType] = "ZeroHour", + }, + }; + + var result = await sink.EmitAsync(ev); + + Assert.True(result.Success); + Assert.NotNull(capturedRequest); + Assert.Equal(TelemetryConstants.DefaultPostHogCaptureEndpoint, capturedRequest.RequestUri?.ToString()); + + Assert.NotNull(capturedBody); + using var jsonDoc = JsonDocument.Parse(capturedBody); + Assert.Equal(TelemetryConstants.DefaultPostHogApiKey, jsonDoc.RootElement.GetProperty("api_key").GetString()); + Assert.Equal(TelemetryConstants.Events.GameSessionStarted, jsonDoc.RootElement.GetProperty("event").GetString()); + Assert.Equal("inst-9999", jsonDoc.RootElement.GetProperty("distinct_id").GetString()); + + var properties = jsonDoc.RootElement.GetProperty("properties"); + Assert.Equal("GenHub", properties.GetProperty("$lib").GetString()); + Assert.Equal("sess-7777", properties.GetProperty("$session_id").GetString()); + Assert.Equal("ZeroHour", properties.GetProperty(TelemetryConstants.Properties.GameType).GetString()); + } + + /// + /// Verifies EmitAsync buffers and returns failure when endpoint returns error. + /// + [Fact] + public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() + { + var handler = new TestHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway))); + using var client = new HttpClient(handler); + var sink = new AnalyticsTelemetrySink(_loggerMock.Object, client); + + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + Level = TelemetryLevel.AnonymousMetrics, + }; + + var result = await sink.EmitAsync(ev); + Assert.False(result.Success); + } + + /// + /// Verifies FlushAsync flushes buffered events when client is active. + /// + [Fact] + public async Task FlushAsync_FlushesBufferedEventsSuccessfully() + { + var handler = new TestHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + using var client = new HttpClient(handler); + var sink = new AnalyticsTelemetrySink(_loggerMock.Object, client); + + var flushResult = await sink.FlushAsync(); + Assert.True(flushResult.Success); + } + + private sealed class TestHandler(Func> handlerFunc) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return handlerFunc(request); + } + } +} + diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs new file mode 100644 index 000000000..dd20ea722 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Telemetry; +using GenHub.Features.Telemetry.Sinks; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for . +/// +public class LoggingTelemetrySinkTests +{ + private readonly Mock> _loggerMock = new(); + private readonly LoggingTelemetrySink _sink; + + /// + /// Initializes a new instance of the class. + /// + public LoggingTelemetrySinkTests() + { + _sink = new LoggingTelemetrySink(_loggerMock.Object); + } + + /// + /// Verifies sink metadata and CanHandle predicate. + /// + [Fact] + public void SinkProperties_AreValid() + { + Assert.Equal("Logging", _sink.Name); + + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + Level = TelemetryLevel.AnonymousMetrics, + }; + + Assert.True(_sink.CanHandle(ev)); + } + + /// + /// Verifies EmitAsync succeeds for normal event. + /// + [Fact] + public async Task EmitAsync_StandardEvent_ReturnsSuccess() + { + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + SessionId = "1234", + Level = TelemetryLevel.AnonymousMetrics, + Properties = new Dictionary { ["game"] = "Generals" }, + }; + + var result = await _sink.EmitAsync(ev); + Assert.True(result.Success); + } + + /// + /// Verifies EmitAsync succeeds for crash event. + /// + [Fact] + public async Task EmitAsync_CrashEvent_ReturnsSuccess() + { + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + Properties = new Dictionary { ["error"] = "Fatal error" }, + }; + + var result = await _sink.EmitAsync(ev); + Assert.True(result.Success); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs new file mode 100644 index 000000000..527b8608d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Telemetry; +using GenHub.Features.Telemetry.Sinks; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for . +/// +public class SentryTelemetrySinkTests +{ + private readonly Mock> _loggerMock = new(); + private readonly SentryTelemetrySink _sink; + + /// + /// Initializes a new instance of the class. + /// + public SentryTelemetrySinkTests() + { + _sink = new SentryTelemetrySink(_loggerMock.Object); + } + + /// + /// Verifies sink metadata and CanHandle predicate for crash events. + /// + [Fact] + public void CanHandle_HandlesCrashEventsAndCrashLevel() + { + var crashEvent = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + }; + + var standardEvent = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + Level = TelemetryLevel.AnonymousMetrics, + }; + + Assert.True(_sink.CanHandle(crashEvent)); + Assert.False(_sink.CanHandle(standardEvent)); + } + + /// + /// Verifies DSN endpoint property defaults to configured constant. + /// + [Fact] + public void DsnEndpoint_DefaultsToConfiguredConstant() + { + Assert.Equal(TelemetryConstants.DefaultSentryDsn, _sink.DsnEndpoint); + } + + /// + /// Verifies EmitAsync succeeds and buffers locally when no HTTP client is configured. + /// + [Fact] + public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() + { + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + Properties = new Dictionary + { + [TelemetryConstants.Properties.ExceptionType] = "System.NullReferenceException", + [TelemetryConstants.Properties.ExceptionMessage] = "Object reference not set", + }, + }; + + var result = await _sink.EmitAsync(ev); + Assert.True(result.Success); + } + + /// + /// Verifies EmitAsync sends request to Sentry store endpoint with auth headers. + /// + [Fact] + public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAuthHeader() + { + HttpRequestMessage? capturedRequest = null; + string? capturedBody = null; + + var handler = new TestHandler(async request => + { + capturedRequest = request; + if (request.Content != null) + { + capturedBody = await request.Content.ReadAsStringAsync(); + } + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + using var client = new HttpClient(handler); + var sink = new SentryTelemetrySink(_loggerMock.Object, client); + + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + InstallationId = "inst-12345", + AppVersion = "1.0.0", + Platform = "Linux 6.8.0", + Properties = new Dictionary + { + [TelemetryConstants.Properties.ExceptionType] = "System.InvalidOperationException", + [TelemetryConstants.Properties.ExceptionMessage] = "Reconciliation failed", + [TelemetryConstants.Properties.StackTrace] = "at Foo.Bar() in Foo.cs:line 10", + [TelemetryConstants.Properties.IsFatal] = true, + }, + }; + + var result = await sink.EmitAsync(ev); + + Assert.True(result.Success); + Assert.NotNull(capturedRequest); + Assert.Contains("/api/4511943606927440/store/", capturedRequest.RequestUri?.ToString()); + Assert.True(capturedRequest.Headers.Contains("X-Sentry-Auth")); + + var authHeader = capturedRequest.Headers.GetValues("X-Sentry-Auth").FirstOrDefault(); + Assert.Contains("sentry_key=06a9269c6418a6917f0fec49e1589e44", authHeader); + + Assert.NotNull(capturedBody); + using var jsonDoc = JsonDocument.Parse(capturedBody); + Assert.Equal("fatal", jsonDoc.RootElement.GetProperty("level").GetString()); + Assert.Equal("csharp", jsonDoc.RootElement.GetProperty("platform").GetString()); + } + + /// + /// Verifies EmitAsync handles endpoint failure gracefully by buffering and returning failure. + /// + [Fact] + public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() + { + var handler = new TestHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError))); + using var client = new HttpClient(handler); + var sink = new SentryTelemetrySink(_loggerMock.Object, client); + + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + }; + + var result = await sink.EmitAsync(ev); + Assert.False(result.Success); + } + + /// + /// Verifies FlushAsync flushes buffered events when client is active. + /// + [Fact] + public async Task FlushAsync_FlushesBufferedEventsSuccessfully() + { + var sendCount = 0; + var handler = new TestHandler(_ => + { + Interlocked.Increment(ref sendCount); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + using var client = new HttpClient(handler); + var sink = new SentryTelemetrySink(_loggerMock.Object, client); + + var flushResult = await sink.FlushAsync(); + Assert.True(flushResult.Success); + } + + private sealed class TestHandler(Func> handlerFunc) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return handlerFunc(request); + } + } +} + diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs new file mode 100644 index 000000000..591c77317 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs @@ -0,0 +1,102 @@ +using GenHub.Core.Constants; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for . +/// +public class TelemetryConstantsTests +{ + /// + /// Verifies core telemetry constants have expected default values. + /// + [Fact] + public void Constants_HaveExpectedDefaults() + { + Assert.Equal("GenHub", TelemetryConstants.AppName); + Assert.Equal(30, TelemetryConstants.DefaultFlushIntervalSeconds); + Assert.Equal(500, TelemetryConstants.MaxQueueCapacity); + Assert.Equal(5, TelemetryConstants.SessionHeartbeatIntervalMinutes); + Assert.Equal(50, TelemetryConstants.MaxBreadcrumbsCount); + Assert.StartsWith("https://", TelemetryConstants.DefaultSentryDsn); + Assert.StartsWith("phc_", TelemetryConstants.DefaultPostHogApiKey); + Assert.Equal("https://us.i.posthog.com", TelemetryConstants.DefaultPostHogHost); + Assert.Equal("https://us.i.posthog.com/capture/", TelemetryConstants.DefaultPostHogCaptureEndpoint); + Assert.Equal("567732", TelemetryConstants.DefaultPostHogProjectId); + } + + /// + /// Verifies event name constants are non-empty and distinct. + /// + [Fact] + public void EventNames_AreDistinctAndNonEmpty() + { + var events = new[] + { + TelemetryConstants.Events.GameSessionStarted, + TelemetryConstants.Events.GameSessionHeartbeat, + TelemetryConstants.Events.GameSessionEnded, + TelemetryConstants.Events.ContentDownloadCompleted, + TelemetryConstants.Events.AppUpdateChecked, + TelemetryConstants.Events.AppUpdateApplied, + TelemetryConstants.Events.CasReconcileCompleted, + TelemetryConstants.Events.AppCrash, + }; + + foreach (var ev in events) + { + Assert.False(string.IsNullOrWhiteSpace(ev)); + } + + Assert.Equal(events.Length, events.Distinct().Count()); + } + + /// + /// Verifies property key constants are non-empty and distinct. + /// + [Fact] + public void PropertyKeys_AreDistinctAndNonEmpty() + { + var properties = new[] + { + TelemetryConstants.Properties.SessionId, + TelemetryConstants.Properties.GameType, + TelemetryConstants.Properties.ProfileId, + TelemetryConstants.Properties.ProfileName, + TelemetryConstants.Properties.DurationSeconds, + TelemetryConstants.Properties.ExitCode, + TelemetryConstants.Properties.Platform, + TelemetryConstants.Properties.Runner, + TelemetryConstants.Properties.Resolution, + TelemetryConstants.Properties.ManifestId, + TelemetryConstants.Properties.ContentType, + TelemetryConstants.Properties.SizeMb, + TelemetryConstants.Properties.SpeedMbps, + TelemetryConstants.Properties.SourceProvider, + TelemetryConstants.Properties.RetryCount, + TelemetryConstants.Properties.FromVersion, + TelemetryConstants.Properties.ToVersion, + TelemetryConstants.Properties.Channel, + TelemetryConstants.Properties.RestartDurationMs, + TelemetryConstants.Properties.CacheHitRate, + TelemetryConstants.Properties.FileCount, + TelemetryConstants.Properties.BytesReconciled, + TelemetryConstants.Properties.ExceptionType, + TelemetryConstants.Properties.ExceptionMessage, + TelemetryConstants.Properties.StackTrace, + TelemetryConstants.Properties.IsFatal, + TelemetryConstants.Properties.Context, + TelemetryConstants.Properties.InstallationId, + TelemetryConstants.Properties.AppVersion, + TelemetryConstants.Properties.ExecutablePath, + }; + + foreach (var prop in properties) + { + Assert.False(string.IsNullOrWhiteSpace(prop)); + } + + Assert.Equal(properties.Length, properties.Distinct().Count()); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs new file mode 100644 index 000000000..6277040ce --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using GenHub.Core.Constants; +using GenHub.Core.Utilities; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for . +/// +public class TelemetrySanitizerTests +{ + private readonly TelemetrySanitizer _sanitizer = new(); + + /// + /// Verifies that null and empty strings are handled gracefully. + /// + [Fact] + public void SanitizeString_NullOrEmpty_ReturnsEmptyString() + { + Assert.Equal(string.Empty, _sanitizer.SanitizeString(null)); + Assert.Equal(string.Empty, _sanitizer.SanitizeString(string.Empty)); + } + + /// + /// Verifies that Windows user paths are sanitized. + /// + [Fact] + public void SanitizeString_WindowsUserPath_ReplacesWithUserDirMask() + { + var input = @"C:\Users\JohnDoe\AppData\Local\GenHub\game.dat"; + var result = _sanitizer.SanitizeString(input); + + Assert.Contains(TelemetryConstants.UserDirectoryMask, result); + Assert.DoesNotContain("JohnDoe", result); + } + + /// + /// Verifies that Unix user paths are sanitized. + /// + [Fact] + public void SanitizeString_UnixUserPath_ReplacesWithUserDirMask() + { + var input = "/home/alice/games/cnc/generals.exe"; + var result = _sanitizer.SanitizeString(input); + + Assert.Contains(TelemetryConstants.UserDirectoryMask, result); + Assert.DoesNotContain("alice", result); + } + + /// + /// Verifies that Wine prefix paths are sanitized. + /// + [Fact] + public void SanitizeString_WinePrefixPath_ReplacesWithWinePrefixMask() + { + var input = "/home/gamer/.wine/drive_c/Program Files/EA Games/Command and Conquer Generals"; + var result = _sanitizer.SanitizeString(input); + + Assert.Contains(TelemetryConstants.WinePrefixMask, result); + } + + /// + /// Verifies that IPv4 and IPv6 addresses are masked. + /// + [Fact] + public void SanitizeString_IpAddresses_ReplacesWithIpMask() + { + var input = "Connection from 192.168.1.50 and 2001:0db8:85a3:0000:0000:8a2e:0370:7334 failed."; + var result = _sanitizer.SanitizeString(input); + + Assert.Contains(TelemetryConstants.IpAddressMask, result); + Assert.DoesNotContain("192.168.1.50", result); + Assert.DoesNotContain("2001:0db8:85a3:0000:0000:8a2e:0370:7334", result); + } + + /// + /// Verifies that GitHub tokens and Bearer tokens are masked. + /// + [Fact] + public void SanitizeString_Tokens_ReplacesWithTokenMask() + { + var input = "Authorization: Bearer secret_token_1234567890abcdef123456 and token ghp_123456789012345678901234567890123456"; + var result = _sanitizer.SanitizeString(input); + + Assert.Contains(TelemetryConstants.SecretTokenMask, result); + Assert.DoesNotContain("secret_token_1234567890abcdef123456", result); + Assert.DoesNotContain("ghp_123456789012345678901234567890123456", result); + } + + /// + /// Verifies that dictionary properties are recursively sanitized. + /// + [Fact] + public void SanitizeProperties_NestedDictionary_SanitizesAllValues() + { + var props = new Dictionary + { + ["path"] = @"C:\Users\SecretUser\game.exe", + ["ip"] = "10.0.0.1", + ["count"] = 42, + ["nested"] = new Dictionary + { + ["user_folder"] = "/home/secretuser/workspace", + }, + }; + + var sanitized = _sanitizer.SanitizeProperties(props); + + Assert.Equal(42, sanitized["count"]); + Assert.Contains(TelemetryConstants.UserDirectoryMask, sanitized["path"]?.ToString()); + Assert.Contains(TelemetryConstants.IpAddressMask, sanitized["ip"]?.ToString()); + + var nested = sanitized["nested"] as IReadOnlyDictionary; + Assert.NotNull(nested); + Assert.Contains(TelemetryConstants.UserDirectoryMask, nested["user_folder"]?.ToString()); + Assert.DoesNotContain("secretuser", nested["user_folder"]?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that stack trace sanitization strips sensitive directory information. + /// + [Fact] + public void SanitizeStackTrace_StripsPersonalPaths() + { + var stackTrace = @"at GenHub.Program.Main() in C:\Users\Tester\source\repos\GenHub\Program.cs:line 45"; + var result = _sanitizer.SanitizeStackTrace(stackTrace); + + Assert.Contains(TelemetryConstants.UserDirectoryMask, result); + Assert.DoesNotContain("Tester", result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs new file mode 100644 index 000000000..f937700f9 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; +using GenHub.Core.Utilities; +using GenHub.Features.Telemetry.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for . +/// +public class TelemetryServiceTests : IDisposable +{ + private readonly Mock> _mockLogger = new(); + private readonly Mock _mockUserSettingsService = new(); + private readonly TelemetrySanitizer _sanitizer = new(); + private readonly Mock _mockSink = new(); + private UserSettings _settings; + + /// + /// Initializes a new instance of the class. + /// + public TelemetryServiceTests() + { + _settings = new UserSettings + { + TelemetryPreference = TelemetryLevel.AnonymousMetrics, + AnonymousInstallationId = "test-installation-guid", + }; + + _mockUserSettingsService.Setup(s => s.Get()).Returns(() => _settings); + _mockSink.Setup(s => s.CanHandle(It.IsAny())).Returns(true); + _mockSink.Setup(s => s.EmitAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _mockSink.Setup(s => s.FlushAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + } + + /// + /// Cleans up resources. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + } + + /// + /// Verifies that TrackEvent does not emit when telemetry is Disabled. + /// + [Fact] + public async Task TrackEvent_WhenDisabled_DoesNotEmit() + { + _settings.TelemetryPreference = TelemetryLevel.Disabled; + + await using var service = new TelemetryService( + _mockLogger.Object, + _sanitizer, + _mockUserSettingsService.Object, + [_mockSink.Object]); + + service.TrackEvent(TelemetryConstants.Events.GameSessionStarted); + + // Allow background loop a moment + await Task.Delay(50); + + _mockSink.Verify(s => s.EmitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that TrackEvent emits when telemetry is AnonymousMetrics. + /// + [Fact] + public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() + { + _settings.TelemetryPreference = TelemetryLevel.AnonymousMetrics; + + await using var service = new TelemetryService( + _mockLogger.Object, + _sanitizer, + _mockUserSettingsService.Object, + [_mockSink.Object]); + + service.TrackEvent(TelemetryConstants.Events.GameSessionStarted, new Dictionary + { + [TelemetryConstants.Properties.SessionId] = "test-session", + }); + + // Allow background loop to process + await Task.Delay(100); + + _mockSink.Verify(s => s.EmitAsync( + It.Is(e => e.EventName == TelemetryConstants.Events.GameSessionStarted && e.SessionId == "test-session"), + It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Verifies that TrackException captures exception details, sanitized message, and breadcrumbs. + /// + [Fact] + public async Task TrackException_RecordsSanitizedCrashEvent() + { + _settings.TelemetryPreference = TelemetryLevel.CrashReportsOnly; + + await using var service = new TelemetryService( + _mockLogger.Object, + _sanitizer, + _mockUserSettingsService.Object, + [_mockSink.Object]); + + service.AddBreadcrumb("Clicked Launch Button", "ui"); + + try + { + throw new InvalidOperationException("Failed to launch in C:\\Users\\Secret\\game.exe"); + } + catch (Exception ex) + { + service.TrackException(ex, "GameLauncher", isFatal: true); + } + + await Task.Delay(100); + + _mockSink.Verify(s => s.EmitAsync( + It.Is(e => e.EventName == TelemetryConstants.Events.AppCrash && + e.Level == TelemetryLevel.CrashReportsOnly && + e.Properties.ContainsKey(TelemetryConstants.Properties.ExceptionType)), + It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Verifies that breadcrumbs circular buffer is capped at MaxBreadcrumbsCount. + /// + [Fact] + public async Task AddBreadcrumb_CappedAtMaxCount() + { + await using var service = new TelemetryService( + _mockLogger.Object, + _sanitizer, + _mockUserSettingsService.Object, + [_mockSink.Object]); + + for (int i = 0; i < 70; i++) + { + service.AddBreadcrumb($"Action {i}", "test"); + } + + var breadcrumbs = service.GetRecentBreadcrumbs(); + Assert.Equal(TelemetryConstants.MaxBreadcrumbsCount, breadcrumbs.Count); + Assert.Equal("Action 69", breadcrumbs[^1].Message); + } + + /// + /// Verifies that FlushAsync flushes all registered sinks. + /// + [Fact] + public async Task FlushAsync_CallsFlushOnAllSinks() + { + await using var service = new TelemetryService( + _mockLogger.Object, + _sanitizer, + _mockUserSettingsService.Object, + [_mockSink.Object]); + + var result = await service.FlushAsync(); + + Assert.True(result.Success); + _mockSink.Verify(s => s.FlushAsync(It.IsAny()), Times.Once); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/UserSettingsTelemetryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/UserSettingsTelemetryTests.cs new file mode 100644 index 000000000..251139619 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/UserSettingsTelemetryTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using Xunit; + +namespace GenHub.Tests.Core.Telemetry; + +/// +/// Unit tests for telemetry configuration in . +/// +public class UserSettingsTelemetryTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + /// + /// Verifies default values for telemetry settings. + /// + [Fact] + public void DefaultSettings_HaveAnonymousMetricsEnabled() + { + var settings = new UserSettings(); + + Assert.Equal(TelemetryLevel.AnonymousMetrics, settings.TelemetryPreference); + Assert.False(settings.EnableTelemetryPromptShown); + Assert.Null(settings.AnonymousInstallationId); + } + + /// + /// Verifies Clone method copies telemetry settings correctly. + /// + [Fact] + public void Clone_CopiesTelemetrySettings() + { + var original = new UserSettings + { + TelemetryPreference = TelemetryLevel.CrashReportsOnly, + EnableTelemetryPromptShown = true, + AnonymousInstallationId = "test-guid-123", + }; + + var clone = original.Clone(); + + Assert.Equal(TelemetryLevel.CrashReportsOnly, clone.TelemetryPreference); + Assert.True(clone.EnableTelemetryPromptShown); + Assert.Equal("test-guid-123", clone.AnonymousInstallationId); + } + + /// + /// Verifies JSON roundtrip serialization of telemetry settings. + /// + [Fact] + public void JsonSerialization_RoundtripsTelemetrySettings() + { + var original = new UserSettings + { + TelemetryPreference = TelemetryLevel.Disabled, + EnableTelemetryPromptShown = true, + AnonymousInstallationId = "inst-456", + }; + + var json = JsonSerializer.Serialize(original, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(TelemetryLevel.Disabled, deserialized.TelemetryPreference); + Assert.True(deserialized.EnableTelemetryPromptShown); + Assert.Equal("inst-456", deserialized.AnonymousInstallationId); + } +} diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index a2f92fc64..aa71edde2 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -12,6 +12,7 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Telemetry; using GenHub.Core.Models.Enums; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -27,6 +28,7 @@ public partial class App : Application private readonly IUserSettingsService _userSettingsService; private readonly IConfigurationProviderService _configurationProvider; private readonly IProfileLauncherFacade _profileLauncherFacade; + private readonly ITelemetryService? _telemetryService; /// /// Initializes a new instance of the class with the specified service provider. @@ -38,6 +40,7 @@ public App(IServiceProvider serviceProvider) _userSettingsService = _serviceProvider.GetService() ?? throw new InvalidOperationException("IUserSettingsService not registered"); _configurationProvider = _serviceProvider.GetService() ?? throw new InvalidOperationException("IConfigurationProviderService not registered"); _profileLauncherFacade = _serviceProvider.GetRequiredService(); + _telemetryService = _serviceProvider.GetService(); } /// @@ -56,6 +59,22 @@ public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + // Hook global unhandled exceptions to telemetry + AppDomain.CurrentDomain.UnhandledException += (sender, args) => + { + if (args.ExceptionObject is Exception ex) + { + _telemetryService?.TrackException(ex, "AppDomain.UnhandledException", isFatal: true); + } + }; + + TaskScheduler.UnobservedTaskException += (sender, args) => + { + _telemetryService?.TrackException(args.Exception, "TaskScheduler.UnobservedTaskException", isFatal: false); + }; + + _telemetryService?.AddBreadcrumb("Application initialized", "lifecycle"); + var mainWindow = new MainWindow { DataContext = _serviceProvider.GetService(), @@ -163,6 +182,18 @@ private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArg } finally { + if (_telemetryService != null) + { + try + { + await _telemetryService.FlushAsync(); + } + catch + { + // Suppress telemetry flush errors during application exit + } + } + if (_serviceProvider is IDisposable disposable) { disposable.Dispose(); diff --git a/GenHub/GenHub/Common/Services/DownloadService.cs b/GenHub/GenHub/Common/Services/DownloadService.cs index 9928b2305..cbef8eb3f 100644 --- a/GenHub/GenHub/Common/Services/DownloadService.cs +++ b/GenHub/GenHub/Common/Services/DownloadService.cs @@ -1,10 +1,13 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Telemetry; using GenHub.Core.Models.Common; using GenHub.Core.Models.Results; using Microsoft.Extensions.Logging; @@ -17,7 +20,8 @@ namespace GenHub.Common.Services; public class DownloadService( ILogger logger, HttpClient httpClient, - IFileHashProvider hashProvider) : IDownloadService + IFileHashProvider hashProvider, + ITelemetryService? telemetryService = null) : IDownloadService { /// public async Task DownloadFileAsync( @@ -180,6 +184,18 @@ private async Task PerformDownloadAsync( } } + var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; + var sizeMb = downloadedBytes / (1024.0 * 1024.0); + var speedMbps = elapsedSeconds > 0 ? (sizeMb * 8.0) / elapsedSeconds : 0.0; + + telemetryService?.TrackEvent(TelemetryConstants.Events.ContentDownloadCompleted, new Dictionary + { + [TelemetryConstants.Properties.SizeMb] = Math.Round(sizeMb, 2), + [TelemetryConstants.Properties.DurationSeconds] = Math.Round(elapsedSeconds, 2), + [TelemetryConstants.Properties.SpeedMbps] = Math.Round(speedMbps, 2), + [TelemetryConstants.Properties.SourceProvider] = configuration.Url.Host, + }); + return DownloadResult.CreateSuccess(configuration.DestinationPath, downloadedBytes, stopwatch.Elapsed, hashVerified); } } diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 5f1d9f05d..c41dd68dc 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -15,6 +15,7 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GitHub; +using GenHub.Core.Interfaces.Telemetry; using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Enums; using GenHub.Features.AppUpdate.Interfaces; @@ -41,6 +42,7 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable private readonly IGitHubTokenStorage? _gitHubTokenStorage; private readonly IUserSettingsService? _userSettingsService; private readonly IFileDownloader _fileDownloader; + private readonly ITelemetryService? _telemetryService; private readonly UpdateManager? _updateManager; private readonly GithubSource _githubSource; @@ -110,18 +112,21 @@ public string? SubscribedBranch /// The GitHub token storage (optional). /// The user settings service (optional). /// The high-performance file downloader (optional). + /// The telemetry service (optional). public VelopackUpdateManager( ILogger logger, IHttpClientFactory httpClientFactory, IGitHubTokenStorage? gitHubTokenStorage = null, IUserSettingsService? userSettingsService = null, - IFileDownloader? fileDownloader = null) + IFileDownloader? fileDownloader = null, + ITelemetryService? telemetryService = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _gitHubTokenStorage = gitHubTokenStorage; _userSettingsService = userSettingsService; _fileDownloader = fileDownloader ?? new FastHttpClientFileDownloader(); + _telemetryService = telemetryService; // Always initialize GithubSource for update checking with high-performance downloader _githubSource = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true, _fileDownloader); @@ -166,6 +171,13 @@ public void Dispose() _logger.LogInformation("Starting GitHub update check for repository: {Url}", AppConstants.GitHubRepositoryUrl); + _telemetryService?.TrackEvent(TelemetryConstants.Events.AppUpdateChecked, new Dictionary + { + [TelemetryConstants.Properties.FromVersion] = AppConstants.AppVersion, + [TelemetryConstants.Properties.Channel] = _subscribedPrNumber.HasValue ? $"PR-{_subscribedPrNumber}" : _subscribedBranch ?? "Release", + [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, + }); + try { var uri = new Uri(AppConstants.GitHubRepositoryUrl); @@ -329,6 +341,14 @@ public void ApplyUpdatesAndRestart(UpdateInfo updateInfo) _logger.LogInformation("Update package: {Package}", updateInfo.TargetFullRelease.FileName); _logger.LogInformation("Current app will exit and restart with new version"); + _telemetryService?.TrackEvent(TelemetryConstants.Events.AppUpdateApplied, new Dictionary + { + [TelemetryConstants.Properties.FromVersion] = AppConstants.AppVersion, + [TelemetryConstants.Properties.ToVersion] = updateInfo.TargetFullRelease.Version.ToString(), + [TelemetryConstants.Properties.Channel] = _subscribedPrNumber.HasValue ? $"PR-{_subscribedPrNumber}" : _subscribedBranch ?? "Release", + [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, + }); + _updateManager.ApplyUpdatesAndRestart(updateInfo.TargetFullRelease); // If we reach here, restart might have failed diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 933b635d9..2cb0d9f96 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -5,12 +5,14 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Telemetry; using GenHub.Core.Models.Events; using GenHub.Core.Models.Launching; using GenHub.Core.Models.Results; @@ -22,10 +24,12 @@ namespace GenHub.Features.GameProfiles.Infrastructure; /// Manages game processes and their lifecycle. /// public class GameProcessManager( - ILogger logger) : IGameProcessManager, IDisposable + ILogger logger, + ITelemetryService? telemetryService = null) : IGameProcessManager, IDisposable { private const int CleanupIntervalMs = ProcessConstants.ProcessCleanupIntervalMs; private readonly ConcurrentDictionary _managedProcesses = new(); + private readonly ConcurrentDictionary _sessionMetadata = new(); private readonly SemaphoreSlim _terminationSemaphore = new(1, 1); /// @@ -37,6 +41,15 @@ public class GameProcessManager( TimeSpan.FromMilliseconds(CleanupIntervalMs), TimeSpan.FromMilliseconds(CleanupIntervalMs)); + /// + /// Periodic timer to send anonymous heartbeats for active game sessions. + /// + private readonly Timer _heartbeatTimer = new( + state => ((GameProcessManager?)state)?.EmitHeartbeats(), + null, + TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes), + TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes)); + private bool _disposed; /// @@ -107,6 +120,7 @@ public async Task> StartProcessAsync(GameLaunch } _managedProcesses[process.Id] = process; + RegisterSessionAndEmitStarted(process, configuration.ExecutablePath); if (configuration.WaitForExit) { @@ -361,6 +375,7 @@ public void TrackProcess(Process process) logger.LogInformation("[Process] Registering existing process for tracking: {ProcessId} ({ProcessName})", process.Id, process.ProcessName); _managedProcesses[process.Id] = process; + RegisterSessionAndEmitStarted(process, process.ProcessName); try { @@ -477,8 +492,9 @@ public void Dispose() logger.LogDebug("Disposing GameProcessManager with {Count} managed processes", _managedProcesses.Count); - // Dispose cleanup timer first + // Dispose timers first _cleanupTimer?.Dispose(); + _heartbeatTimer?.Dispose(); // Clean up all managed processes foreach (var kvp in _managedProcesses) @@ -896,6 +912,19 @@ private void OnProcessExited(object? sender, EventArgs e) // Remove from managed processes _managedProcesses.TryRemove(processId, out _); + if (_sessionMetadata.TryRemove(processId, out var sessionMeta) && telemetryService != null) + { + var duration = (DateTime.UtcNow - sessionMeta.StartTime).TotalSeconds; + telemetryService.TrackEvent(TelemetryConstants.Events.GameSessionEnded, new Dictionary + { + [TelemetryConstants.Properties.SessionId] = sessionMeta.SessionId, + [TelemetryConstants.Properties.DurationSeconds] = duration, + [TelemetryConstants.Properties.ExitCode] = exitCode, + [TelemetryConstants.Properties.ExecutablePath] = sessionMeta.ExecName, + [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(), + }); + } + // Raise the event var args = new GameProcessExitedEventArgs { @@ -973,6 +1002,7 @@ private async Task> AdoptExpectedChildProcessAs if (child != null) { _managedProcesses[child.Id] = child; + RegisterSessionAndEmitStarted(child, expectedName); try { @@ -1350,13 +1380,69 @@ private string AppendLauncherErrors(string message, Process launcher, BoundedErr return string.IsNullOrWhiteSpace(detail) ? message : $"{message} {detail}"; } + private static string DetectRunnerEnvironment() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Native"; + } + + if (Environment.GetEnvironmentVariable("PROTON_VERSION") is { Length: > 0 } proton) + { + return $"Proton-{proton}"; + } + + if (Environment.GetEnvironmentVariable("WINEPREFIX") is { Length: > 0 }) + { + return "Wine"; + } + + return RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "Linux-Runner" : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macOS-Runner" : "Native"; + } + + private void RegisterSessionAndEmitStarted(Process process, string executableName) + { + var sessionId = Guid.NewGuid().ToString("N")[..8]; + var execName = Path.GetFileName(executableName); + _sessionMetadata[process.Id] = (sessionId, DateTime.UtcNow, execName); + + if (telemetryService != null) + { + telemetryService.TrackEvent(TelemetryConstants.Events.GameSessionStarted, new Dictionary + { + [TelemetryConstants.Properties.SessionId] = sessionId, + [TelemetryConstants.Properties.ExecutablePath] = execName, + [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, + [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(), + }); + } + } + + private void EmitHeartbeats() + { + if (_disposed || telemetryService == null || _sessionMetadata.IsEmpty) + { + return; + } + + foreach (var (_, (sessionId, startTime, execName)) in _sessionMetadata) + { + telemetryService.TrackEvent(TelemetryConstants.Events.GameSessionHeartbeat, new Dictionary + { + [TelemetryConstants.Properties.SessionId] = sessionId, + [TelemetryConstants.Properties.DurationSeconds] = (DateTime.UtcNow - startTime).TotalSeconds, + [TelemetryConstants.Properties.ExecutablePath] = execName, + [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(), + }); + } + } + /// - /// Waits for the asynchronous stderr handlers to finish before the capture is read. + /// Waits for asynchronous stderr reads to finish draining so the buffer holds the complete output. /// /// - /// without a timeout additionally waits for - /// redirected-output handlers to complete; the timed overloads do not, so reading the - /// buffer straight after the process exits can miss the final lines. Only stderr is + /// Standard output is left inherited by the process rather than being /// redirected, so there is no stdout stream to drain. /// /// The exited process. diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index 9f97f5367..167ba80f6 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -48,6 +48,11 @@ public partial class SettingsViewModel : ObservableObject, IDisposable /// public static IEnumerable AvailableWorkspaceStrategies => Enum.GetValues(); + /// + /// Gets the available telemetry consent levels for selection in the UI. + /// + public static IEnumerable AvailableTelemetryLevels => Enum.GetValues(); + /// /// Gets the current application version for display. /// @@ -141,6 +146,9 @@ public partial class SettingsViewModel : ObservableObject, IDisposable [ObservableProperty] private bool _enableDetailedLogging = false; + [ObservableProperty] + private TelemetryLevel _telemetryPreference = TelemetryLevel.AnonymousMetrics; + [ObservableProperty] private WorkspaceStrategy _defaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; @@ -494,6 +502,7 @@ private void LoadSettings() PeriodicUpdateCheckIntervalMinutes = settings.PeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = settings.AllowBackgroundDownloads; EnableDetailedLogging = settings.EnableDetailedLogging; + TelemetryPreference = settings.TelemetryPreference; DefaultWorkspaceStrategy = settings.DefaultWorkspaceStrategy; DownloadBufferSizeKB = settings.DownloadBufferSize / (double)ConversionConstants.BytesPerKilobyte; // Convert bytes to KB DownloadTimeoutSeconds = settings.DownloadTimeoutSeconds; @@ -550,6 +559,7 @@ private async Task SaveSettings() settings.PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes; settings.AllowBackgroundDownloads = AllowBackgroundDownloads; settings.EnableDetailedLogging = EnableDetailedLogging; + settings.TelemetryPreference = TelemetryPreference; settings.DefaultWorkspaceStrategy = DefaultWorkspaceStrategy; settings.SubscribedBranch = string.IsNullOrWhiteSpace(SubscribedBranchInput) ? null : SubscribedBranchInput; @@ -622,6 +632,7 @@ private async Task ResetToDefaults() PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = true; EnableDetailedLogging = false; + TelemetryPreference = TelemetryLevel.AnonymousMetrics; DefaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; DownloadBufferSizeKB = DownloadDefaults.BufferSizeKB; // 80KB default DownloadTimeoutSeconds = DownloadDefaults.TimeoutSeconds; diff --git a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs new file mode 100644 index 000000000..89e7a1bf9 --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Telemetry.Services; + +/// +/// Core telemetry service that manages the bounded event queue, client-side scrubbing, breadcrumbs, and sink dispatching. +/// +public sealed class TelemetryService : ITelemetryService, IAsyncDisposable, IDisposable +{ + private readonly ILogger _logger; + private readonly ITelemetrySanitizer _sanitizer; + private readonly IUserSettingsService _userSettingsService; + private readonly IReadOnlyList _sinks; + + private readonly Channel _channel; + private readonly ConcurrentQueue _breadcrumbs = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _processingTask; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The telemetry data sanitizer. + /// The user settings service. + /// The registered telemetry destination sinks. + public TelemetryService( + ILogger logger, + ITelemetrySanitizer sanitizer, + IUserSettingsService userSettingsService, + IEnumerable sinks) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer)); + _userSettingsService = userSettingsService ?? throw new ArgumentNullException(nameof(userSettingsService)); + _sinks = (sinks ?? []).ToList(); + + _channel = Channel.CreateBounded(new BoundedChannelOptions(TelemetryConstants.MaxQueueCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + _processingTask = Task.Run(() => ProcessChannelAsync(_cts.Token)); + } + + /// + public TelemetryLevel CurrentLevel + { + get + { + try + { + return _userSettingsService.Get().TelemetryPreference; + } + catch + { + return TelemetryLevel.Disabled; + } + } + } + + /// + public bool IsEnabled(TelemetryLevel level) + { + var current = CurrentLevel; + if (current == TelemetryLevel.Disabled) + { + return false; + } + + return (int)current >= (int)level; + } + + /// + public void TrackEvent( + string eventName, + IReadOnlyDictionary? properties = null, + TelemetryLevel level = TelemetryLevel.AnonymousMetrics) + { + if (string.IsNullOrWhiteSpace(eventName) || !IsEnabled(level) || _disposed) + { + return; + } + + try + { + var installationId = GetOrCreateInstallationId(); + var sanitizedProperties = _sanitizer.SanitizeProperties(properties); + + string? sessionId = null; + if (properties != null && properties.TryGetValue(TelemetryConstants.Properties.SessionId, out var rawSessionId) && rawSessionId != null) + { + sessionId = rawSessionId.ToString(); + } + + var telemetryEvent = new TelemetryEvent + { + EventName = _sanitizer.SanitizeString(eventName), + Timestamp = DateTimeOffset.UtcNow, + Level = level, + InstallationId = installationId, + SessionId = sessionId, + AppVersion = AppConstants.AppVersion, + Platform = RuntimeInformation.OSDescription, + Properties = sanitizedProperties, + }; + + _channel.Writer.TryWrite(telemetryEvent); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to track telemetry event {EventName}", eventName); + } + } + + /// + public void TrackException( + Exception exception, + string? context = null, + IReadOnlyDictionary? properties = null, + bool isFatal = false) + { + ArgumentNullException.ThrowIfNull(exception); + + if (!IsEnabled(TelemetryLevel.CrashReportsOnly) || _disposed) + { + return; + } + + try + { + var installationId = GetOrCreateInstallationId(); + var sanitizedMessage = _sanitizer.SanitizeString(exception.Message); + var sanitizedStackTrace = _sanitizer.SanitizeStackTrace(exception.StackTrace); + var breadcrumbs = GetRecentBreadcrumbs(); + + var combinedProperties = new Dictionary(properties ?? new Dictionary()) + { + [TelemetryConstants.Properties.ExceptionType] = exception.GetType().FullName ?? exception.GetType().Name, + [TelemetryConstants.Properties.ExceptionMessage] = sanitizedMessage, + [TelemetryConstants.Properties.StackTrace] = sanitizedStackTrace, + [TelemetryConstants.Properties.IsFatal] = isFatal, + [TelemetryConstants.Properties.Context] = context ?? "Application", + ["breadcrumbs"] = breadcrumbs.Select(b => new + { + b.Message, + b.Category, + Timestamp = b.Timestamp.ToString("o"), + b.Data, + }).ToList(), + }; + + var sanitizedProperties = _sanitizer.SanitizeProperties(combinedProperties); + + var telemetryEvent = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Timestamp = DateTimeOffset.UtcNow, + Level = TelemetryLevel.CrashReportsOnly, + InstallationId = installationId, + AppVersion = AppConstants.AppVersion, + Platform = RuntimeInformation.OSDescription, + Properties = sanitizedProperties, + }; + + _channel.Writer.TryWrite(telemetryEvent); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to track exception"); + } + } + + /// + public void AddBreadcrumb(string message, string? category = null, IReadOnlyDictionary? data = null) + { + if (string.IsNullOrWhiteSpace(message) || _disposed) + { + return; + } + + try + { + var breadcrumb = new Breadcrumb + { + Message = _sanitizer.SanitizeString(message), + Category = category ?? "general", + Timestamp = DateTimeOffset.UtcNow, + Data = data != null ? _sanitizer.SanitizeProperties(data) : null, + }; + + _breadcrumbs.Enqueue(breadcrumb); + + while (_breadcrumbs.Count > TelemetryConstants.MaxBreadcrumbsCount) + { + _breadcrumbs.TryDequeue(out _); + } + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to add breadcrumb"); + } + } + + /// + public IReadOnlyList GetRecentBreadcrumbs() + { + return [.. _breadcrumbs]; + } + + /// + public async Task> FlushAsync(CancellationToken cancellationToken = default) + { + try + { + var tasks = _sinks.Select(sink => sink.FlushAsync(cancellationToken)); + await Task.WhenAll(tasks); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Error while flushing telemetry sinks"); + return OperationResult.CreateFailure(ex.Message); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _channel.Writer.TryComplete(); + _cts.Cancel(); + + try + { + _processingTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // Suppress background task cancellation exceptions on shutdown + } + + _cts.Dispose(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _channel.Writer.TryComplete(); + _cts.Cancel(); + + try + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _processingTask.WaitAsync(timeoutCts.Token); + await FlushAsync(timeoutCts.Token); + } + catch + { + // Suppress background task cancellation exceptions on shutdown + } + + _cts.Dispose(); + } + + private string GetOrCreateInstallationId() + { + try + { + var settings = _userSettingsService.Get(); + if (!string.IsNullOrWhiteSpace(settings.AnonymousInstallationId)) + { + return settings.AnonymousInstallationId; + } + + var newId = Guid.NewGuid().ToString("N"); + _userSettingsService.Update(s => s.AnonymousInstallationId = newId); + return newId; + } + catch + { + return Guid.Empty.ToString("N"); + } + } + + private async Task ProcessChannelAsync(CancellationToken cancellationToken) + { + try + { + while (await _channel.Reader.WaitToReadAsync(cancellationToken)) + { + while (_channel.Reader.TryRead(out var telemetryEvent)) + { + if (telemetryEvent == null) + { + continue; + } + + foreach (var sink in _sinks) + { + if (!sink.CanHandle(telemetryEvent)) + { + continue; + } + + try + { + await sink.EmitAsync(telemetryEvent, cancellationToken); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Telemetry sink {SinkName} failed emitting event", sink.Name); + } + } + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Unexpected error in telemetry event processing channel"); + } + } +} diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs new file mode 100644 index 000000000..6f01b563a --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Telemetry.Sinks; + +/// +/// Telemetry sink for delivering anonymous usage, game session, and update metrics to an analytics endpoint such as PostHog. +/// +public sealed class AnalyticsTelemetrySink( + ILogger logger, + HttpClient? httpClient = null) : ITelemetrySink +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ConcurrentQueue _buffer = new(); + private string? _endpointUrl; + private string? _apiKey; + + /// + public string Name => "Analytics"; + + /// + /// Gets or sets the remote HTTP endpoint URL for analytics ingestion (e.g. PostHog capture endpoint). + /// When null or empty, defaults to the configured default PostHog capture URL or buffers locally. + /// + public string? EndpointUrl + { + get => _endpointUrl ?? Environment.GetEnvironmentVariable("POSTHOG_CAPTURE_URL") ?? (Environment.GetEnvironmentVariable("POSTHOG_HOST") != null ? $"{Environment.GetEnvironmentVariable("POSTHOG_HOST")?.TrimEnd('/')}/capture/" : TelemetryConstants.DefaultPostHogCaptureEndpoint); + set => _endpointUrl = value; + } + + /// + /// Gets or sets the analytics project API token / key (e.g. PostHog project token). + /// + public string? ApiKey + { + get => _apiKey ?? Environment.GetEnvironmentVariable("POSTHOG_API_KEY") ?? Environment.GetEnvironmentVariable("GENHUB_POSTHOG_API_KEY") ?? TelemetryConstants.DefaultPostHogApiKey; + set => _apiKey = value; + } + + /// + public bool CanHandle(TelemetryEvent telemetryEvent) + { + ArgumentNullException.ThrowIfNull(telemetryEvent); + return telemetryEvent.Level == TelemetryLevel.AnonymousMetrics; + } + + /// + public async Task> EmitAsync(TelemetryEvent telemetryEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(telemetryEvent); + + if (!CanHandle(telemetryEvent)) + { + return OperationResult.CreateSuccess(false); + } + + var endpoint = EndpointUrl; + var apiKey = ApiKey; + + if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey) || httpClient == null) + { + // Offline / unconfigured remote endpoint mode: buffer in memory + _buffer.Enqueue(telemetryEvent); + while (_buffer.Count > 100) + { + _buffer.TryDequeue(out _); + } + + return OperationResult.CreateSuccess(true); + } + + try + { + var postHogProperties = new Dictionary(telemetryEvent.Properties ?? new Dictionary()) + { + ["$lib"] = TelemetryConstants.AppName, + ["$app_version"] = telemetryEvent.AppVersion, + ["$os"] = telemetryEvent.Platform, + }; + + if (!string.IsNullOrEmpty(telemetryEvent.SessionId)) + { + postHogProperties["$session_id"] = telemetryEvent.SessionId; + } + + var payload = new Dictionary + { + ["api_key"] = apiKey, + ["event"] = telemetryEvent.EventName, + ["distinct_id"] = telemetryEvent.InstallationId, + ["properties"] = postHogProperties, + ["timestamp"] = telemetryEvent.Timestamp.ToString("o"), + }; + + var json = JsonSerializer.Serialize(payload, JsonOptions); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await httpClient.PostAsync(endpoint, content, cancellationToken); + if (response.IsSuccessStatusCode) + { + return OperationResult.CreateSuccess(true); + } + + logger.LogDebug("[Analytics] Endpoint returned status code {StatusCode}", response.StatusCode); + _buffer.Enqueue(telemetryEvent); + return OperationResult.CreateFailure($"Remote endpoint returned {response.StatusCode}"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, "[Analytics] Failed to send telemetry event to endpoint"); + _buffer.Enqueue(telemetryEvent); + return OperationResult.CreateFailure(ex.Message); + } + } + + /// + public async Task> FlushAsync(CancellationToken cancellationToken = default) + { + if (httpClient == null || _buffer.IsEmpty) + { + return OperationResult.CreateSuccess(true); + } + + var failed = false; + var count = _buffer.Count; + for (var i = 0; i < count && _buffer.TryDequeue(out var ev); i++) + { + var res = await EmitAsync(ev, cancellationToken); + if (!res.Success) + { + failed = true; + } + } + + return failed + ? OperationResult.CreateFailure("Failed to flush some buffered events") + : OperationResult.CreateSuccess(true); + } +} + diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/LoggingTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/LoggingTelemetrySink.cs new file mode 100644 index 000000000..fb2a4da43 --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Sinks/LoggingTelemetrySink.cs @@ -0,0 +1,69 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Telemetry.Sinks; + +/// +/// Telemetry sink that outputs structured events to the application log stream. +/// +public sealed class LoggingTelemetrySink(ILogger logger) : ITelemetrySink +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + }; + + /// + public string Name => "Logging"; + + /// + public bool CanHandle(TelemetryEvent telemetryEvent) => true; + + /// + public Task> EmitAsync(TelemetryEvent telemetryEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(telemetryEvent); + + try + { + if (telemetryEvent.EventName == TelemetryConstants.Events.AppCrash) + { + logger.LogWarning( + "[Telemetry:Crash] Event={EventName}, Platform={Platform}, AppVersion={AppVersion}, Properties={Properties}", + telemetryEvent.EventName, + telemetryEvent.Platform, + telemetryEvent.AppVersion, + JsonSerializer.Serialize(telemetryEvent.Properties, JsonOptions)); + } + else + { + logger.LogDebug( + "[Telemetry:Event] Event={EventName}, Session={SessionId}, Level={Level}, Properties={Properties}", + telemetryEvent.EventName, + telemetryEvent.SessionId, + telemetryEvent.Level, + JsonSerializer.Serialize(telemetryEvent.Properties, JsonOptions)); + } + + return Task.FromResult(OperationResult.CreateSuccess(true)); + } + catch (Exception ex) + { + logger.LogTrace(ex, "Failed to write telemetry event to logger"); + return Task.FromResult(OperationResult.CreateFailure(ex.Message)); + } + } + + /// + public Task> FlushAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(OperationResult.CreateSuccess(true)); + } +} diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/NullTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/NullTelemetrySink.cs new file mode 100644 index 000000000..e762a5999 --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Sinks/NullTelemetrySink.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; + +namespace GenHub.Features.Telemetry.Sinks; + +/// +/// A no-op telemetry sink for testing or when telemetry sinks are unconfigured. +/// +public sealed class NullTelemetrySink : ITelemetrySink +{ + /// + public string Name => "Null"; + + /// + public bool CanHandle(TelemetryEvent telemetryEvent) => false; + + /// + public Task> EmitAsync(TelemetryEvent telemetryEvent, CancellationToken cancellationToken = default) + { + return Task.FromResult(OperationResult.CreateSuccess(true)); + } + + /// + public Task> FlushAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(OperationResult.CreateSuccess(true)); + } +} diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs new file mode 100644 index 000000000..006754064 --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Telemetry; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Telemetry.Sinks; + +/// +/// Telemetry sink for delivering unhandled exceptions and crash forensics to Sentry or crash endpoints. +/// +public sealed class SentryTelemetrySink( + ILogger logger, + HttpClient? httpClient = null) : ITelemetrySink +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ConcurrentQueue _crashBuffer = new(); + private string? _dsnEndpoint; + + /// + public string Name => "Sentry"; + + /// + /// Gets or sets the Sentry DSN or HTTP crash reporting endpoint. + /// When null or empty, defaults to the configured default Sentry DSN or buffers locally. + /// + public string? DsnEndpoint + { + get => _dsnEndpoint ?? Environment.GetEnvironmentVariable("SENTRY_DSN") ?? Environment.GetEnvironmentVariable("GENHUB_SENTRY_DSN") ?? TelemetryConstants.DefaultSentryDsn; + set => _dsnEndpoint = value; + } + + /// + public bool CanHandle(TelemetryEvent telemetryEvent) + { + ArgumentNullException.ThrowIfNull(telemetryEvent); + return telemetryEvent.EventName == TelemetryConstants.Events.AppCrash || + telemetryEvent.Level == TelemetryLevel.CrashReportsOnly; + } + + /// + public async Task> EmitAsync(TelemetryEvent telemetryEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(telemetryEvent); + + if (!CanHandle(telemetryEvent)) + { + return OperationResult.CreateSuccess(false); + } + + var dsn = DsnEndpoint; + if (string.IsNullOrWhiteSpace(dsn) || httpClient == null) + { + // Buffer locally if unconfigured + _crashBuffer.Enqueue(telemetryEvent); + while (_crashBuffer.Count > 50) + { + _crashBuffer.TryDequeue(out _); + } + + return OperationResult.CreateSuccess(true); + } + + try + { + var (storeUrl, publicKey) = ParseDsn(dsn); + var extra = new Dictionary(telemetryEvent.Properties ?? new Dictionary()); + + string? exceptionType = null; + string? exceptionMessage = null; + string? stackTrace = null; + var isFatal = false; + + if (extra.Remove(TelemetryConstants.Properties.ExceptionType, out var exTypeObj) && exTypeObj != null) + { + exceptionType = exTypeObj.ToString(); + } + + if (extra.Remove(TelemetryConstants.Properties.ExceptionMessage, out var exMsgObj) && exMsgObj != null) + { + exceptionMessage = exMsgObj.ToString(); + } + + if (extra.Remove(TelemetryConstants.Properties.StackTrace, out var stackObj) && stackObj != null) + { + stackTrace = stackObj.ToString(); + } + + if (extra.Remove(TelemetryConstants.Properties.IsFatal, out var fatalObj) && fatalObj is bool b) + { + isFatal = b; + } + + var payload = new Dictionary + { + ["event_id"] = Guid.NewGuid().ToString("N"), + ["timestamp"] = telemetryEvent.Timestamp.ToString("o"), + ["platform"] = "csharp", + ["level"] = isFatal ? "fatal" : "error", + ["logger"] = TelemetryConstants.AppName, + ["release"] = telemetryEvent.AppVersion, + ["environment"] = AppConstants.BuildChannel, + ["tags"] = new Dictionary + { + ["os"] = telemetryEvent.Platform, + ["arch"] = RuntimeInformation.ProcessArchitecture.ToString(), + }, + ["user"] = new Dictionary + { + ["id"] = telemetryEvent.InstallationId, + }, + ["extra"] = extra, + }; + + if (!string.IsNullOrEmpty(exceptionMessage) || !string.IsNullOrEmpty(exceptionType)) + { + payload["message"] = new Dictionary + { + ["formatted"] = exceptionMessage ?? exceptionType ?? "Application Crash", + }; + + payload["exception"] = new Dictionary + { + ["values"] = new[] + { + new Dictionary + { + ["type"] = exceptionType ?? "Exception", + ["value"] = exceptionMessage ?? string.Empty, + ["stacktrace"] = !string.IsNullOrEmpty(stackTrace) + ? new Dictionary { ["raw"] = stackTrace } + : null, + }, + }, + }; + } + + var json = JsonSerializer.Serialize(payload, JsonOptions); + using var request = new HttpRequestMessage(HttpMethod.Post, storeUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + if (!string.IsNullOrEmpty(publicKey)) + { + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var authHeader = $"Sentry sentry_version=7, sentry_client={TelemetryConstants.AppName}/{telemetryEvent.AppVersion}, sentry_key={publicKey}, sentry_timestamp={unixTimestamp}"; + request.Headers.TryAddWithoutValidation("X-Sentry-Auth", authHeader); + } + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + return OperationResult.CreateSuccess(true); + } + + logger.LogDebug("[Sentry] Crash endpoint returned status code {StatusCode}", response.StatusCode); + _crashBuffer.Enqueue(telemetryEvent); + return OperationResult.CreateFailure($"Crash endpoint returned {response.StatusCode}"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, "[Sentry] Failed to send crash report to Sentry endpoint"); + _crashBuffer.Enqueue(telemetryEvent); + return OperationResult.CreateFailure(ex.Message); + } + } + + /// + public async Task> FlushAsync(CancellationToken cancellationToken = default) + { + if (httpClient == null || _crashBuffer.IsEmpty) + { + return OperationResult.CreateSuccess(true); + } + + var failed = false; + var count = _crashBuffer.Count; + for (var i = 0; i < count && _crashBuffer.TryDequeue(out var ev); i++) + { + var res = await EmitAsync(ev, cancellationToken); + if (!res.Success) + { + failed = true; + } + } + + return failed + ? OperationResult.CreateFailure("Failed to flush some buffered crash events") + : OperationResult.CreateSuccess(true); + } + + private static (string StoreUrl, string? PublicKey) ParseDsn(string dsn) + { + if (string.IsNullOrWhiteSpace(dsn)) + { + return (string.Empty, null); + } + + if (!Uri.TryCreate(dsn, UriKind.Absolute, out var uri)) + { + return (dsn, null); + } + + if (string.IsNullOrEmpty(uri.UserInfo)) + { + return (dsn, null); + } + + var publicKey = uri.UserInfo; + var projectId = uri.AbsolutePath.Trim('/'); + var storeUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? string.Empty : $":{uri.Port}")}/api/{projectId}/store/"; + + return (storeUrl, publicKey); + } +} + diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs index 08f4e8cd6..2ae26d1cc 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs @@ -25,6 +25,7 @@ public static IServiceCollection ConfigureApplicationServices( // Register core services in dependency order services.AddLoggingModule(); + services.AddTelemetryServices(); services.AddValidationServices(); services.AddGameDetectionService(); services.AddGameInstallation(); diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs new file mode 100644 index 000000000..f35c2bdd1 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs @@ -0,0 +1,53 @@ +using System.Net.Http; +using GenHub.Core.Interfaces.Telemetry; +using GenHub.Core.Utilities; +using GenHub.Features.Telemetry.Services; +using GenHub.Features.Telemetry.Sinks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GenHub.Infrastructure.DependencyInjection; + +/// +/// Provides extension methods for registering telemetry services, sanitizers, and sinks. +/// +public static class TelemetryModule +{ + /// + /// Registers telemetry services and sinks in the dependency injection container. + /// + /// The service collection. + /// The updated service collection. + public static IServiceCollection AddTelemetryServices(this IServiceCollection services) + { + services.AddHttpClient(); + services.AddSingleton(); + + // Register default pluggable sinks + services.AddSingleton(); + + services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + var factory = sp.GetService(); + var client = factory != null ? factory.CreateClient("TelemetryAnalytics") : sp.GetService(); + return new AnalyticsTelemetrySink(logger, client); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + var factory = sp.GetService(); + var client = factory != null ? factory.CreateClient("TelemetrySentry") : sp.GetService(); + return new SentryTelemetrySink(logger, client); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Register core TelemetryService + services.AddSingleton(); + + return services; + } +} + diff --git a/docs/dev/constants.md b/docs/dev/constants.md index 052e8dcf4..0c3b33a86 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -1578,14 +1578,38 @@ Constants specifically for the Map Manager feature. --- -## UserDataConstants Class - -Constants for tracked user data installations — content GenHub deploys into the user's game data -folder under `Documents`. - -| Constant | Value | Description | -| -------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------- | -| `UserModifiedSuffix` | `".user-modified"` | Suffix appended to a deployed file that no longer matches its recorded hash when it is moved aside so the pristine backup can be restored over it | +## TelemetryConstants Class + +Constants for telemetry event names, properties, data scrubbing masks, and queue buffering limits. + +| Constant | Value/Type | Description | +| ------------------------------------ | ---------- | ------------------------------------------------------------- | +| `AppName` | `"GenHub"` | Application identifier for telemetry | +| `DefaultFlushIntervalSeconds` | `30` | Default flush interval in seconds for background batching | +| `MaxQueueCapacity` | `500` | Maximum capacity of the bounded event channel queue | +| `SessionHeartbeatIntervalMinutes` | `5` | Heartbeat interval in minutes for active game sessions | +| `MaxBreadcrumbsCount` | `50` | Maximum number of breadcrumbs in circular buffer for crashes | +| `UserDirectoryMask` | `""` | Replacement mask for user directories | +| `WorkspaceDirectoryMask` | `""` | Replacement mask for workspace paths | +| `WinePrefixMask` | `""` | Replacement mask for Wine prefixes | +| `IpAddressMask` | `""` | Replacement mask for IP addresses | +| `SecretTokenMask` | `""` | Replacement mask for sensitive tokens and keys | +| `DefaultSentryDsn` | `"https://06a9...ingest.de.sentry.io/4511943606927440"` | Default Sentry DSN endpoint for crash reporting | +| `DefaultPostHogApiKey` | `"phc_yJwFR...K98g"` | Default PostHog project API key for anonymous analytics | +| `DefaultPostHogHost` | `"https://us.i.posthog.com"` | Default PostHog host URL | +| `DefaultPostHogCaptureEndpoint` | `"https://us.i.posthog.com/capture/"` | Default PostHog event capture endpoint | +| `DefaultPostHogProjectId` | `"567732"` | Default PostHog project identifier | + +### Telemetry Events (`TelemetryConstants.Events`) + +- `GameSessionStarted`: `"game_session_started"` - Emitted when a game process starts. +- `GameSessionHeartbeat`: `"game_session_heartbeat"` - Emitted periodically while a game process is running. +- `GameSessionEnded`: `"game_session_ended"` - Emitted when a game process exits. +- `ContentDownloadCompleted`: `"content_download_completed"` - Emitted when a content or mod download completes. +- `AppUpdateChecked`: `"app_update_checked"` - Emitted when an application update check finishes. +- `AppUpdateApplied`: `"app_update_applied"` - Emitted when an application update is applied. +- `CasReconcileCompleted`: `"cas_reconcile_completed"` - Emitted when CAS workspace reconciliation completes. +- `AppCrash`: `"app_unhandled_crash"` - Emitted when an unhandled application exception or crash occurs. --- From e5339ab7436d4dcf12721bfd4413520f023eeab1 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 14:52:10 +0000 Subject: [PATCH 02/12] fix(review): address build errors and static analysis findings - Fix variable naming conflict (CS0136) in DownloadService - Extract BuildSentryPayload helper and resolve nullability warnings in SentryTelemetrySink - Reorder static methods before instance methods in GameProcessManager (SA1204) and simplify runner detection - Simplify lambda expressions and null checks in App.axaml and TelemetryService - Fix trailing blank line warnings (SA1518) across telemetry sinks and module --- .../Telemetry/TelemetryServiceTests.cs | 12 +- GenHub/GenHub/App.axaml.cs | 2 - .../GenHub/Common/Services/DownloadService.cs | 6 +- .../Infrastructure/GameProcessManager.cs | 51 +++--- .../Telemetry/Services/TelemetryService.cs | 2 +- .../Telemetry/Sinks/AnalyticsTelemetrySink.cs | 1 - .../Telemetry/Sinks/SentryTelemetrySink.cs | 148 +++++++++--------- .../DependencyInjection/TelemetryModule.cs | 1 - 8 files changed, 116 insertions(+), 107 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs index f937700f9..050ebcbaa 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs @@ -26,19 +26,17 @@ public class TelemetryServiceTests : IDisposable private readonly Mock _mockUserSettingsService = new(); private readonly TelemetrySanitizer _sanitizer = new(); private readonly Mock _mockSink = new(); - private UserSettings _settings; + private readonly UserSettings _settings = new() + { + TelemetryPreference = TelemetryLevel.AnonymousMetrics, + AnonymousInstallationId = "test-installation-guid", + }; /// /// Initializes a new instance of the class. /// public TelemetryServiceTests() { - _settings = new UserSettings - { - TelemetryPreference = TelemetryLevel.AnonymousMetrics, - AnonymousInstallationId = "test-installation-guid", - }; - _mockUserSettingsService.Setup(s => s.Get()).Returns(() => _settings); _mockSink.Setup(s => s.CanHandle(It.IsAny())).Returns(true); _mockSink.Setup(s => s.EmitAsync(It.IsAny(), It.IsAny())) diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index aa71edde2..17d32f91b 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -69,9 +69,7 @@ public override void OnFrameworkInitializationCompleted() }; TaskScheduler.UnobservedTaskException += (sender, args) => - { _telemetryService?.TrackException(args.Exception, "TaskScheduler.UnobservedTaskException", isFatal: false); - }; _telemetryService?.AddBreadcrumb("Application initialized", "lifecycle"); diff --git a/GenHub/GenHub/Common/Services/DownloadService.cs b/GenHub/GenHub/Common/Services/DownloadService.cs index cbef8eb3f..ceff9c511 100644 --- a/GenHub/GenHub/Common/Services/DownloadService.cs +++ b/GenHub/GenHub/Common/Services/DownloadService.cs @@ -184,14 +184,14 @@ private async Task PerformDownloadAsync( } } - var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; + var totalElapsedSeconds = stopwatch.Elapsed.TotalSeconds; var sizeMb = downloadedBytes / (1024.0 * 1024.0); - var speedMbps = elapsedSeconds > 0 ? (sizeMb * 8.0) / elapsedSeconds : 0.0; + var speedMbps = totalElapsedSeconds > 0 ? (sizeMb * 8.0) / totalElapsedSeconds : 0.0; telemetryService?.TrackEvent(TelemetryConstants.Events.ContentDownloadCompleted, new Dictionary { [TelemetryConstants.Properties.SizeMb] = Math.Round(sizeMb, 2), - [TelemetryConstants.Properties.DurationSeconds] = Math.Round(elapsedSeconds, 2), + [TelemetryConstants.Properties.DurationSeconds] = Math.Round(totalElapsedSeconds, 2), [TelemetryConstants.Properties.SpeedMbps] = Math.Round(speedMbps, 2), [TelemetryConstants.Properties.SourceProvider] = configuration.Url.Host, }); diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 2cb0d9f96..d65347583 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -589,6 +589,36 @@ private static bool HasExecutePermission(string path) } } + private static string DetectRunnerEnvironment() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Native"; + } + + if (Environment.GetEnvironmentVariable("PROTON_VERSION") is { Length: > 0 } proton) + { + return $"Proton-{proton}"; + } + + if (Environment.GetEnvironmentVariable("WINEPREFIX") is { Length: > 0 }) + { + return "Wine"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "Linux-Runner"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "macOS-Runner"; + } + + return "Native"; + } + /// /// Reads a process's start time in UTC, or reports that it could not be read. /// @@ -1380,27 +1410,6 @@ private string AppendLauncherErrors(string message, Process launcher, BoundedErr return string.IsNullOrWhiteSpace(detail) ? message : $"{message} {detail}"; } - private static string DetectRunnerEnvironment() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "Native"; - } - - if (Environment.GetEnvironmentVariable("PROTON_VERSION") is { Length: > 0 } proton) - { - return $"Proton-{proton}"; - } - - if (Environment.GetEnvironmentVariable("WINEPREFIX") is { Length: > 0 }) - { - return "Wine"; - } - - return RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "Linux-Runner" : - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macOS-Runner" : "Native"; - } - private void RegisterSessionAndEmitStarted(Process process, string executableName) { var sessionId = Guid.NewGuid().ToString("N")[..8]; diff --git a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs index 89e7a1bf9..4a0d43fc4 100644 --- a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -105,7 +105,7 @@ public void TrackEvent( var sanitizedProperties = _sanitizer.SanitizeProperties(properties); string? sessionId = null; - if (properties != null && properties.TryGetValue(TelemetryConstants.Properties.SessionId, out var rawSessionId) && rawSessionId != null) + if (properties?.TryGetValue(TelemetryConstants.Properties.SessionId, out var rawSessionId) is true && rawSessionId != null) { sessionId = rawSessionId.ToString(); } diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs index 6f01b563a..5edd9000d 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs @@ -158,4 +158,3 @@ public async Task> FlushAsync(CancellationToken cancellati : OperationResult.CreateSuccess(true); } } - diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs index 006754064..c3ab17094 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs @@ -79,76 +79,7 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent try { var (storeUrl, publicKey) = ParseDsn(dsn); - var extra = new Dictionary(telemetryEvent.Properties ?? new Dictionary()); - - string? exceptionType = null; - string? exceptionMessage = null; - string? stackTrace = null; - var isFatal = false; - - if (extra.Remove(TelemetryConstants.Properties.ExceptionType, out var exTypeObj) && exTypeObj != null) - { - exceptionType = exTypeObj.ToString(); - } - - if (extra.Remove(TelemetryConstants.Properties.ExceptionMessage, out var exMsgObj) && exMsgObj != null) - { - exceptionMessage = exMsgObj.ToString(); - } - - if (extra.Remove(TelemetryConstants.Properties.StackTrace, out var stackObj) && stackObj != null) - { - stackTrace = stackObj.ToString(); - } - - if (extra.Remove(TelemetryConstants.Properties.IsFatal, out var fatalObj) && fatalObj is bool b) - { - isFatal = b; - } - - var payload = new Dictionary - { - ["event_id"] = Guid.NewGuid().ToString("N"), - ["timestamp"] = telemetryEvent.Timestamp.ToString("o"), - ["platform"] = "csharp", - ["level"] = isFatal ? "fatal" : "error", - ["logger"] = TelemetryConstants.AppName, - ["release"] = telemetryEvent.AppVersion, - ["environment"] = AppConstants.BuildChannel, - ["tags"] = new Dictionary - { - ["os"] = telemetryEvent.Platform, - ["arch"] = RuntimeInformation.ProcessArchitecture.ToString(), - }, - ["user"] = new Dictionary - { - ["id"] = telemetryEvent.InstallationId, - }, - ["extra"] = extra, - }; - - if (!string.IsNullOrEmpty(exceptionMessage) || !string.IsNullOrEmpty(exceptionType)) - { - payload["message"] = new Dictionary - { - ["formatted"] = exceptionMessage ?? exceptionType ?? "Application Crash", - }; - - payload["exception"] = new Dictionary - { - ["values"] = new[] - { - new Dictionary - { - ["type"] = exceptionType ?? "Exception", - ["value"] = exceptionMessage ?? string.Empty, - ["stacktrace"] = !string.IsNullOrEmpty(stackTrace) - ? new Dictionary { ["raw"] = stackTrace } - : null, - }, - }, - }; - } + var payload = BuildSentryPayload(telemetryEvent); var json = JsonSerializer.Serialize(payload, JsonOptions); using var request = new HttpRequestMessage(HttpMethod.Post, storeUrl) @@ -209,6 +140,82 @@ public async Task> FlushAsync(CancellationToken cancellati : OperationResult.CreateSuccess(true); } + private static Dictionary BuildSentryPayload(TelemetryEvent telemetryEvent) + { + var extra = new Dictionary(telemetryEvent.Properties ?? new Dictionary()); + + string? exceptionType = null; + string? exceptionMessage = null; + string? stackTrace = null; + var isFatal = false; + + if (extra.Remove(TelemetryConstants.Properties.ExceptionType, out var exTypeObj) && exTypeObj != null) + { + exceptionType = exTypeObj.ToString(); + } + + if (extra.Remove(TelemetryConstants.Properties.ExceptionMessage, out var exMsgObj) && exMsgObj != null) + { + exceptionMessage = exMsgObj.ToString(); + } + + if (extra.Remove(TelemetryConstants.Properties.StackTrace, out var stackObj) && stackObj != null) + { + stackTrace = stackObj.ToString(); + } + + if (extra.Remove(TelemetryConstants.Properties.IsFatal, out var fatalObj) && fatalObj is bool b) + { + isFatal = b; + } + + var payload = new Dictionary + { + ["event_id"] = Guid.NewGuid().ToString("N"), + ["timestamp"] = telemetryEvent.Timestamp.ToString("o"), + ["platform"] = "csharp", + ["level"] = isFatal ? "fatal" : "error", + ["logger"] = TelemetryConstants.AppName, + ["release"] = telemetryEvent.AppVersion, + ["environment"] = AppConstants.BuildChannel, + ["tags"] = new Dictionary + { + ["os"] = telemetryEvent.Platform, + ["arch"] = RuntimeInformation.ProcessArchitecture.ToString(), + }, + ["user"] = new Dictionary + { + ["id"] = telemetryEvent.InstallationId, + }, + ["extra"] = extra, + }; + + if (!string.IsNullOrEmpty(exceptionMessage) || !string.IsNullOrEmpty(exceptionType)) + { + payload["message"] = new Dictionary + { + ["formatted"] = exceptionMessage ?? exceptionType ?? "Application Crash", + }; + + payload["exception"] = new Dictionary + { + ["values"] = new[] + { + new Dictionary + { + ["type"] = exceptionType ?? "Exception", + ["value"] = exceptionMessage ?? string.Empty, + ["stacktrace"] = !string.IsNullOrEmpty(stackTrace) + ? new Dictionary { ["raw"] = stackTrace } + : new Dictionary(), + }, + }, + }; + } + + return payload; + } + private static (string StoreUrl, string? PublicKey) ParseDsn(string dsn) { if (string.IsNullOrWhiteSpace(dsn)) @@ -233,4 +240,3 @@ private static (string StoreUrl, string? PublicKey) ParseDsn(string dsn) return (storeUrl, publicKey); } } - diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs index f35c2bdd1..cb780738c 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs @@ -50,4 +50,3 @@ public static IServiceCollection AddTelemetryServices(this IServiceCollection se return services; } } - From b027398653726f199d0160b39a246661cfe0712c Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 14:57:21 +0000 Subject: [PATCH 03/12] fix(telemetry): add test documentation and track runner environment from launch configuration - Add XML returns documentation on Task-returning test methods (SA1615) and remove trailing blank lines (SA1518) - Support inspecting launch configuration environment variables in DetectRunnerEnvironment - Register session tracking in DiscoverAndTrackProcessAsync and HandleImmediateProcessExit --- .../Telemetry/AnalyticsTelemetrySinkTests.cs | 5 ++++- .../Telemetry/LoggingTelemetrySinkTests.cs | 2 ++ .../Telemetry/SentryTelemetrySinkTests.cs | 5 ++++- .../Telemetry/TelemetryServiceTests.cs | 5 +++++ .../Infrastructure/GameProcessManager.cs | 18 +++++++++++++----- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs index a51c43871..e9deaf015 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs @@ -66,6 +66,7 @@ public void EndpointUrlAndApiKey_DefaultToPostHogConstants() /// /// Verifies EmitAsync succeeds and buffers locally when no HTTP client is configured. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() { @@ -87,6 +88,7 @@ public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() /// /// Verifies EmitAsync sends request formatted for PostHog capture API. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayload() { @@ -142,6 +144,7 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayload( /// /// Verifies EmitAsync buffers and returns failure when endpoint returns error. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() { @@ -162,6 +165,7 @@ public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() /// /// Verifies FlushAsync flushes buffered events when client is active. /// + /// A representing the asynchronous unit test. [Fact] public async Task FlushAsync_FlushesBufferedEventsSuccessfully() { @@ -181,4 +185,3 @@ protected override Task SendAsync(HttpRequestMessage reques } } } - diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs index dd20ea722..7eec48949 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs @@ -46,6 +46,7 @@ public void SinkProperties_AreValid() /// /// Verifies EmitAsync succeeds for normal event. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_StandardEvent_ReturnsSuccess() { @@ -64,6 +65,7 @@ public async Task EmitAsync_StandardEvent_ReturnsSuccess() /// /// Verifies EmitAsync succeeds for crash event. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_CrashEvent_ReturnsSuccess() { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs index 527b8608d..dad77fdb3 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs @@ -66,6 +66,7 @@ public void DsnEndpoint_DefaultsToConfiguredConstant() /// /// Verifies EmitAsync succeeds and buffers locally when no HTTP client is configured. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() { @@ -87,6 +88,7 @@ public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() /// /// Verifies EmitAsync sends request to Sentry store endpoint with auth headers. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAuthHeader() { @@ -142,6 +144,7 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAu /// /// Verifies EmitAsync handles endpoint failure gracefully by buffering and returning failure. /// + /// A representing the asynchronous unit test. [Fact] public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() { @@ -162,6 +165,7 @@ public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() /// /// Verifies FlushAsync flushes buffered events when client is active. /// + /// A representing the asynchronous unit test. [Fact] public async Task FlushAsync_FlushesBufferedEventsSuccessfully() { @@ -187,4 +191,3 @@ protected override Task SendAsync(HttpRequestMessage reques } } } - diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs index 050ebcbaa..991daece2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs @@ -56,6 +56,7 @@ public void Dispose() /// /// Verifies that TrackEvent does not emit when telemetry is Disabled. /// + /// A representing the asynchronous unit test. [Fact] public async Task TrackEvent_WhenDisabled_DoesNotEmit() { @@ -78,6 +79,7 @@ public async Task TrackEvent_WhenDisabled_DoesNotEmit() /// /// Verifies that TrackEvent emits when telemetry is AnonymousMetrics. /// + /// A representing the asynchronous unit test. [Fact] public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() { @@ -105,6 +107,7 @@ public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() /// /// Verifies that TrackException captures exception details, sanitized message, and breadcrumbs. /// + /// A representing the asynchronous unit test. [Fact] public async Task TrackException_RecordsSanitizedCrashEvent() { @@ -139,6 +142,7 @@ public async Task TrackException_RecordsSanitizedCrashEvent() /// /// Verifies that breadcrumbs circular buffer is capped at MaxBreadcrumbsCount. /// + /// A representing the asynchronous unit test. [Fact] public async Task AddBreadcrumb_CappedAtMaxCount() { @@ -161,6 +165,7 @@ public async Task AddBreadcrumb_CappedAtMaxCount() /// /// Verifies that FlushAsync flushes all registered sinks. /// + /// A representing the asynchronous unit test. [Fact] public async Task FlushAsync_CallsFlushOnAllSinks() { diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index d65347583..e81237439 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -120,7 +120,7 @@ public async Task> StartProcessAsync(GameLaunch } _managedProcesses[process.Id] = process; - RegisterSessionAndEmitStarted(process, configuration.ExecutablePath); + RegisterSessionAndEmitStarted(process, configuration.ExecutablePath, configuration.EnvironmentVariables); if (configuration.WaitForExit) { @@ -412,6 +412,7 @@ public async Task> DiscoverAndTrackProcessAsync // Track it _managedProcesses[process.Id] = process; + RegisterSessionAndEmitStarted(process, processName); try { @@ -589,19 +590,25 @@ private static bool HasExecutePermission(string path) } } - private static string DetectRunnerEnvironment() + private static string DetectRunnerEnvironment(IReadOnlyDictionary? envVars = null) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return "Native"; } + if (envVars != null && envVars.TryGetValue("PROTON_VERSION", out var configProton) && !string.IsNullOrWhiteSpace(configProton)) + { + return $"Proton-{configProton}"; + } + if (Environment.GetEnvironmentVariable("PROTON_VERSION") is { Length: > 0 } proton) { return $"Proton-{proton}"; } - if (Environment.GetEnvironmentVariable("WINEPREFIX") is { Length: > 0 }) + if ((envVars != null && envVars.ContainsKey("WINEPREFIX")) + || Environment.GetEnvironmentVariable("WINEPREFIX") is { Length: > 0 }) { return "Wine"; } @@ -873,6 +880,7 @@ private OperationResult HandleImmediateProcessExit( process.Dispose(); _managedProcesses[spawnedProcess.Id] = spawnedProcess; + RegisterSessionAndEmitStarted(spawnedProcess, configuration.ExecutablePath, configuration.EnvironmentVariables); try { @@ -1410,7 +1418,7 @@ private string AppendLauncherErrors(string message, Process launcher, BoundedErr return string.IsNullOrWhiteSpace(detail) ? message : $"{message} {detail}"; } - private void RegisterSessionAndEmitStarted(Process process, string executableName) + private void RegisterSessionAndEmitStarted(Process process, string executableName, IReadOnlyDictionary? envVars = null) { var sessionId = Guid.NewGuid().ToString("N")[..8]; var execName = Path.GetFileName(executableName); @@ -1423,7 +1431,7 @@ private void RegisterSessionAndEmitStarted(Process process, string executableNam [TelemetryConstants.Properties.SessionId] = sessionId, [TelemetryConstants.Properties.ExecutablePath] = execName, [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, - [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(), + [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(envVars), }); } } From bd520cb4f65a4044911ba0039ed0d10f6484497c Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 15:01:36 +0000 Subject: [PATCH 04/12] fix(style): resolve StyleCop SA1116/SA1117 and CS8601 nullability warnings - Use null-coalescing on nullable strings for Sentry dictionary payload - Format multi-line Verify parameter invocations in TelemetryServiceTests --- .../Telemetry/TelemetryServiceTests.cs | 20 +++++++++++-------- .../Telemetry/Sinks/SentryTelemetrySink.cs | 6 +++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs index 991daece2..efb09ceba 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs @@ -99,9 +99,11 @@ public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() // Allow background loop to process await Task.Delay(100); - _mockSink.Verify(s => s.EmitAsync( - It.Is(e => e.EventName == TelemetryConstants.Events.GameSessionStarted && e.SessionId == "test-session"), - It.IsAny()), Times.AtLeastOnce); + _mockSink.Verify( + s => s.EmitAsync( + It.Is(e => e.EventName == TelemetryConstants.Events.GameSessionStarted && e.SessionId == "test-session"), + It.IsAny()), + Times.AtLeastOnce); } /// @@ -132,11 +134,13 @@ public async Task TrackException_RecordsSanitizedCrashEvent() await Task.Delay(100); - _mockSink.Verify(s => s.EmitAsync( - It.Is(e => e.EventName == TelemetryConstants.Events.AppCrash && - e.Level == TelemetryLevel.CrashReportsOnly && - e.Properties.ContainsKey(TelemetryConstants.Properties.ExceptionType)), - It.IsAny()), Times.AtLeastOnce); + _mockSink.Verify( + s => s.EmitAsync( + It.Is(e => e.EventName == TelemetryConstants.Events.AppCrash && + e.Level == TelemetryLevel.CrashReportsOnly && + e.Properties.ContainsKey(TelemetryConstants.Properties.ExceptionType)), + It.IsAny()), + Times.AtLeastOnce); } /// diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs index c3ab17094..cd95832c6 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs @@ -176,16 +176,16 @@ public async Task> FlushAsync(CancellationToken cancellati ["platform"] = "csharp", ["level"] = isFatal ? "fatal" : "error", ["logger"] = TelemetryConstants.AppName, - ["release"] = telemetryEvent.AppVersion, + ["release"] = telemetryEvent.AppVersion ?? string.Empty, ["environment"] = AppConstants.BuildChannel, ["tags"] = new Dictionary { - ["os"] = telemetryEvent.Platform, + ["os"] = telemetryEvent.Platform ?? string.Empty, ["arch"] = RuntimeInformation.ProcessArchitecture.ToString(), }, ["user"] = new Dictionary { - ["id"] = telemetryEvent.InstallationId, + ["id"] = telemetryEvent.InstallationId ?? string.Empty, }, ["extra"] = extra, }; From 1521bbcce43084687bd1dfcf2233c060f4284237 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 15:06:01 +0000 Subject: [PATCH 05/12] fix(telemetry): correct Wine prefix regex and prioritization in TelemetrySanitizer --- GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs b/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs index 2ab171623..fea267b6f 100644 --- a/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs +++ b/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs @@ -33,7 +33,7 @@ public partial class TelemetrySanitizer : ITelemetrySanitizer [GeneratedRegex(@"/(?:home|Users)/[^/]+", RegexOptions.Compiled)] private static partial Regex UnixUserDirRegex(); - [GeneratedRegex(@"/[^\s""]+/\.wine(?:-[^\s""]+)?/drive_c", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"(?:[^\s""]+)?/\.wine(?:-[^\s""]+)?/drive_c", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex WinePrefixRegex(); private readonly string? _userProfilePath; @@ -66,6 +66,9 @@ public string SanitizeString(string? input) var result = input; + // Mask Wine prefix paths + result = WinePrefixRegex().Replace(result, TelemetryConstants.WinePrefixMask); + // Mask exact user profile path if available if (!string.IsNullOrEmpty(_userProfilePath) && _userProfilePath.Length > 2) { @@ -78,9 +81,6 @@ public string SanitizeString(string? input) // Mask generic Unix/macOS user directory patterns (e.g. /home/john or /Users/john) result = UnixUserDirRegex().Replace(result, TelemetryConstants.UserDirectoryMask); - // Mask Wine prefix paths - result = WinePrefixRegex().Replace(result, TelemetryConstants.WinePrefixMask); - // Mask GitHub & authorization tokens result = GitHubTokenRegex().Replace(result, TelemetryConstants.SecretTokenMask); result = GitHubFineGrainedTokenRegex().Replace(result, TelemetryConstants.SecretTokenMask); From 17c5fa35d54fb4844c9f2ecc0c0f27e3a3a32541 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 15:13:02 +0000 Subject: [PATCH 06/12] fix(telemetry): lazily initialize heartbeat timer and deduplicate update telemetry channel --- .../AppUpdate/Services/VelopackUpdateManager.cs | 7 +++++-- .../Infrastructure/GameProcessManager.cs | 12 +++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index c41dd68dc..b25c295da 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -159,6 +159,9 @@ public void Dispose() GC.SuppressFinalize(this); } + private string TelemetryChannel => + _subscribedPrNumber.HasValue ? $"PR-{_subscribedPrNumber}" : _subscribedBranch ?? "Release"; + /// public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default) { @@ -174,7 +177,7 @@ public void Dispose() _telemetryService?.TrackEvent(TelemetryConstants.Events.AppUpdateChecked, new Dictionary { [TelemetryConstants.Properties.FromVersion] = AppConstants.AppVersion, - [TelemetryConstants.Properties.Channel] = _subscribedPrNumber.HasValue ? $"PR-{_subscribedPrNumber}" : _subscribedBranch ?? "Release", + [TelemetryConstants.Properties.Channel] = TelemetryChannel, [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, }); @@ -345,7 +348,7 @@ public void ApplyUpdatesAndRestart(UpdateInfo updateInfo) { [TelemetryConstants.Properties.FromVersion] = AppConstants.AppVersion, [TelemetryConstants.Properties.ToVersion] = updateInfo.TargetFullRelease.Version.ToString(), - [TelemetryConstants.Properties.Channel] = _subscribedPrNumber.HasValue ? $"PR-{_subscribedPrNumber}" : _subscribedBranch ?? "Release", + [TelemetryConstants.Properties.Channel] = TelemetryChannel, [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, }); diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index e81237439..2fa06ce19 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -44,11 +44,7 @@ public class GameProcessManager( /// /// Periodic timer to send anonymous heartbeats for active game sessions. /// - private readonly Timer _heartbeatTimer = new( - state => ((GameProcessManager?)state)?.EmitHeartbeats(), - null, - TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes), - TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes)); + private Timer? _heartbeatTimer; private bool _disposed; @@ -1426,6 +1422,12 @@ private void RegisterSessionAndEmitStarted(Process process, string executableNam if (telemetryService != null) { + if (_heartbeatTimer == null) + { + var interval = TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes); + _heartbeatTimer = new Timer(_ => EmitHeartbeats(), null, interval, interval); + } + telemetryService.TrackEvent(TelemetryConstants.Events.GameSessionStarted, new Dictionary { [TelemetryConstants.Properties.SessionId] = sessionId, From a1cfb958c173174c849475624fe26ac717be62e4 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 15:19:16 +0000 Subject: [PATCH 07/12] fix(telemetry): aggregate sink flush failures and persist anonymous installation id --- .../Features/Telemetry/Services/TelemetryService.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs index 4a0d43fc4..cb87a1609 100644 --- a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -231,7 +231,14 @@ public async Task> FlushAsync(CancellationToken cancellati try { var tasks = _sinks.Select(sink => sink.FlushAsync(cancellationToken)); - await Task.WhenAll(tasks); + var results = await Task.WhenAll(tasks); + var failures = results.Where(r => !r.Success).ToList(); + if (failures.Count > 0) + { + var errors = string.Join("; ", failures.Select(r => r.ErrorMessage ?? "Sink flush failed")); + return OperationResult.CreateFailure(errors); + } + return OperationResult.CreateSuccess(true); } catch (Exception ex) @@ -303,6 +310,7 @@ private string GetOrCreateInstallationId() var newId = Guid.NewGuid().ToString("N"); _userSettingsService.Update(s => s.AnonymousInstallationId = newId); + _ = _userSettingsService.SaveAsync(CancellationToken.None); return newId; } catch From 4dfff733788a681692a3bf9707c55f8d404c581c Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 15:23:30 +0000 Subject: [PATCH 08/12] fix(telemetry): use FirstError property on OperationResult --- GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs index cb87a1609..3a6082dd7 100644 --- a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -235,7 +235,7 @@ public async Task> FlushAsync(CancellationToken cancellati var failures = results.Where(r => !r.Success).ToList(); if (failures.Count > 0) { - var errors = string.Join("; ", failures.Select(r => r.ErrorMessage ?? "Sink flush failed")); + var errors = string.Join("; ", failures.Select(r => r.FirstError ?? "Sink flush failed")); return OperationResult.CreateFailure(errors); } From e0ecf9f1e7848387203f6d362f69fadc48b65d03 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 16:03:48 +0000 Subject: [PATCH 09/12] fix(telemetry): address review feedback on buffer bounding, async drain, and test naming --- .../Utilities/TelemetrySanitizer.cs | 10 +++--- .../Telemetry/AnalyticsTelemetrySinkTests.cs | 32 ++++++++++++++--- .../Telemetry/LoggingTelemetrySinkTests.cs | 4 +-- .../Telemetry/SentryTelemetrySinkTests.cs | 34 ++++++++++++++----- .../Telemetry/TelemetrySanitizerTests.cs | 13 +++++-- .../Telemetry/TelemetryServiceTests.cs | 18 +++++----- GenHub/GenHub/App.axaml.cs | 12 ++++++- .../Infrastructure/GameProcessManager.cs | 20 +++++------ .../Telemetry/Services/TelemetryService.cs | 19 ++++++++--- .../Telemetry/Sinks/AnalyticsTelemetrySink.cs | 30 +++++++++------- .../Telemetry/Sinks/SentryTelemetrySink.cs | 25 ++++++++------ 11 files changed, 146 insertions(+), 71 deletions(-) diff --git a/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs b/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs index fea267b6f..eb84daddc 100644 --- a/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs +++ b/GenHub/GenHub.Core/Utilities/TelemetrySanitizer.cs @@ -15,7 +15,7 @@ public partial class TelemetrySanitizer : ITelemetrySanitizer [GeneratedRegex(@"\b(?:\d{1,3}\.){3}\d{1,3}\b", RegexOptions.Compiled)] private static partial Regex Ipv4Regex(); - [GeneratedRegex(@"\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b", RegexOptions.Compiled)] + [GeneratedRegex(@"(?i)(? stringList) + if (value is System.Collections.IEnumerable enumerable and not string) { - var sanitizedList = new List(); - foreach (var item in stringList) + var sanitizedList = new List(); + foreach (var item in enumerable) { - sanitizedList.Add(SanitizeString(item)); + sanitizedList.Add(SanitizeValue(item)); } return sanitizedList; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs index e9deaf015..e78012561 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs @@ -68,7 +68,7 @@ public void EndpointUrlAndApiKey_DefaultToPostHogConstants() /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() + public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccessAsync() { var ev = new TelemetryEvent { @@ -90,7 +90,7 @@ public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayload() + public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayloadAsync() { HttpRequestMessage? capturedRequest = null; string? capturedBody = null; @@ -139,6 +139,7 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayload( Assert.Equal("GenHub", properties.GetProperty("$lib").GetString()); Assert.Equal("sess-7777", properties.GetProperty("$session_id").GetString()); Assert.Equal("ZeroHour", properties.GetProperty(TelemetryConstants.Properties.GameType).GetString()); + Assert.False(properties.GetProperty("$process_person_profile").GetBoolean()); } /// @@ -146,7 +147,7 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayload( /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() + public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailureAsync() { var handler = new TestHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway))); using var client = new HttpClient(handler); @@ -167,14 +168,35 @@ public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() /// /// A representing the asynchronous unit test. [Fact] - public async Task FlushAsync_FlushesBufferedEventsSuccessfully() + public async Task FlushAsync_FlushesBufferedEventsSuccessfullyAsync() { - var handler = new TestHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + var sendCount = 0; + var returnError = true; + var handler = new TestHandler(_ => + { + Interlocked.Increment(ref sendCount); + return Task.FromResult(new HttpResponseMessage(returnError ? HttpStatusCode.BadGateway : HttpStatusCode.OK)); + }); + using var client = new HttpClient(handler); var sink = new AnalyticsTelemetrySink(_loggerMock.Object, client); + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.GameSessionStarted, + Level = TelemetryLevel.AnonymousMetrics, + }; + + // Fail once to populate internal retry buffer + var emitResult = await sink.EmitAsync(ev); + Assert.False(emitResult.Success); + Assert.Equal(1, sendCount); + + // Allow success and flush + returnError = false; var flushResult = await sink.FlushAsync(); Assert.True(flushResult.Success); + Assert.Equal(2, sendCount); } private sealed class TestHandler(Func> handlerFunc) : HttpMessageHandler diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs index 7eec48949..53e86dde4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs @@ -48,7 +48,7 @@ public void SinkProperties_AreValid() /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_StandardEvent_ReturnsSuccess() + public async Task EmitAsync_StandardEvent_ReturnsSuccessAsync() { var ev = new TelemetryEvent { @@ -67,7 +67,7 @@ public async Task EmitAsync_StandardEvent_ReturnsSuccess() /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_CrashEvent_ReturnsSuccess() + public async Task EmitAsync_CrashEvent_ReturnsSuccessAsync() { var ev = new TelemetryEvent { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs index dad77fdb3..0f7497add 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs @@ -68,7 +68,7 @@ public void DsnEndpoint_DefaultsToConfiguredConstant() /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() + public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccessAsync() { var ev = new TelemetryEvent { @@ -90,7 +90,7 @@ public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccess() /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAuthHeader() + public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAuthHeaderAsync() { HttpRequestMessage? capturedRequest = null; string? capturedBody = null; @@ -107,7 +107,10 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAu }); using var client = new HttpClient(handler); - var sink = new SentryTelemetrySink(_loggerMock.Object, client); + var sink = new SentryTelemetrySink(_loggerMock.Object, client) + { + DsnEndpoint = "https://testkey@sentry.example.com/1234", + }; var ev = new TelemetryEvent { @@ -129,11 +132,11 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAu Assert.True(result.Success); Assert.NotNull(capturedRequest); - Assert.Contains("/api/4511943606927440/store/", capturedRequest.RequestUri?.ToString()); + Assert.Contains("/api/1234/store/", capturedRequest.RequestUri?.ToString()); Assert.True(capturedRequest.Headers.Contains("X-Sentry-Auth")); var authHeader = capturedRequest.Headers.GetValues("X-Sentry-Auth").FirstOrDefault(); - Assert.Contains("sentry_key=06a9269c6418a6917f0fec49e1589e44", authHeader); + Assert.Contains("sentry_key=testkey", authHeader); Assert.NotNull(capturedBody); using var jsonDoc = JsonDocument.Parse(capturedBody); @@ -146,7 +149,7 @@ public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAu /// /// A representing the asynchronous unit test. [Fact] - public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() + public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailureAsync() { var handler = new TestHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError))); using var client = new HttpClient(handler); @@ -167,20 +170,35 @@ public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailure() /// /// A representing the asynchronous unit test. [Fact] - public async Task FlushAsync_FlushesBufferedEventsSuccessfully() + public async Task FlushAsync_FlushesBufferedEventsSuccessfullyAsync() { var sendCount = 0; + var returnError = true; var handler = new TestHandler(_ => { Interlocked.Increment(ref sendCount); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + return Task.FromResult(new HttpResponseMessage(returnError ? HttpStatusCode.InternalServerError : HttpStatusCode.OK)); }); using var client = new HttpClient(handler); var sink = new SentryTelemetrySink(_loggerMock.Object, client); + var ev = new TelemetryEvent + { + EventName = TelemetryConstants.Events.AppCrash, + Level = TelemetryLevel.CrashReportsOnly, + }; + + // Fail once to populate internal retry buffer + var emitResult = await sink.EmitAsync(ev); + Assert.False(emitResult.Success); + Assert.Equal(1, sendCount); + + // Allow success and flush + returnError = false; var flushResult = await sink.FlushAsync(); Assert.True(flushResult.Success); + Assert.Equal(2, sendCount); } private sealed class TestHandler(Func> handlerFunc) : HttpMessageHandler diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs index 6277040ce..6bd2ce4b0 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs @@ -66,12 +66,13 @@ public void SanitizeString_WinePrefixPath_ReplacesWithWinePrefixMask() [Fact] public void SanitizeString_IpAddresses_ReplacesWithIpMask() { - var input = "Connection from 192.168.1.50 and 2001:0db8:85a3:0000:0000:8a2e:0370:7334 failed."; + var input = "Connection from 192.168.1.50, 2001:0db8:85a3:0000:0000:8a2e:0370:7334, and 2001:db8::1 failed."; var result = _sanitizer.SanitizeString(input); Assert.Contains(TelemetryConstants.IpAddressMask, result); Assert.DoesNotContain("192.168.1.50", result); Assert.DoesNotContain("2001:0db8:85a3:0000:0000:8a2e:0370:7334", result); + Assert.DoesNotContain("2001:db8::1", result); } /// @@ -89,16 +90,17 @@ public void SanitizeString_Tokens_ReplacesWithTokenMask() } /// - /// Verifies that dictionary properties are recursively sanitized. + /// Verifies that dictionary properties and object collections are recursively sanitized. /// [Fact] - public void SanitizeProperties_NestedDictionary_SanitizesAllValues() + public void SanitizeProperties_NestedDictionaryAndCollections_SanitizesAllValues() { var props = new Dictionary { ["path"] = @"C:\Users\SecretUser\game.exe", ["ip"] = "10.0.0.1", ["count"] = 42, + ["collection"] = new object?[] { @"C:\Users\OtherUser\file.txt", "192.168.1.1" }, ["nested"] = new Dictionary { ["user_folder"] = "/home/secretuser/workspace", @@ -111,6 +113,11 @@ public void SanitizeProperties_NestedDictionary_SanitizesAllValues() Assert.Contains(TelemetryConstants.UserDirectoryMask, sanitized["path"]?.ToString()); Assert.Contains(TelemetryConstants.IpAddressMask, sanitized["ip"]?.ToString()); + var coll = sanitized["collection"] as List; + Assert.NotNull(coll); + Assert.Contains(TelemetryConstants.UserDirectoryMask, coll[0]?.ToString()); + Assert.Contains(TelemetryConstants.IpAddressMask, coll[1]?.ToString()); + var nested = sanitized["nested"] as IReadOnlyDictionary; Assert.NotNull(nested); Assert.Contains(TelemetryConstants.UserDirectoryMask, nested["user_folder"]?.ToString()); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs index efb09ceba..d8672fd2d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs @@ -58,7 +58,7 @@ public void Dispose() /// /// A representing the asynchronous unit test. [Fact] - public async Task TrackEvent_WhenDisabled_DoesNotEmit() + public async Task TrackEvent_WhenDisabled_DoesNotEmitAsync() { _settings.TelemetryPreference = TelemetryLevel.Disabled; @@ -70,8 +70,7 @@ public async Task TrackEvent_WhenDisabled_DoesNotEmit() service.TrackEvent(TelemetryConstants.Events.GameSessionStarted); - // Allow background loop a moment - await Task.Delay(50); + await service.FlushAsync(); _mockSink.Verify(s => s.EmitAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -81,7 +80,7 @@ public async Task TrackEvent_WhenDisabled_DoesNotEmit() /// /// A representing the asynchronous unit test. [Fact] - public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() + public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSinkAsync() { _settings.TelemetryPreference = TelemetryLevel.AnonymousMetrics; @@ -96,8 +95,7 @@ public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() [TelemetryConstants.Properties.SessionId] = "test-session", }); - // Allow background loop to process - await Task.Delay(100); + await service.FlushAsync(); _mockSink.Verify( s => s.EmitAsync( @@ -111,7 +109,7 @@ public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSink() /// /// A representing the asynchronous unit test. [Fact] - public async Task TrackException_RecordsSanitizedCrashEvent() + public async Task TrackException_RecordsSanitizedCrashEventAsync() { _settings.TelemetryPreference = TelemetryLevel.CrashReportsOnly; @@ -132,7 +130,7 @@ public async Task TrackException_RecordsSanitizedCrashEvent() service.TrackException(ex, "GameLauncher", isFatal: true); } - await Task.Delay(100); + await service.FlushAsync(); _mockSink.Verify( s => s.EmitAsync( @@ -148,7 +146,7 @@ public async Task TrackException_RecordsSanitizedCrashEvent() /// /// A representing the asynchronous unit test. [Fact] - public async Task AddBreadcrumb_CappedAtMaxCount() + public async Task AddBreadcrumb_CappedAtMaxCountAsync() { await using var service = new TelemetryService( _mockLogger.Object, @@ -171,7 +169,7 @@ public async Task AddBreadcrumb_CappedAtMaxCount() /// /// A representing the asynchronous unit test. [Fact] - public async Task FlushAsync_CallsFlushOnAllSinks() + public async Task FlushAsync_CallsFlushOnAllSinksAsync() { await using var service = new TelemetryService( _mockLogger.Object, diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 17d32f91b..b12829759 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -65,6 +65,15 @@ public override void OnFrameworkInitializationCompleted() if (args.ExceptionObject is Exception ex) { _telemetryService?.TrackException(ex, "AppDomain.UnhandledException", isFatal: true); + try + { + using var crashFlushCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + _telemetryService?.FlushAsync(crashFlushCts.Token).GetAwaiter().GetResult(); + } + catch + { + // Suppress crash flush failures during terminal exception + } } }; @@ -184,7 +193,8 @@ private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArg { try { - await _telemetryService.FlushAsync(); + using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _telemetryService.FlushAsync(flushCts.Token); } catch { diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 2fa06ce19..4d10b1e84 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -29,7 +29,7 @@ public class GameProcessManager( { private const int CleanupIntervalMs = ProcessConstants.ProcessCleanupIntervalMs; private readonly ConcurrentDictionary _managedProcesses = new(); - private readonly ConcurrentDictionary _sessionMetadata = new(); + private readonly ConcurrentDictionary _sessionMetadata = new(); private readonly SemaphoreSlim _terminationSemaphore = new(1, 1); /// @@ -593,7 +593,7 @@ private static string DetectRunnerEnvironment(IReadOnlyDictionary 0 }) + if (envVars?.ContainsKey("WINEPREFIX") is true || Environment.GetEnvironmentVariable("WINEPREFIX") is { Length: > 0 }) { return "Wine"; } @@ -955,7 +954,7 @@ private void OnProcessExited(object? sender, EventArgs e) [TelemetryConstants.Properties.DurationSeconds] = duration, [TelemetryConstants.Properties.ExitCode] = exitCode, [TelemetryConstants.Properties.ExecutablePath] = sessionMeta.ExecName, - [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(), + [TelemetryConstants.Properties.Runner] = sessionMeta.Runner, }); } @@ -1416,9 +1415,10 @@ private string AppendLauncherErrors(string message, Process launcher, BoundedErr private void RegisterSessionAndEmitStarted(Process process, string executableName, IReadOnlyDictionary? envVars = null) { - var sessionId = Guid.NewGuid().ToString("N")[..8]; + var sessionId = Guid.NewGuid().ToString("N"); var execName = Path.GetFileName(executableName); - _sessionMetadata[process.Id] = (sessionId, DateTime.UtcNow, execName); + var runner = DetectRunnerEnvironment(envVars); + _sessionMetadata[process.Id] = (sessionId, DateTime.UtcNow, execName, runner); if (telemetryService != null) { @@ -1433,7 +1433,7 @@ private void RegisterSessionAndEmitStarted(Process process, string executableNam [TelemetryConstants.Properties.SessionId] = sessionId, [TelemetryConstants.Properties.ExecutablePath] = execName, [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, - [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(envVars), + [TelemetryConstants.Properties.Runner] = runner, }); } } @@ -1445,14 +1445,14 @@ private void EmitHeartbeats() return; } - foreach (var (_, (sessionId, startTime, execName)) in _sessionMetadata) + foreach (var (_, (sessionId, startTime, execName, runner)) in _sessionMetadata) { telemetryService.TrackEvent(TelemetryConstants.Events.GameSessionHeartbeat, new Dictionary { [TelemetryConstants.Properties.SessionId] = sessionId, [TelemetryConstants.Properties.DurationSeconds] = (DateTime.UtcNow - startTime).TotalSeconds, [TelemetryConstants.Properties.ExecutablePath] = execName, - [TelemetryConstants.Properties.Runner] = DetectRunnerEnvironment(), + [TelemetryConstants.Properties.Runner] = runner, }); } } diff --git a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs index 3a6082dd7..51f89a558 100644 --- a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -69,8 +69,9 @@ public TelemetryLevel CurrentLevel { return _userSettingsService.Get().TelemetryPreference; } - catch + catch (Exception ex) { + _logger.LogTrace(ex, "Failed to retrieve telemetry preference from user settings"); return TelemetryLevel.Disabled; } } @@ -107,7 +108,7 @@ public void TrackEvent( string? sessionId = null; if (properties?.TryGetValue(TelemetryConstants.Properties.SessionId, out var rawSessionId) is true && rawSessionId != null) { - sessionId = rawSessionId.ToString(); + sessionId = _sanitizer.SanitizeString(rawSessionId.ToString()); } var telemetryEvent = new TelemetryEvent @@ -230,6 +231,14 @@ public async Task> FlushAsync(CancellationToken cancellati { try { + // Allow queued channel items to drain to sinks before flushing sink buffers + var spinCount = 0; + while (_channel.Reader.Count > 0 && spinCount < 20 && !cancellationToken.IsCancellationRequested) + { + await Task.Delay(25, cancellationToken); + spinCount++; + } + var tasks = _sinks.Select(sink => sink.FlushAsync(cancellationToken)); var results = await Task.WhenAll(tasks); var failures = results.Where(r => !r.Success).ToList(); @@ -258,7 +267,7 @@ public void Dispose() _disposed = true; _channel.Writer.TryComplete(); - _cts.Cancel(); + _cts.CancelAfter(TimeSpan.FromSeconds(2)); try { @@ -282,7 +291,6 @@ public async ValueTask DisposeAsync() _disposed = true; _channel.Writer.TryComplete(); - _cts.Cancel(); try { @@ -295,6 +303,7 @@ public async ValueTask DisposeAsync() // Suppress background task cancellation exceptions on shutdown } + _cts.Cancel(); _cts.Dispose(); } @@ -327,7 +336,7 @@ private async Task ProcessChannelAsync(CancellationToken cancellationToken) { while (_channel.Reader.TryRead(out var telemetryEvent)) { - if (telemetryEvent == null) + if (telemetryEvent == null || !IsEnabled(telemetryEvent.Level)) { continue; } diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs index 5edd9000d..9bbb984d2 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs @@ -28,9 +28,10 @@ public sealed class AnalyticsTelemetrySink( PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private const int MaxBufferSize = 100; private readonly ConcurrentQueue _buffer = new(); - private string? _endpointUrl; - private string? _apiKey; + private string? _endpointUrl = Environment.GetEnvironmentVariable("POSTHOG_CAPTURE_URL") ?? (Environment.GetEnvironmentVariable("POSTHOG_HOST") != null ? $"{Environment.GetEnvironmentVariable("POSTHOG_HOST")?.TrimEnd('/')}/capture/" : TelemetryConstants.DefaultPostHogCaptureEndpoint); + private string? _apiKey = Environment.GetEnvironmentVariable("POSTHOG_API_KEY") ?? Environment.GetEnvironmentVariable("GENHUB_POSTHOG_API_KEY") ?? TelemetryConstants.DefaultPostHogApiKey; /// public string Name => "Analytics"; @@ -41,7 +42,7 @@ public sealed class AnalyticsTelemetrySink( /// public string? EndpointUrl { - get => _endpointUrl ?? Environment.GetEnvironmentVariable("POSTHOG_CAPTURE_URL") ?? (Environment.GetEnvironmentVariable("POSTHOG_HOST") != null ? $"{Environment.GetEnvironmentVariable("POSTHOG_HOST")?.TrimEnd('/')}/capture/" : TelemetryConstants.DefaultPostHogCaptureEndpoint); + get => _endpointUrl; set => _endpointUrl = value; } @@ -50,7 +51,7 @@ public string? EndpointUrl /// public string? ApiKey { - get => _apiKey ?? Environment.GetEnvironmentVariable("POSTHOG_API_KEY") ?? Environment.GetEnvironmentVariable("GENHUB_POSTHOG_API_KEY") ?? TelemetryConstants.DefaultPostHogApiKey; + get => _apiKey; set => _apiKey = value; } @@ -77,12 +78,7 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey) || httpClient == null) { // Offline / unconfigured remote endpoint mode: buffer in memory - _buffer.Enqueue(telemetryEvent); - while (_buffer.Count > 100) - { - _buffer.TryDequeue(out _); - } - + EnqueueBounded(telemetryEvent); return OperationResult.CreateSuccess(true); } @@ -93,6 +89,7 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent ["$lib"] = TelemetryConstants.AppName, ["$app_version"] = telemetryEvent.AppVersion, ["$os"] = telemetryEvent.Platform, + ["$process_person_profile"] = false, }; if (!string.IsNullOrEmpty(telemetryEvent.SessionId)) @@ -119,7 +116,7 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent } logger.LogDebug("[Analytics] Endpoint returned status code {StatusCode}", response.StatusCode); - _buffer.Enqueue(telemetryEvent); + EnqueueBounded(telemetryEvent); return OperationResult.CreateFailure($"Remote endpoint returned {response.StatusCode}"); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -129,11 +126,20 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent catch (Exception ex) { logger.LogDebug(ex, "[Analytics] Failed to send telemetry event to endpoint"); - _buffer.Enqueue(telemetryEvent); + EnqueueBounded(telemetryEvent); return OperationResult.CreateFailure(ex.Message); } } + private void EnqueueBounded(TelemetryEvent telemetryEvent) + { + _buffer.Enqueue(telemetryEvent); + while (_buffer.Count > MaxBufferSize) + { + _buffer.TryDequeue(out _); + } + } + /// public async Task> FlushAsync(CancellationToken cancellationToken = default) { diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs index cd95832c6..9c630b356 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs @@ -29,8 +29,9 @@ public sealed class SentryTelemetrySink( PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private const int MaxBufferSize = 50; private readonly ConcurrentQueue _crashBuffer = new(); - private string? _dsnEndpoint; + private string? _dsnEndpoint = Environment.GetEnvironmentVariable("SENTRY_DSN") ?? Environment.GetEnvironmentVariable("GENHUB_SENTRY_DSN") ?? TelemetryConstants.DefaultSentryDsn; /// public string Name => "Sentry"; @@ -41,7 +42,7 @@ public sealed class SentryTelemetrySink( /// public string? DsnEndpoint { - get => _dsnEndpoint ?? Environment.GetEnvironmentVariable("SENTRY_DSN") ?? Environment.GetEnvironmentVariable("GENHUB_SENTRY_DSN") ?? TelemetryConstants.DefaultSentryDsn; + get => _dsnEndpoint; set => _dsnEndpoint = value; } @@ -67,12 +68,7 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent if (string.IsNullOrWhiteSpace(dsn) || httpClient == null) { // Buffer locally if unconfigured - _crashBuffer.Enqueue(telemetryEvent); - while (_crashBuffer.Count > 50) - { - _crashBuffer.TryDequeue(out _); - } - + EnqueueBounded(telemetryEvent); return OperationResult.CreateSuccess(true); } @@ -101,7 +97,7 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent } logger.LogDebug("[Sentry] Crash endpoint returned status code {StatusCode}", response.StatusCode); - _crashBuffer.Enqueue(telemetryEvent); + EnqueueBounded(telemetryEvent); return OperationResult.CreateFailure($"Crash endpoint returned {response.StatusCode}"); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -111,11 +107,20 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent catch (Exception ex) { logger.LogDebug(ex, "[Sentry] Failed to send crash report to Sentry endpoint"); - _crashBuffer.Enqueue(telemetryEvent); + EnqueueBounded(telemetryEvent); return OperationResult.CreateFailure(ex.Message); } } + private void EnqueueBounded(TelemetryEvent telemetryEvent) + { + _crashBuffer.Enqueue(telemetryEvent); + while (_crashBuffer.Count > MaxBufferSize) + { + _crashBuffer.TryDequeue(out _); + } + } + /// public async Task> FlushAsync(CancellationToken cancellationToken = default) { From 32a934d72fd140c110b82b06e69bbf54d4ee0801 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 16:10:09 +0000 Subject: [PATCH 10/12] fix(style): add threading using and reorder members to satisfy StyleCop SA1202/SA1203 --- GenHub/GenHub/App.axaml.cs | 1 + .../Telemetry/Sinks/AnalyticsTelemetrySink.cs | 21 ++++++++++--------- .../Telemetry/Sinks/SentryTelemetrySink.cs | 21 ++++++++++--------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index b12829759..bf01faa37 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs index 9bbb984d2..30fce4231 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs @@ -22,13 +22,14 @@ public sealed class AnalyticsTelemetrySink( ILogger logger, HttpClient? httpClient = null) : ITelemetrySink { + private const int MaxBufferSize = 100; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - private const int MaxBufferSize = 100; private readonly ConcurrentQueue _buffer = new(); private string? _endpointUrl = Environment.GetEnvironmentVariable("POSTHOG_CAPTURE_URL") ?? (Environment.GetEnvironmentVariable("POSTHOG_HOST") != null ? $"{Environment.GetEnvironmentVariable("POSTHOG_HOST")?.TrimEnd('/')}/capture/" : TelemetryConstants.DefaultPostHogCaptureEndpoint); private string? _apiKey = Environment.GetEnvironmentVariable("POSTHOG_API_KEY") ?? Environment.GetEnvironmentVariable("GENHUB_POSTHOG_API_KEY") ?? TelemetryConstants.DefaultPostHogApiKey; @@ -131,15 +132,6 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent } } - private void EnqueueBounded(TelemetryEvent telemetryEvent) - { - _buffer.Enqueue(telemetryEvent); - while (_buffer.Count > MaxBufferSize) - { - _buffer.TryDequeue(out _); - } - } - /// public async Task> FlushAsync(CancellationToken cancellationToken = default) { @@ -163,4 +155,13 @@ public async Task> FlushAsync(CancellationToken cancellati ? OperationResult.CreateFailure("Failed to flush some buffered events") : OperationResult.CreateSuccess(true); } + + private void EnqueueBounded(TelemetryEvent telemetryEvent) + { + _buffer.Enqueue(telemetryEvent); + while (_buffer.Count > MaxBufferSize) + { + _buffer.TryDequeue(out _); + } + } } diff --git a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs index 9c630b356..13424d35c 100644 --- a/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs +++ b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs @@ -23,13 +23,14 @@ public sealed class SentryTelemetrySink( ILogger logger, HttpClient? httpClient = null) : ITelemetrySink { + private const int MaxBufferSize = 50; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - private const int MaxBufferSize = 50; private readonly ConcurrentQueue _crashBuffer = new(); private string? _dsnEndpoint = Environment.GetEnvironmentVariable("SENTRY_DSN") ?? Environment.GetEnvironmentVariable("GENHUB_SENTRY_DSN") ?? TelemetryConstants.DefaultSentryDsn; @@ -112,15 +113,6 @@ public async Task> EmitAsync(TelemetryEvent telemetryEvent } } - private void EnqueueBounded(TelemetryEvent telemetryEvent) - { - _crashBuffer.Enqueue(telemetryEvent); - while (_crashBuffer.Count > MaxBufferSize) - { - _crashBuffer.TryDequeue(out _); - } - } - /// public async Task> FlushAsync(CancellationToken cancellationToken = default) { @@ -145,6 +137,15 @@ public async Task> FlushAsync(CancellationToken cancellati : OperationResult.CreateSuccess(true); } + private void EnqueueBounded(TelemetryEvent telemetryEvent) + { + _crashBuffer.Enqueue(telemetryEvent); + while (_crashBuffer.Count > MaxBufferSize) + { + _crashBuffer.TryDequeue(out _); + } + } + private static Dictionary BuildSentryPayload(TelemetryEvent telemetryEvent) { var extra = new Dictionary(telemetryEvent.Properties ?? new Dictionary()); From 0950a12a0993a1d1406c8cb998fcae9545a26266 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 16:16:42 +0000 Subject: [PATCH 11/12] fix(telemetry): synchronize heartbeat timer initialization and observe installation id save task --- .../Infrastructure/GameProcessManager.cs | 6 +++++- .../Telemetry/Services/TelemetryService.cs | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 4d10b1e84..b371a7488 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -1425,7 +1425,11 @@ private void RegisterSessionAndEmitStarted(Process process, string executableNam if (_heartbeatTimer == null) { var interval = TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes); - _heartbeatTimer = new Timer(_ => EmitHeartbeats(), null, interval, interval); + var newTimer = new Timer(_ => EmitHeartbeats(), null, interval, interval); + if (Interlocked.CompareExchange(ref _heartbeatTimer, newTimer, null) != null) + { + newTimer.Dispose(); + } } telemetryService.TrackEvent(TelemetryConstants.Events.GameSessionStarted, new Dictionary diff --git a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs index 51f89a558..56575348e 100644 --- a/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -30,6 +30,7 @@ public sealed class TelemetryService : ITelemetryService, IAsyncDisposable, IDis private readonly ConcurrentQueue _breadcrumbs = new(); private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; + private Task? _installationIdSaveTask; private bool _disposed; /// @@ -239,6 +240,18 @@ public async Task> FlushAsync(CancellationToken cancellati spinCount++; } + if (_installationIdSaveTask is { IsCompleted: false } saveTask) + { + try + { + await saveTask.WaitAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Error while awaiting installation ID persistence"); + } + } + var tasks = _sinks.Select(sink => sink.FlushAsync(cancellationToken)); var results = await Task.WhenAll(tasks); var failures = results.Where(r => !r.Success).ToList(); @@ -319,7 +332,7 @@ private string GetOrCreateInstallationId() var newId = Guid.NewGuid().ToString("N"); _userSettingsService.Update(s => s.AnonymousInstallationId = newId); - _ = _userSettingsService.SaveAsync(CancellationToken.None); + _installationIdSaveTask = _userSettingsService.SaveAsync(CancellationToken.None); return newId; } catch From 49ba3ceb4dc0f1b62e70b7c3b2a20c1ded6d40e8 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 20 Aug 2026 17:49:08 +0000 Subject: [PATCH 12/12] feat(telemetry): enrich download and process metadata and add settings UI controls Add ContentName, ContentId, PublisherId, ContentType, and Strategy metadata to telemetry events, pass environment variables during child adoption, and expose the Diagnostics & Privacy settings card in SettingsView. --- .../Constants/TelemetryConstants.cs | 12 +++++++++ .../Models/Common/DownloadConfiguration.cs | 12 +++++++++ .../Telemetry/TelemetryConstantsTests.cs | 4 +++ .../GenHub/Common/Services/DownloadService.cs | 26 +++++++++++++++++-- .../Infrastructure/GameProcessManager.cs | 2 +- .../Settings/Views/SettingsView.axaml | 25 ++++++++++++++++++ docs/dev/constants.md | 4 +++ 7 files changed, 82 insertions(+), 3 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/TelemetryConstants.cs b/GenHub/GenHub.Core/Constants/TelemetryConstants.cs index cb2fa24ce..f4dd9a83a 100644 --- a/GenHub/GenHub.Core/Constants/TelemetryConstants.cs +++ b/GenHub/GenHub.Core/Constants/TelemetryConstants.cs @@ -148,6 +148,18 @@ public static class Properties /// Content type (e.g. Mod, Patch, Map). public const string ContentType = "content_type"; + /// Content identifier. + public const string ContentId = "content_id"; + + /// Content name or display title. + public const string ContentName = "content_name"; + + /// Publisher identifier. + public const string PublisherId = "publisher_id"; + + /// Reconciliation strategy name. + public const string Strategy = "strategy"; + /// Size in megabytes. public const string SizeMb = "size_mb"; diff --git a/GenHub/GenHub.Core/Models/Common/DownloadConfiguration.cs b/GenHub/GenHub.Core/Models/Common/DownloadConfiguration.cs index e905c4e58..f5c28ba45 100644 --- a/GenHub/GenHub.Core/Models/Common/DownloadConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Common/DownloadConfiguration.cs @@ -65,4 +65,16 @@ public DownloadConfiguration() /// Gets or sets the delay between retry attempts. public TimeSpan RetryDelay { get; set; } + + /// Gets or sets the display name or title of the content being downloaded. + public string? ContentName { get; set; } + + /// Gets or sets the unique identifier of the content being downloaded. + public string? ContentId { get; set; } + + /// Gets or sets the publisher identifier. + public string? PublisherId { get; set; } + + /// Gets or sets the content type (e.g. Mod, Map, Patch, Addon). + public string? ContentType { get; set; } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs index 591c77317..931137c6f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs @@ -71,6 +71,10 @@ public void PropertyKeys_AreDistinctAndNonEmpty() TelemetryConstants.Properties.Resolution, TelemetryConstants.Properties.ManifestId, TelemetryConstants.Properties.ContentType, + TelemetryConstants.Properties.ContentId, + TelemetryConstants.Properties.ContentName, + TelemetryConstants.Properties.PublisherId, + TelemetryConstants.Properties.Strategy, TelemetryConstants.Properties.SizeMb, TelemetryConstants.Properties.SpeedMbps, TelemetryConstants.Properties.SourceProvider, diff --git a/GenHub/GenHub/Common/Services/DownloadService.cs b/GenHub/GenHub/Common/Services/DownloadService.cs index ceff9c511..b47ccec73 100644 --- a/GenHub/GenHub/Common/Services/DownloadService.cs +++ b/GenHub/GenHub/Common/Services/DownloadService.cs @@ -188,13 +188,35 @@ private async Task PerformDownloadAsync( var sizeMb = downloadedBytes / (1024.0 * 1024.0); var speedMbps = totalElapsedSeconds > 0 ? (sizeMb * 8.0) / totalElapsedSeconds : 0.0; - telemetryService?.TrackEvent(TelemetryConstants.Events.ContentDownloadCompleted, new Dictionary + var downloadProperties = new Dictionary { [TelemetryConstants.Properties.SizeMb] = Math.Round(sizeMb, 2), [TelemetryConstants.Properties.DurationSeconds] = Math.Round(totalElapsedSeconds, 2), [TelemetryConstants.Properties.SpeedMbps] = Math.Round(speedMbps, 2), [TelemetryConstants.Properties.SourceProvider] = configuration.Url.Host, - }); + }; + + if (!string.IsNullOrWhiteSpace(configuration.ContentName)) + { + downloadProperties[TelemetryConstants.Properties.ContentName] = configuration.ContentName; + } + + if (!string.IsNullOrWhiteSpace(configuration.ContentId)) + { + downloadProperties[TelemetryConstants.Properties.ContentId] = configuration.ContentId; + } + + if (!string.IsNullOrWhiteSpace(configuration.PublisherId)) + { + downloadProperties[TelemetryConstants.Properties.PublisherId] = configuration.PublisherId; + } + + if (!string.IsNullOrWhiteSpace(configuration.ContentType)) + { + downloadProperties[TelemetryConstants.Properties.ContentType] = configuration.ContentType; + } + + telemetryService?.TrackEvent(TelemetryConstants.Events.ContentDownloadCompleted, downloadProperties); return DownloadResult.CreateSuccess(configuration.DestinationPath, downloadedBytes, stopwatch.Elapsed, hashVerified); } diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index b371a7488..9cb9f5c7e 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -1035,7 +1035,7 @@ private async Task> AdoptExpectedChildProcessAs if (child != null) { _managedProcesses[child.Id] = child; - RegisterSessionAndEmitStarted(child, expectedName); + RegisterSessionAndEmitStarted(child, expectedName, configuration.EnvironmentVariables); try { diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml index 7a022331b..e8b766104 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml @@ -476,6 +476,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/constants.md b/docs/dev/constants.md index 0c3b33a86..e4009a84d 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -1611,6 +1611,10 @@ Constants for telemetry event names, properties, data scrubbing masks, and queue - `CasReconcileCompleted`: `"cas_reconcile_completed"` - Emitted when CAS workspace reconciliation completes. - `AppCrash`: `"app_unhandled_crash"` - Emitted when an unhandled application exception or crash occurs. +### Telemetry Properties (`TelemetryConstants.Properties`) + +Common property keys attached to telemetry payloads: `SessionId`, `GameType`, `ProfileId`, `ProfileName`, `DurationSeconds`, `ExitCode`, `Platform`, `Runner`, `Resolution`, `ManifestId`, `ContentType`, `ContentId`, `ContentName`, `PublisherId`, `Strategy`, `SizeMb`, `SpeedMbps`, `SourceProvider`, `RetryCount`, `FromVersion`, `ToVersion`, `Channel`, `RestartDurationMs`, `CacheHitRate`, `FileCount`, `BytesReconciled`, `ExceptionType`, `ExceptionMessage`, `StackTrace`, `IsFatal`, `Context`, `InstallationId`, `AppVersion`, `ExecutablePath`. + --- ## Related Documentation