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