diff --git a/GenHub/GenHub.Core/Constants/TelemetryConstants.cs b/GenHub/GenHub.Core/Constants/TelemetryConstants.cs new file mode 100644 index 000000000..f4dd9a83a --- /dev/null +++ b/GenHub/GenHub.Core/Constants/TelemetryConstants.cs @@ -0,0 +1,220 @@ +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"; + + /// 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"; + + /// 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/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.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..eb84daddc --- /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(@"(?i)(? + /// 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 Wine prefix paths + result = WinePrefixRegex().Replace(result, TelemetryConstants.WinePrefixMask); + + // 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 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 System.Collections.IEnumerable enumerable and not string) + { + var sanitizedList = new List(); + foreach (var item in enumerable) + { + sanitizedList.Add(SanitizeValue(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..e78012561 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/AnalyticsTelemetrySinkTests.cs @@ -0,0 +1,209 @@ +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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccessAsync() + { + 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_WhenHttpClientProvided_SendsPostHogFormattedPayloadAsync() + { + 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()); + Assert.False(properties.GetProperty("$process_person_profile").GetBoolean()); + } + + /// + /// Verifies EmitAsync buffers and returns failure when endpoint returns error. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailureAsync() + { + 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task FlushAsync_FlushesBufferedEventsSuccessfullyAsync() + { + 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 + { + 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..53e86dde4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/LoggingTelemetrySinkTests.cs @@ -0,0 +1,82 @@ +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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_StandardEvent_ReturnsSuccessAsync() + { + 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_CrashEvent_ReturnsSuccessAsync() + { + 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..0f7497add --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/SentryTelemetrySinkTests.cs @@ -0,0 +1,211 @@ +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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_WhenNoHttpClient_BuffersAndReturnsSuccessAsync() + { + 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_WhenHttpClientProvided_SendsSentryStorePayloadWithAuthHeaderAsync() + { + 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) + { + DsnEndpoint = "https://testkey@sentry.example.com/1234", + }; + + 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/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=testkey", 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task EmitAsync_WhenEndpointReturnsError_BuffersAndReturnsFailureAsync() + { + 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task FlushAsync_FlushesBufferedEventsSuccessfullyAsync() + { + var sendCount = 0; + var returnError = true; + var handler = new TestHandler(_ => + { + Interlocked.Increment(ref sendCount); + 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 + { + 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..931137c6f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryConstantsTests.cs @@ -0,0 +1,106 @@ +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.ContentId, + TelemetryConstants.Properties.ContentName, + TelemetryConstants.Properties.PublisherId, + TelemetryConstants.Properties.Strategy, + 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..6bd2ce4b0 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetrySanitizerTests.cs @@ -0,0 +1,139 @@ +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, 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); + } + + /// + /// 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 and object collections are recursively sanitized. + /// + [Fact] + 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", + }, + }; + + 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 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()); + 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..d8672fd2d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Telemetry/TelemetryServiceTests.cs @@ -0,0 +1,185 @@ +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 readonly UserSettings _settings = new() + { + TelemetryPreference = TelemetryLevel.AnonymousMetrics, + AnonymousInstallationId = "test-installation-guid", + }; + + /// + /// Initializes a new instance of the class. + /// + public TelemetryServiceTests() + { + _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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task TrackEvent_WhenDisabled_DoesNotEmitAsync() + { + _settings.TelemetryPreference = TelemetryLevel.Disabled; + + await using var service = new TelemetryService( + _mockLogger.Object, + _sanitizer, + _mockUserSettingsService.Object, + [_mockSink.Object]); + + service.TrackEvent(TelemetryConstants.Events.GameSessionStarted); + + await service.FlushAsync(); + + _mockSink.Verify(s => s.EmitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that TrackEvent emits when telemetry is AnonymousMetrics. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task TrackEvent_WhenAnonymousMetrics_EmitsToSinkAsync() + { + _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", + }); + + await service.FlushAsync(); + + _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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task TrackException_RecordsSanitizedCrashEventAsync() + { + _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 service.FlushAsync(); + + _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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task AddBreadcrumb_CappedAtMaxCountAsync() + { + 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. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task FlushAsync_CallsFlushOnAllSinksAsync() + { + 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..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; @@ -12,6 +13,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 +29,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 +41,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 +60,29 @@ 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); + try + { + using var crashFlushCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + _telemetryService?.FlushAsync(crashFlushCts.Token).GetAwaiter().GetResult(); + } + catch + { + // Suppress crash flush failures during terminal exception + } + } + }; + + 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 +190,19 @@ private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArg } finally { + if (_telemetryService != null) + { + try + { + using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _telemetryService.FlushAsync(flushCts.Token); + } + 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..b47ccec73 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,40 @@ private async Task PerformDownloadAsync( } } + var totalElapsedSeconds = stopwatch.Elapsed.TotalSeconds; + var sizeMb = downloadedBytes / (1024.0 * 1024.0); + var speedMbps = totalElapsedSeconds > 0 ? (sizeMb * 8.0) / totalElapsedSeconds : 0.0; + + 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/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 5f1d9f05d..b25c295da 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); @@ -154,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) { @@ -166,6 +174,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] = TelemetryChannel, + [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, + }); + try { var uri = new Uri(AppConstants.GitHubRepositoryUrl); @@ -329,6 +344,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] = TelemetryChannel, + [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..9cb9f5c7e 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,11 @@ public class GameProcessManager( TimeSpan.FromMilliseconds(CleanupIntervalMs), TimeSpan.FromMilliseconds(CleanupIntervalMs)); + /// + /// Periodic timer to send anonymous heartbeats for active game sessions. + /// + private Timer? _heartbeatTimer; + private bool _disposed; /// @@ -107,6 +116,7 @@ public async Task> StartProcessAsync(GameLaunch } _managedProcesses[process.Id] = process; + RegisterSessionAndEmitStarted(process, configuration.ExecutablePath, configuration.EnvironmentVariables); if (configuration.WaitForExit) { @@ -361,6 +371,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 { @@ -397,6 +408,7 @@ public async Task> DiscoverAndTrackProcessAsync // Track it _managedProcesses[process.Id] = process; + RegisterSessionAndEmitStarted(process, processName); try { @@ -477,8 +489,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) @@ -573,6 +586,41 @@ private static bool HasExecutePermission(string path) } } + private static string DetectRunnerEnvironment(IReadOnlyDictionary? envVars = null) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Native"; + } + + if (envVars?.TryGetValue("PROTON_VERSION", out var configProton) is true && !string.IsNullOrWhiteSpace(configProton)) + { + return $"Proton-{configProton}"; + } + + if (Environment.GetEnvironmentVariable("PROTON_VERSION") is { Length: > 0 } proton) + { + return $"Proton-{proton}"; + } + + if (envVars?.ContainsKey("WINEPREFIX") is true || 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. /// @@ -827,6 +875,7 @@ private OperationResult HandleImmediateProcessExit( process.Dispose(); _managedProcesses[spawnedProcess.Id] = spawnedProcess; + RegisterSessionAndEmitStarted(spawnedProcess, configuration.ExecutablePath, configuration.EnvironmentVariables); try { @@ -896,6 +945,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] = sessionMeta.Runner, + }); + } + // Raise the event var args = new GameProcessExitedEventArgs { @@ -973,6 +1035,7 @@ private async Task> AdoptExpectedChildProcessAs if (child != null) { _managedProcesses[child.Id] = child; + RegisterSessionAndEmitStarted(child, expectedName, configuration.EnvironmentVariables); try { @@ -1350,13 +1413,59 @@ private string AppendLauncherErrors(string message, Process launcher, BoundedErr return string.IsNullOrWhiteSpace(detail) ? message : $"{message} {detail}"; } + private void RegisterSessionAndEmitStarted(Process process, string executableName, IReadOnlyDictionary? envVars = null) + { + var sessionId = Guid.NewGuid().ToString("N"); + var execName = Path.GetFileName(executableName); + var runner = DetectRunnerEnvironment(envVars); + _sessionMetadata[process.Id] = (sessionId, DateTime.UtcNow, execName, runner); + + if (telemetryService != null) + { + if (_heartbeatTimer == null) + { + var interval = TimeSpan.FromMinutes(TelemetryConstants.SessionHeartbeatIntervalMinutes); + 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 + { + [TelemetryConstants.Properties.SessionId] = sessionId, + [TelemetryConstants.Properties.ExecutablePath] = execName, + [TelemetryConstants.Properties.Platform] = RuntimeInformation.OSDescription, + [TelemetryConstants.Properties.Runner] = runner, + }); + } + } + + private void EmitHeartbeats() + { + if (_disposed || telemetryService == null || _sessionMetadata.IsEmpty) + { + return; + } + + 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] = runner, + }); + } + } + /// - /// 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/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/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs new file mode 100644 index 000000000..56575348e --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Services/TelemetryService.cs @@ -0,0 +1,385 @@ +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 Task? _installationIdSaveTask; + 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 (Exception ex) + { + _logger.LogTrace(ex, "Failed to retrieve telemetry preference from user settings"); + 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?.TryGetValue(TelemetryConstants.Properties.SessionId, out var rawSessionId) is true && rawSessionId != null) + { + sessionId = _sanitizer.SanitizeString(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 + { + // 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++; + } + + 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(); + if (failures.Count > 0) + { + var errors = string.Join("; ", failures.Select(r => r.FirstError ?? "Sink flush failed")); + return OperationResult.CreateFailure(errors); + } + + 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.CancelAfter(TimeSpan.FromSeconds(2)); + + 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(); + + 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.Cancel(); + _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); + _installationIdSaveTask = _userSettingsService.SaveAsync(CancellationToken.None); + 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 || !IsEnabled(telemetryEvent.Level)) + { + 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..30fce4231 --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Sinks/AnalyticsTelemetrySink.cs @@ -0,0 +1,167 @@ +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 const int MaxBufferSize = 100; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + 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; + + /// + 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; + set => _endpointUrl = value; + } + + /// + /// Gets or sets the analytics project API token / key (e.g. PostHog project token). + /// + public string? ApiKey + { + get => _apiKey; + 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 + EnqueueBounded(telemetryEvent); + return OperationResult.CreateSuccess(true); + } + + try + { + var postHogProperties = new Dictionary(telemetryEvent.Properties ?? new Dictionary()) + { + ["$lib"] = TelemetryConstants.AppName, + ["$app_version"] = telemetryEvent.AppVersion, + ["$os"] = telemetryEvent.Platform, + ["$process_person_profile"] = false, + }; + + 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); + EnqueueBounded(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"); + EnqueueBounded(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); + } + + private void EnqueueBounded(TelemetryEvent telemetryEvent) + { + _buffer.Enqueue(telemetryEvent); + while (_buffer.Count > MaxBufferSize) + { + _buffer.TryDequeue(out _); + } + } +} 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..13424d35c --- /dev/null +++ b/GenHub/GenHub/Features/Telemetry/Sinks/SentryTelemetrySink.cs @@ -0,0 +1,248 @@ +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 const int MaxBufferSize = 50; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ConcurrentQueue _crashBuffer = new(); + private string? _dsnEndpoint = Environment.GetEnvironmentVariable("SENTRY_DSN") ?? Environment.GetEnvironmentVariable("GENHUB_SENTRY_DSN") ?? TelemetryConstants.DefaultSentryDsn; + + /// + 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; + 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 + EnqueueBounded(telemetryEvent); + return OperationResult.CreateSuccess(true); + } + + try + { + var (storeUrl, publicKey) = ParseDsn(dsn); + var payload = BuildSentryPayload(telemetryEvent); + + 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); + EnqueueBounded(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"); + EnqueueBounded(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 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()); + + 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 ?? string.Empty, + ["environment"] = AppConstants.BuildChannel, + ["tags"] = new Dictionary + { + ["os"] = telemetryEvent.Platform ?? string.Empty, + ["arch"] = RuntimeInformation.ProcessArchitecture.ToString(), + }, + ["user"] = new Dictionary + { + ["id"] = telemetryEvent.InstallationId ?? string.Empty, + }, + ["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)) + { + 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..cb780738c --- /dev/null +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/TelemetryModule.cs @@ -0,0 +1,52 @@ +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..e4009a84d 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -1578,14 +1578,42 @@ 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. + +### 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`. ---