diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index c2016d7f70..41d08bc7db 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -25,7 +25,6 @@ using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Core.Validation; -using Exceptionless.Serializer; using FluentValidation; using Foundatio.Caching; using Foundatio.Extensions.Hosting.Jobs; @@ -54,27 +53,7 @@ public class Bootstrapper { public static void RegisterServices(IServiceCollection services, AppOptions appOptions) { - // PERF: Work towards getting rid of JSON.NET. - Newtonsoft.Json.JsonConvert.DefaultSettings = () => new Newtonsoft.Json.JsonSerializerSettings - { - DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset - }; - - services.AddSingleton(_ => GetJsonContractResolver()); - services.AddSingleton(s => - { - // NOTE: These settings may need to be synced in the Elastic Configuration. - var settings = new Newtonsoft.Json.JsonSerializerSettings - { - MissingMemberHandling = Newtonsoft.Json.MissingMemberHandling.Ignore, - DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset, - ContractResolver = s.GetRequiredService() - }; - - settings.AddModelConverters(s.GetRequiredService>()); - return settings; - }); - + // Register System.Text.Json options with Exceptionless defaults (snake_case, null handling) services.AddSingleton(_ => new JsonSerializerOptions().ConfigureExceptionlessDefaults()); services.AddSingleton(s => s.GetRequiredService()); @@ -293,13 +272,6 @@ public static void AddHostedJobs(IServiceCollection services, ILoggerFactory log logger.LogWarning("Jobs running in process"); } - public static DynamicTypeContractResolver GetJsonContractResolver() - { - var resolver = new DynamicTypeContractResolver(new LowerCaseUnderscorePropertyNamesContractResolver()); - resolver.UseDefaultResolverFor(typeof(DataDictionary), typeof(SettingsDictionary), typeof(VersionOnePlugin.VersionOneWebHookStack), typeof(VersionOnePlugin.VersionOneWebHookEvent)); - return resolver; - } - private static IQueue CreateQueue(IServiceProvider container, TimeSpan? workItemTimeout = null) where T : class { var loggerFactory = container.GetRequiredService(); diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index 3db2f58214..0479212657 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -24,9 +24,7 @@ - - diff --git a/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs b/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs index 6ebf49a9e8..7b3df3cdd4 100644 --- a/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs +++ b/src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs @@ -1,18 +1,27 @@ using System.Text.Json; using System.Text.Json.Nodes; using Exceptionless.Core.Models; +using Foundatio.Serializer; namespace Exceptionless.Core.Extensions; public static class DataDictionaryExtensions { + /// + /// Options for deserializing JsonElement values that may use PascalCase or snake_case + /// property names. Uses case-insensitive matching without a naming policy so both formats work. + /// + private static readonly JsonSerializerOptions CaseInsensitiveOptions = new() + { + PropertyNameCaseInsensitive = true + }; /// /// Retrieves a typed value from the , deserializing if necessary. /// /// The target type to deserialize to. /// The data dictionary containing the value. /// The key of the value to retrieve. - /// The JSON serializer options to use for deserialization. + /// The text serializer to use for deserialization. /// The deserialized value, or default if deserialization fails. /// Thrown when the key is not found in the dictionary. /// @@ -20,16 +29,16 @@ public static class DataDictionaryExtensions /// /// Direct type match - returns value directly /// - extracts root element and deserializes - /// - deserializes using provided options - /// - deserializes using provided options - /// - re-serializes to JSON then deserializes (for ObjectToInferredTypesConverter output) - /// of objects - re-serializes to JSON then deserializes - /// - uses ToObject for Elasticsearch compatibility (data read from Elasticsearch uses JSON.NET) - /// JSON string - parses and deserializes + /// - extracts raw JSON and deserializes via ITextSerializer + /// - extracts JSON string and deserializes via ITextSerializer + /// - re-serializes to JSON then deserializes via ITextSerializer + /// of objects - re-serializes to JSON then deserializes via ITextSerializer + /// - uses ToObject for Elasticsearch compatibility + /// JSON string - deserializes via ITextSerializer /// Fallback - attempts type conversion via ToType /// /// - public static T? GetValue(this DataDictionary extendedData, string key, JsonSerializerOptions options) + public static T? GetValue(this DataDictionary extendedData, string key, ITextSerializer serializer) { if (!extendedData.TryGetValue(key, out object? data)) throw new KeyNotFoundException($"Key \"{key}\" not found in the dictionary."); @@ -42,10 +51,37 @@ public static class DataDictionaryExtensions data = jsonDocument.RootElement; // JsonElement (from STJ deserialization when ObjectToInferredTypesConverter wasn't used) - if (data is JsonElement jsonElement && - TryDeserialize(jsonElement, options, out T? jsonElementResult)) + if (data is JsonElement jsonElement) { - return jsonElementResult; + try + { + // Fast-path for string type + if (typeof(T) == typeof(string)) + { + object? s = jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString(), + JsonValueKind.Number => jsonElement.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => jsonElement.GetRawText() + }; + + return (T?)s; + } + + // Deserialize directly from JsonElement using case-insensitive matching. + // This handles both snake_case (from Elasticsearch) and PascalCase (from + // [JsonExtensionData] which preserves original property names). + var result = jsonElement.Deserialize(CaseInsensitiveOptions); + if (result is not null) + return result; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + // Ignored - fall through to next handler + } } // JsonNode (JsonObject/JsonArray/JsonValue) @@ -53,26 +89,30 @@ public static class DataDictionaryExtensions { try { - var result = jsonNode.Deserialize(options); + string jsonString = jsonNode.ToJsonString(); + var result = serializer.Deserialize(jsonString); if (result is not null) return result; } - catch + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) { // Ignored - fall through to next handler } } // Dictionary from ObjectToInferredTypesConverter - // Re-serialize to JSON then deserialize to target type with proper naming policy + // Re-serialize to JSON then deserialize to target type via ITextSerializer if (data is Dictionary dictionary) { try { - string dictJson = JsonSerializer.Serialize(dictionary, options); - var result = JsonSerializer.Deserialize(dictJson, options); - if (result is not null) - return result; + string? dictJson = serializer.SerializeToString(dictionary); + if (dictJson is not null) + { + var result = serializer.Deserialize(dictJson); + if (result is not null) + return result; + } } catch { @@ -85,10 +125,13 @@ public static class DataDictionaryExtensions { try { - string listJson = JsonSerializer.Serialize(list, options); - var result = JsonSerializer.Deserialize(listJson, options); - if (result is not null) - return result; + string? listJson = serializer.SerializeToString(list); + if (listJson is not null) + { + var result = serializer.Deserialize(listJson); + if (result is not null) + return result; + } } catch { @@ -111,12 +154,12 @@ public static class DataDictionaryExtensions } } - // JSON string + // JSON string - deserialize via ITextSerializer if (data is string json && json.IsJson()) { try { - var result = JsonSerializer.Deserialize(json, options); + var result = serializer.Deserialize(json); if (result is not null) return result; } @@ -142,49 +185,9 @@ public static class DataDictionaryExtensions return default; } - private static bool TryDeserialize(JsonElement element, JsonSerializerOptions options, out T? result) - { - result = default; - - try - { - // Fast-path for common primitives where the element isn't an object/array - // (Deserialize also works for these, but this avoids some edge cases and allocations) - if (typeof(T) == typeof(string)) - { - object? s = element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - JsonValueKind.Null => null, - _ => element.GetRawText() - }; - - result = (T?)s; - return true; - } - - // General case - var deserialized = element.Deserialize(options); - if (deserialized is not null) - { - result = deserialized; - return true; - } - } - catch - { - // Ignored - } - - return false; - } - public static void RemoveSensitiveData(this DataDictionary extendedData) { - string[] removeKeys = extendedData.Keys.Where(k => k.StartsWith('-')).ToArray(); + string[] removeKeys = [.. extendedData.Keys.Where(k => k.StartsWith('-'))]; foreach (string key in removeKeys) extendedData.Remove(key); } diff --git a/src/Exceptionless.Core/Extensions/ErrorExtensions.cs b/src/Exceptionless.Core/Extensions/ErrorExtensions.cs index 634f1fa8fc..48ff51704d 100644 --- a/src/Exceptionless.Core/Extensions/ErrorExtensions.cs +++ b/src/Exceptionless.Core/Extensions/ErrorExtensions.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; +using Foundatio.Serializer; namespace Exceptionless.Core.Extensions; @@ -59,9 +59,9 @@ public static StackingTarget GetStackingTarget(this Error error) }; } - public static StackingTarget? GetStackingTarget(this Event ev, JsonSerializerOptions options) + public static StackingTarget? GetStackingTarget(this Event ev, ITextSerializer serializer) { - var error = ev.GetError(options); + var error = ev.GetError(serializer); return error?.GetStackingTarget(); } diff --git a/src/Exceptionless.Core/Extensions/EventExtensions.cs b/src/Exceptionless.Core/Extensions/EventExtensions.cs index 0797660f87..261b8a0262 100644 --- a/src/Exceptionless.Core/Extensions/EventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/EventExtensions.cs @@ -1,9 +1,8 @@ using System.Text; -using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; -using Newtonsoft.Json; +using Foundatio.Serializer; namespace Exceptionless; @@ -14,14 +13,14 @@ public static bool HasError(this Event ev) return ev.Data is not null && ev.Data.ContainsKey(Event.KnownDataKeys.Error); } - public static Error? GetError(this Event ev, JsonSerializerOptions options) + public static Error? GetError(this Event ev, ITextSerializer serializer) { if (!ev.HasError()) return null; try { - return ev.Data!.GetValue(Event.KnownDataKeys.Error, options); + return ev.Data!.GetValue(Event.KnownDataKeys.Error, serializer); } catch (Exception) { @@ -36,14 +35,14 @@ public static bool HasSimpleError(this Event ev) return ev.Data is not null && ev.Data.ContainsKey(Event.KnownDataKeys.SimpleError); } - public static SimpleError? GetSimpleError(this Event ev, JsonSerializerOptions options) + public static SimpleError? GetSimpleError(this Event ev, ITextSerializer serializer) { if (!ev.HasSimpleError()) return null; try { - return ev.Data!.GetValue(Event.KnownDataKeys.SimpleError, options); + return ev.Data!.GetValue(Event.KnownDataKeys.SimpleError, serializer); } catch (Exception) { @@ -53,14 +52,14 @@ public static bool HasSimpleError(this Event ev) return null; } - public static RequestInfo? GetRequestInfo(this Event ev, JsonSerializerOptions options) + public static RequestInfo? GetRequestInfo(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.RequestInfo)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.RequestInfo, options); + return ev.Data.GetValue(Event.KnownDataKeys.RequestInfo, serializer); } catch (Exception) { @@ -70,14 +69,14 @@ public static bool HasSimpleError(this Event ev) return null; } - public static EnvironmentInfo? GetEnvironmentInfo(this Event ev, JsonSerializerOptions options) + public static EnvironmentInfo? GetEnvironmentInfo(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.EnvironmentInfo)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.EnvironmentInfo, options); + return ev.Data.GetValue(Event.KnownDataKeys.EnvironmentInfo, serializer); } catch (Exception) { @@ -183,14 +182,14 @@ public static void AddRequestInfo(this Event ev, RequestInfo request) /// /// Gets the user info object from extended data. /// - public static UserInfo? GetUserIdentity(this Event ev, JsonSerializerOptions options) + public static UserInfo? GetUserIdentity(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.UserInfo)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.UserInfo, options); + return ev.Data.GetValue(Event.KnownDataKeys.UserInfo, serializer); } catch (Exception) { @@ -219,14 +218,14 @@ public static void SetVersion(this Event ev, string? version) ev.Data[Event.KnownDataKeys.Version] = version.Trim(); } - public static SubmissionClient? GetSubmissionClient(this Event ev, JsonSerializerOptions options) + public static SubmissionClient? GetSubmissionClient(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.SubmissionClient)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.SubmissionClient, options); + return ev.Data.GetValue(Event.KnownDataKeys.SubmissionClient, serializer); } catch (Exception) { @@ -241,14 +240,14 @@ public static bool HasLocation(this Event ev) return ev.Data != null && ev.Data.ContainsKey(Event.KnownDataKeys.Location); } - public static Location? GetLocation(this Event ev, JsonSerializerOptions options) + public static Location? GetLocation(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.Location)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.Location, options); + return ev.Data.GetValue(Event.KnownDataKeys.Location, serializer); } catch (Exception) { @@ -301,14 +300,14 @@ public static void SetEnvironmentInfo(this Event ev, EnvironmentInfo? environmen /// /// Gets the stacking info from extended data. /// - public static ManualStackingInfo? GetManualStackingInfo(this Event ev, JsonSerializerOptions options) + public static ManualStackingInfo? GetManualStackingInfo(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.ManualStackingInfo)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.ManualStackingInfo, options); + return ev.Data.GetValue(Event.KnownDataKeys.ManualStackingInfo, serializer); } catch (Exception) { @@ -423,14 +422,14 @@ public static void RemoveUserIdentity(this Event ev) /// /// Gets the user description from extended data. /// - public static UserDescription? GetUserDescription(this Event ev, JsonSerializerOptions options) + public static UserDescription? GetUserDescription(this Event ev, ITextSerializer serializer) { if (ev.Data is null || !ev.Data.ContainsKey(Event.KnownDataKeys.UserDescription)) return null; try { - return ev.Data.GetValue(Event.KnownDataKeys.UserDescription, options); + return ev.Data.GetValue(Event.KnownDataKeys.UserDescription, serializer); } catch (Exception) { @@ -469,8 +468,11 @@ public static void SetUserDescription(this Event ev, UserDescription description ev.Data[Event.KnownDataKeys.UserDescription] = description; } - public static byte[] GetBytes(this Event ev, JsonSerializerSettings settings) + /// + /// Serializes an event to UTF-8 JSON bytes using the specified serializer. + /// + public static byte[] GetBytes(this Event ev, ITextSerializer serializer) { - return Encoding.UTF8.GetBytes(ev.ToJson(Formatting.None, settings)); + return serializer.SerializeToBytes(ev); } } diff --git a/src/Exceptionless.Core/Extensions/JsonExtensions.cs b/src/Exceptionless.Core/Extensions/JsonExtensions.cs index 7c3e95e905..f6837f37e6 100644 --- a/src/Exceptionless.Core/Extensions/JsonExtensions.cs +++ b/src/Exceptionless.Core/Extensions/JsonExtensions.cs @@ -1,155 +1,24 @@ -using System.Collections; -using System.Collections.Concurrent; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.Data; -using Exceptionless.Core.Reflection; -using Exceptionless.Serializer; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; +using System.Text.Json; namespace Exceptionless.Core.Extensions; -[System.Runtime.InteropServices.GuidAttribute("4186FC77-AF28-4D51-AAC3-49055DD855A4")] +/// +/// Extension methods for JSON operations using System.Text.Json. +/// For JsonNode/JsonObject operations, see . +/// public static class JsonExtensions { - public static bool IsNullOrEmpty(this JToken target) - { - if (target is null || target.Type == JTokenType.Null) - return true; - - if (target.Type == JTokenType.Object || target.Type == JTokenType.Array) - return !target.HasValues; - - if (target.Type != JTokenType.Property) - return false; - - var value = ((JProperty)target).Value; - if (value.Type == JTokenType.String) - return value.ToString().IsNullOrEmpty(); - - return IsNullOrEmpty(value); - } - - public static bool IsPropertyNullOrEmpty(this JObject target, string name) - { - var property = target.Property(name); - if (property is null) - return true; - - return property.Value.IsNullOrEmpty(); - } - - public static bool RemoveIfNullOrEmpty(this JObject target, string name) - { - if (!target.IsPropertyNullOrEmpty(name)) - return false; - - target.Remove(name); - return true; - } - - public static void RemoveAll(this JObject target, params string[] names) - { - foreach (string name in names) - target.Remove(name); - } - - - public static bool RemoveAllIfNullOrEmpty(this JObject target, params string[] names) - { - if (target.IsNullOrEmpty()) - return false; - - var properties = target.Descendants().OfType().Where(t => names.Contains(t.Name) && t.IsNullOrEmpty()).ToList(); - foreach (var p in properties) - p.Remove(); - - return true; - } - - public static bool Rename(this JObject target, string currentName, string newName) - { - if (String.Equals(currentName, newName)) - return true; - - var property = target.Property(currentName); - if (property is null) - return false; - - property.Replace(new JProperty(newName, property.Value)); - return true; - } - - public static bool RenameOrRemoveIfNullOrEmpty(this JObject target, string currentName, string newName) - { - var property = target.Property(currentName); - if (property is null) - return false; - - bool isNullOrEmpty = target.IsPropertyNullOrEmpty(currentName); - if (isNullOrEmpty) - { - target.Remove(property.Name); - return false; - } - - property.Replace(new JProperty(newName, property.Value)); - return true; - } - - public static void MoveOrRemoveIfNullOrEmpty(this JObject target, JObject source, params string[] names) - { - foreach (string name in names) - { - var property = source.Property(name); - if (property is null) - continue; - - bool isNullOrEmpty = source.IsPropertyNullOrEmpty(name); - source.Remove(property.Name); - - if (isNullOrEmpty) - continue; - - target.Add(name, property.Value); - } - } - - public static bool RenameAll(this JObject target, string currentName, string newName) - { - var properties = target.Descendants().OfType().Where(t => t.Name == currentName).ToList(); - foreach (var p in properties) - { - if (p.Parent is JObject parent) - parent.Rename(currentName, newName); - } - - return true; - } - - public static string? GetPropertyStringValue(this JObject target, string name) - { - if (target.IsPropertyNullOrEmpty(name)) - return null; - - return target.Property(name)?.Value.ToString(); - } - - - public static string? GetPropertyStringValueAndRemove(this JObject target, string name) - { - string? value = target.GetPropertyStringValue(name); - target.Remove(name); - return value; - } - + /// + /// Checks if a string contains JSON content (starts with { or [). + /// public static bool IsJson(this string value) { return value.GetJsonType() != JsonType.None; } + /// + /// Determines the JSON type of a string (Object, Array, or None). + /// public static JsonType GetJsonType(this string value) { if (String.IsNullOrEmpty(value)) @@ -172,120 +41,7 @@ public static JsonType GetJsonType(this string value) return JsonType.None; } - public static string ToJson(this T data, Formatting formatting = Formatting.None, JsonSerializerSettings? settings = null) - { - var serializer = settings is null ? JsonSerializer.CreateDefault() : JsonSerializer.CreateDefault(settings); - serializer.Formatting = formatting; - - using (var sw = new StringWriter()) - { - serializer.Serialize(sw, data, typeof(T)); - return sw.ToString(); - } - } - - public static List? FromJson(this JArray data, JsonSerializerSettings? settings = null) - { - var serializer = settings is null ? JsonSerializer.CreateDefault() : JsonSerializer.CreateDefault(settings); - return data.ToObject>(serializer); - } - - public static T? FromJson(this string data, JsonSerializerSettings? settings = null) - { - var serializer = settings is null ? JsonSerializer.CreateDefault() : JsonSerializer.CreateDefault(settings); - - using (var sw = new StringReader(data)) - using (var sr = new JsonTextReader(sw)) - return serializer.Deserialize(sr); - } - public static bool TryFromJson(this string data, out T? value, JsonSerializerSettings? settings = null) - { - try - { - value = data.FromJson(settings); - return true; - } - catch (Exception) - { - value = default; - return false; - } - } - - private static readonly ConcurrentDictionary _countAccessors = new(); - public static bool IsValueEmptyCollection(this JsonProperty property, object target) - { - object? value = property.ValueProvider?.GetValue(target); - if (value is null) - return true; - - if (value is ICollection collection) - return collection.Count == 0; - - if (property.PropertyType is null) - return false; - - if (!_countAccessors.ContainsKey(property.PropertyType)) - { - if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) - { - var countProperty = property.PropertyType.GetProperty("Count"); - if (countProperty is not null) - _countAccessors.AddOrUpdate(property.PropertyType, LateBinder.GetPropertyAccessor(countProperty)); - else - _countAccessors.AddOrUpdate(property.PropertyType, null); - } - else - { - _countAccessors.AddOrUpdate(property.PropertyType, null); - } - } - - var countAccessor = _countAccessors[property.PropertyType]; - if (countAccessor is null) - return false; - - int count = (int)(countAccessor.GetValue(value) ?? 0); - return count == 0; - } - - public static void AddModelConverters(this JsonSerializerSettings settings, ILogger logger) - { - var knownEventDataTypes = new Dictionary - { - { Event.KnownDataKeys.Error, typeof(Error) }, - { Event.KnownDataKeys.EnvironmentInfo, typeof(EnvironmentInfo) }, - { Event.KnownDataKeys.Location, typeof(Location) }, - { Event.KnownDataKeys.RequestInfo, typeof(RequestInfo) }, - { Event.KnownDataKeys.SimpleError, typeof(SimpleError) }, - { Event.KnownDataKeys.SubmissionClient, typeof(SubmissionClient) }, - { Event.KnownDataKeys.ManualStackingInfo, typeof(ManualStackingInfo) }, - { Event.KnownDataKeys.UserDescription, typeof(UserDescription) }, - { Event.KnownDataKeys.UserInfo, typeof(UserInfo) } - }; - - var knownProjectDataTypes = new Dictionary - { - { Project.KnownDataKeys.SlackToken, typeof(SlackToken) } - }; - - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger, knownProjectDataTypes)); - settings.Converters.Add(new DataObjectConverter(logger, knownEventDataTypes)); - settings.Converters.Add(new DataObjectConverter(logger, knownEventDataTypes)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - settings.Converters.Add(new DataObjectConverter(logger)); - } } public enum JsonType : byte diff --git a/src/Exceptionless.Core/Extensions/JsonNodeExtensions.cs b/src/Exceptionless.Core/Extensions/JsonNodeExtensions.cs new file mode 100644 index 0000000000..2e0afb66d1 --- /dev/null +++ b/src/Exceptionless.Core/Extensions/JsonNodeExtensions.cs @@ -0,0 +1,405 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Exceptionless.Core.Extensions; + +/// +/// Extension methods for System.Text.Json.Nodes types (JsonNode, JsonObject, JsonArray). +/// Provides helper methods for JSON manipulation during event processing and upgrades. +/// +public static class JsonNodeExtensions +{ + /// + /// Checks if a JsonNode is null or empty (no values for objects/arrays). + /// + public static bool IsNullOrEmpty(this JsonNode? target) + { + if (target is null) + return true; + + if (target is JsonObject obj) + return obj.Count == 0; + + if (target is JsonArray arr) + return arr.Count == 0; + + if (target is JsonValue val) + { + // Check for null value + if (target.GetValueKind() == JsonValueKind.Null) + return true; + + // Check for empty string + if (target.GetValueKind() == JsonValueKind.String) + { + var strValue = val.GetValue(); + return string.IsNullOrEmpty(strValue); + } + } + + return false; + } + + /// + /// Checks if a property in a JsonObject is null or empty. + /// + public static bool IsPropertyNullOrEmpty(this JsonObject target, string name) + { + if (!target.TryGetPropertyValue(name, out var value)) + return true; + + return value.IsNullOrEmpty(); + } + + /// + /// Removes a property if it is null or empty. + /// + /// True if the property was removed, false otherwise. + public static bool RemoveIfNullOrEmpty(this JsonObject target, string name) + { + if (!target.IsPropertyNullOrEmpty(name)) + return false; + + target.Remove(name); + return true; + } + + /// + /// Removes multiple properties from a JsonObject. + /// + public static void RemoveAll(this JsonObject target, params string[] names) + { + foreach (string name in names) + target.Remove(name); + } + + /// + /// Removes all properties with the given names if they are null or empty, recursively. + /// + /// True if any properties were removed, false otherwise. + public static bool RemoveAllIfNullOrEmpty(this JsonObject target, params string[] names) + { + if (target.IsNullOrEmpty()) + return false; + + bool removed = false; + var toRemove = new List<(JsonObject parent, string name)>(); + + foreach (var descendant in target.DescendantsAndSelf().OfType()) + { + foreach (var name in names) + { + if (descendant.TryGetPropertyValue(name, out var value) && value.IsNullOrEmpty()) + { + toRemove.Add((descendant, name)); + } + } + } + + foreach (var (parent, name) in toRemove) + { + parent.Remove(name); + removed = true; + } + + return removed; + } + + /// + /// Renames a property in a JsonObject while preserving property order. + /// + /// True if the property was renamed, false if not found. + public static bool Rename(this JsonObject target, string currentName, string newName) + { + if (string.Equals(currentName, newName)) + return true; + + if (!target.TryGetPropertyValue(currentName, out var value)) + return false; + + // To preserve order, we need to rebuild the object + var properties = target.ToList(); + target.Clear(); + + foreach (var prop in properties) + { + if (prop.Key == currentName) + target.Add(newName, prop.Value); + else + target.Add(prop.Key, prop.Value); + } + + return true; + } + + /// + /// Renames a property or removes it if null or empty, preserving property order. + /// + /// True if renamed, false if removed or not found. + public static bool RenameOrRemoveIfNullOrEmpty(this JsonObject target, string currentName, string newName) + { + if (!target.TryGetPropertyValue(currentName, out var value)) + return false; + + bool isNullOrEmpty = value.IsNullOrEmpty(); + if (isNullOrEmpty) + { + target.Remove(currentName); + return false; + } + + // To preserve order, we need to rebuild the object + var properties = target.ToList(); + target.Clear(); + + foreach (var prop in properties) + { + if (prop.Key == currentName) + target.Add(newName, prop.Value); + else + target.Add(prop.Key, prop.Value); + } + + return true; + } + + /// + /// Moves properties from source to target, removing if null or empty. + /// + public static void MoveOrRemoveIfNullOrEmpty(this JsonObject target, JsonObject source, params string[] names) + { + foreach (string name in names.Where(source.ContainsKey)) + { + source.TryGetPropertyValue(name, out var value); + bool isNullOrEmpty = value.IsNullOrEmpty(); + source.Remove(name); + + if (isNullOrEmpty) + continue; + + target.Add(name, value); + } + } + + /// + /// Renames all properties with the given name recursively throughout the JSON tree. + /// + public static bool RenameAll(this JsonObject target, string currentName, string newName) + { + var objectsWithProperty = target.DescendantsAndSelf() + .OfType() + .Where(o => o.ContainsKey(currentName)) + .ToList(); + + foreach (var obj in objectsWithProperty) + { + obj.Rename(currentName, newName); + } + + return objectsWithProperty.Count > 0; + } + + /// + /// Gets a string value from a property, or null if not found or empty. + /// + public static string? GetPropertyStringValue(this JsonObject target, string name) + { + if (target.IsPropertyNullOrEmpty(name)) + return null; + + if (!target.TryGetPropertyValue(name, out var value)) + return null; + + return value?.ToString(); + } + + /// + /// Gets a string value from a property and removes it. + /// + public static string? GetPropertyStringValueAndRemove(this JsonObject target, string name) + { + string? value = target.GetPropertyStringValue(name); + target.Remove(name); + return value; + } + + /// + /// Enumerates all descendant nodes of a JsonNode. + /// + public static IEnumerable Descendants(this JsonNode? node) + { + if (node is null) + yield break; + + if (node is JsonObject obj) + { + foreach (var prop in obj) + { + yield return prop.Value; + if (prop.Value is not null) + { + foreach (var desc in Descendants(prop.Value)) + yield return desc; + } + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + yield return item; + if (item is not null) + { + foreach (var desc in Descendants(item)) + yield return desc; + } + } + } + } + + /// + /// Enumerates the node itself and all its descendants. + /// + public static IEnumerable DescendantsAndSelf(this JsonNode? node) + { + yield return node; + foreach (var desc in Descendants(node)) + yield return desc; + } + + /// + /// Checks if a JsonNode has any values (for objects: has properties, for arrays: has items). + /// + public static bool HasValues(this JsonNode? node) + { + return !node.IsNullOrEmpty(); + } + + /// + /// Converts a JsonNode to the specified type. + /// + public static T? ToObject(this JsonNode? node, JsonSerializerOptions options) + { + if (node is null) + return default; + + return node.Deserialize(options); + } + + /// + /// Converts a JsonArray to a List of the specified type. + /// + public static List? ToList(this JsonArray? array, JsonSerializerOptions options) + { + if (array is null) + return null; + + return array.Deserialize>(options); + } + + /// + /// Converts a JsonNode to a pretty-printed JSON string. + /// Uses 2-space indentation. Normalizes dates to match existing data format (Z → +00:00). + /// + /// The JSON node to format. + /// Serializer options from DI. Uses WriteIndented=true and IndentSize=2. + public static string ToFormattedString(this JsonNode? node, JsonSerializerOptions options) + { + if (node is null) + return "null"; + + // Normalize the node to match existing date format before serialization + NormalizeDates(node); + + return node.ToJsonString(options); + } + + /// + /// Recursively normalizes date strings from Z format to +00:00 format + /// to match Newtonsoft.Json's default date serialization behavior. + /// + private static void NormalizeDates(JsonNode? node) + { + if (node is JsonObject obj) + { + var propertiesToUpdate = new List<(string key, string newValue)>(); + + foreach (var prop in obj) + { + if (prop.Value is JsonValue val && val.GetValueKind() == System.Text.Json.JsonValueKind.String) + { + var strValue = val.GetValue(); + if (strValue != null && IsIso8601DateWithZ(strValue)) + { + // Convert Z to +00:00 to match Newtonsoft behavior + var normalized = NormalizeDateString(strValue); + if (normalized != strValue) + { + propertiesToUpdate.Add((prop.Key, normalized)); + } + } + } + else + { + NormalizeDates(prop.Value); + } + } + + foreach (var (key, newValue) in propertiesToUpdate) + { + obj[key] = JsonValue.Create(newValue); + } + } + else if (node is JsonArray arr) + { + for (int i = 0; i < arr.Count; i++) + { + if (arr[i] is JsonValue val && val.GetValueKind() == System.Text.Json.JsonValueKind.String) + { + var strValue = val.GetValue(); + if (strValue != null && IsIso8601DateWithZ(strValue)) + { + var normalized = NormalizeDateString(strValue); + if (normalized != strValue) + { + arr[i] = JsonValue.Create(normalized); + } + } + } + else + { + NormalizeDates(arr[i]); + } + } + } + } + + /// + /// Checks if a string looks like an ISO 8601 date with Z suffix. + /// + private static bool IsIso8601DateWithZ(string value) + { + // Check for pattern like "2013-09-11T14:49:54.218Z" or "2014-03-03T11:10:56Z" + return value.Length >= 20 && + value.Length <= 28 && + value.EndsWith("Z") && + value[4] == '-' && + value[7] == '-' && + value[10] == 'T' && + value[13] == ':' && + value[16] == ':'; + } + + /// + /// Normalizes a date string from Z format to +00:00 format. + /// + private static string NormalizeDateString(string value) + { + if (DateTimeOffset.TryParse(value, out var date)) + { + // Format with explicit offset + return date.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFFFzzz"); + } + return value; + } +} diff --git a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs index f502a0d7cc..2c59561c8a 100644 --- a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs @@ -1,7 +1,7 @@ -using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; +using Foundatio.Serializer; namespace Exceptionless; @@ -176,7 +176,7 @@ public static bool UpdateSessionStart(this PersistentEvent ev, DateTime lastActi return true; } - public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, JsonSerializerOptions jsonOptions, DateTime? lastActivityUtc = null, bool? isSessionEnd = null, bool hasPremiumFeatures = true, bool includePrivateInformation = true) + public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, ITextSerializer serializer, DateTime? lastActivityUtc = null, bool? isSessionEnd = null, bool hasPremiumFeatures = true, bool includePrivateInformation = true) { var startEvent = new PersistentEvent { @@ -192,11 +192,11 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, J if (sessionId is not null) startEvent.SetSessionId(sessionId); if (includePrivateInformation) - startEvent.SetUserIdentity(source.GetUserIdentity(jsonOptions)); - startEvent.SetLocation(source.GetLocation(jsonOptions)); + startEvent.SetUserIdentity(source.GetUserIdentity(serializer)); + startEvent.SetLocation(source.GetLocation(serializer)); startEvent.SetVersion(source.GetVersion()); - var ei = source.GetEnvironmentInfo(jsonOptions); + var ei = source.GetEnvironmentInfo(serializer); if (ei is not null) { startEvent.SetEnvironmentInfo(new EnvironmentInfo @@ -217,7 +217,7 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, J }); } - var ri = source.GetRequestInfo(jsonOptions); + var ri = source.GetRequestInfo(serializer); if (ri is not null) { startEvent.AddRequestInfo(new RequestInfo @@ -243,19 +243,19 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, J return startEvent; } - public static IEnumerable GetIpAddresses(this PersistentEvent ev, JsonSerializerOptions jsonOptions) + public static IEnumerable GetIpAddresses(this PersistentEvent ev, ITextSerializer serializer) { if (!String.IsNullOrEmpty(ev.Geo) && (ev.Geo.Contains('.') || ev.Geo.Contains(':'))) yield return ev.Geo.Trim(); - var ri = ev.GetRequestInfo(jsonOptions); + var ri = ev.GetRequestInfo(serializer); if (!String.IsNullOrEmpty(ri?.ClientIpAddress)) { foreach (string ip in ri.ClientIpAddress.Split(_commaSeparator, StringSplitOptions.RemoveEmptyEntries)) yield return ip.Trim(); } - var ei = ev.GetEnvironmentInfo(jsonOptions); + var ei = ev.GetEnvironmentInfo(serializer); if (!String.IsNullOrEmpty(ei?.IpAddress)) { foreach (string ip in ei.IpAddress.Split(_commaSeparator, StringSplitOptions.RemoveEmptyEntries)) diff --git a/src/Exceptionless.Core/Extensions/ProjectExtensions.cs b/src/Exceptionless.Core/Extensions/ProjectExtensions.cs index 3f14a0fb30..d5880de903 100644 --- a/src/Exceptionless.Core/Extensions/ProjectExtensions.cs +++ b/src/Exceptionless.Core/Extensions/ProjectExtensions.cs @@ -1,6 +1,8 @@ using System.Text; +using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.DateTimeExtensions; +using Foundatio.Serializer; namespace Exceptionless.Core.Extensions; @@ -48,9 +50,21 @@ public static string BuildFilter(this IList projects) /// /// Gets the slack token from extended data. /// - public static SlackToken? GetSlackToken(this Project project) + public static SlackToken? GetSlackToken(this Project project, ITextSerializer serializer) { - return project.Data is not null && project.Data.TryGetValue(Project.KnownDataKeys.SlackToken, out object? value) ? value as SlackToken : null; + if (project.Data is null || !project.Data.ContainsKey(Project.KnownDataKeys.SlackToken)) + return null; + + try + { + return project.Data.GetValue(Project.KnownDataKeys.SlackToken, serializer); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + // Ignored — data may be stored in an incompatible format + } + + return null; } public static bool HasHourlyUsage(this Project project, DateTime date) diff --git a/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs b/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs index 1f992b7a58..8019f6a9ae 100644 --- a/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs +++ b/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs @@ -1,35 +1,34 @@ using System.Text; using Exceptionless.Core.Models.Data; -using Newtonsoft.Json; +using Foundatio.Serializer; namespace Exceptionless.Core.Extensions; public static class RequestInfoExtensions { - public static RequestInfo ApplyDataExclusions(this RequestInfo request, IList exclusions, int maxLength = 1000) + public static RequestInfo ApplyDataExclusions(this RequestInfo request, ITextSerializer serializer, IList exclusions, int maxLength = 1000) { request.Cookies = ApplyExclusions(request.Cookies, exclusions, maxLength); request.QueryString = ApplyExclusions(request.QueryString, exclusions, maxLength); - request.PostData = ApplyPostDataExclusions(request.PostData, exclusions, maxLength); + request.PostData = ApplyPostDataExclusions(request.PostData, serializer, exclusions, maxLength); return request; } - private static object? ApplyPostDataExclusions(object? data, IEnumerable exclusions, int maxLength) + private static object? ApplyPostDataExclusions(object? data, ITextSerializer serializer, IEnumerable exclusions, int maxLength) { if (data is null) return null; var dictionary = data as Dictionary; - if (dictionary is null && data is string) + if (dictionary is null && data is string json) { - string json = (string)data; if (!json.IsJson()) return data; try { - dictionary = JsonConvert.DeserializeObject>(json); + dictionary = serializer.Deserialize>(json); } catch (Exception) { } } diff --git a/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs b/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs index c968f02623..8d18510098 100644 --- a/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs +++ b/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -9,6 +8,7 @@ using Foundatio.Lock; using Foundatio.Repositories; using Foundatio.Resilience; +using Foundatio.Serializer; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; @@ -20,11 +20,11 @@ public class CloseInactiveSessionsJob : JobWithLockBase, IHealthCheck private readonly IEventRepository _eventRepository; private readonly ICacheClient _cache; private readonly ILockProvider _lockProvider; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; private DateTime? _lastActivity; public CloseInactiveSessionsJob(IEventRepository eventRepository, ICacheClient cacheClient, - JsonSerializerOptions jsonOptions, + ITextSerializer serializer, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory @@ -33,7 +33,7 @@ ILoggerFactory loggerFactory _eventRepository = eventRepository; _cache = cacheClient; _lockProvider = new ThrottlingLockProvider(cacheClient, 1, TimeSpan.FromMinutes(1), timeProvider, resiliencePolicyProvider, loggerFactory); - _jsonOptions = jsonOptions; + _serializer = serializer; } protected override Task GetLockAsync(CancellationToken cancellationToken = default) @@ -130,7 +130,7 @@ protected override async Task RunInternalAsync(JobContext context) allHeartbeatKeys.Add(sessionIdKey); } - var user = session.GetUserIdentity(_jsonOptions); + var user = session.GetUserIdentity(_serializer); if (!String.IsNullOrWhiteSpace(user?.Identity)) { userIdentityKey = $"Project:{session.ProjectId}:heartbeat:{user.Identity.ToSHA1()}"; diff --git a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs index e51f0f0a27..bca68d754f 100644 --- a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; @@ -13,6 +12,7 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Resilience; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Jobs; @@ -29,7 +29,7 @@ public class EventNotificationsJob : QueueJobBase private readonly IEventRepository _eventRepository; private readonly ICacheClient _cache; private readonly UserAgentParser _parser; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; public EventNotificationsJob(IQueue queue, SlackService slackService, @@ -41,7 +41,7 @@ public EventNotificationsJob(IQueue queue, IEventRepository eventRepository, ICacheClient cacheClient, UserAgentParser parser, - JsonSerializerOptions jsonOptions, + ITextSerializer serializer, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) @@ -55,7 +55,7 @@ public EventNotificationsJob(IQueue queue, _eventRepository = eventRepository; _cache = cacheClient; _parser = parser; - _jsonOptions = jsonOptions; + _serializer = serializer; } protected override async Task ProcessQueueEntryAsync(QueueEntryContext context) @@ -116,7 +116,7 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex _logger.LogTrace("Settings: new error={ReportNewErrors} critical error={ReportCriticalErrors} regression={ReportEventRegressions} new={ReportNewEvents} critical={ReportCriticalEvents}", settings.ReportNewErrors, settings.ReportCriticalErrors, settings.ReportEventRegressions, settings.ReportNewEvents, settings.ReportCriticalEvents); _logger.LogTrace("Should process: new error={ShouldReportNewError} critical error={ShouldReportCriticalError} regression={ShouldReportRegression} new={ShouldReportNewEvent} critical={ShouldReportCriticalEvent}", shouldReportNewError, shouldReportCriticalError, shouldReportRegression, shouldReportNewEvent, shouldReportCriticalEvent); } - var request = ev.GetRequestInfo(_jsonOptions); + var request = ev.GetRequestInfo(_serializer); // check for known bots if the user has elected to not report them if (shouldReport && !String.IsNullOrEmpty(request?.UserAgent)) { diff --git a/src/Exceptionless.Core/Jobs/EventPostsJob.cs b/src/Exceptionless.Core/Jobs/EventPostsJob.cs index 605ee3ee6c..c7dcbb3806 100644 --- a/src/Exceptionless.Core/Jobs/EventPostsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventPostsJob.cs @@ -13,8 +13,8 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Resilience; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace Exceptionless.Core.Jobs; @@ -30,10 +30,10 @@ public class EventPostsJob : QueueJobBase private readonly UsageService _usageService; private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; - private readonly JsonSerializerSettings _jsonSerializerSettings; + private readonly ITextSerializer _serializer; private readonly AppOptions _appOptions; - public EventPostsJob(IQueue queue, EventPostService eventPostService, EventParserPluginManager eventParserPluginManager, EventPipeline eventPipeline, UsageService usageService, IOrganizationRepository organizationRepository, IProjectRepository projectRepository, JsonSerializerSettings jsonSerializerSettings, AppOptions appOptions, TimeProvider timeProvider, + public EventPostsJob(IQueue queue, EventPostService eventPostService, EventParserPluginManager eventParserPluginManager, EventPipeline eventPipeline, UsageService usageService, IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ITextSerializer serializer, AppOptions appOptions, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) { _eventPostService = eventPostService; @@ -42,7 +42,7 @@ public EventPostsJob(IQueue queue, EventPostService eventPostService, _usageService = usageService; _organizationRepository = organizationRepository; _projectRepository = projectRepository; - _jsonSerializerSettings = jsonSerializerSettings; + _serializer = serializer; _appOptions = appOptions; _maximumEventPostFileSize = _appOptions.MaximumEventPostSize + 1024; @@ -302,7 +302,7 @@ private async Task RetryEventsAsync(List eventsToRetry, EventPo { try { - var stream = new MemoryStream(ev.GetBytes(_jsonSerializerSettings)); + using var stream = new MemoryStream(ev.GetBytes(_serializer)); // Put this single event back into the queue so we can retry it separately. await _eventPostService.EnqueueAsync(new EventPost(false) diff --git a/src/Exceptionless.Core/Jobs/WebHooksJob.cs b/src/Exceptionless.Core/Jobs/WebHooksJob.cs index 9f616db157..c88a74e44f 100644 --- a/src/Exceptionless.Core/Jobs/WebHooksJob.cs +++ b/src/Exceptionless.Core/Jobs/WebHooksJob.cs @@ -1,4 +1,7 @@ using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queues.Models; @@ -10,8 +13,8 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Resilience; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace Exceptionless.Core.Jobs; @@ -32,7 +35,8 @@ public class WebHooksJob : QueueJobBase, IDisposable private readonly SlackService _slackService; private readonly IWebHookRepository _webHookRepository; private readonly ICacheClient _cacheClient; - private readonly JsonSerializerSettings _jsonSerializerSettings; + private readonly ITextSerializer _serializer; + private readonly JsonSerializerOptions _jsonOptions; private readonly AppOptions _appOptions; private HttpClient? _client; @@ -42,14 +46,15 @@ private HttpClient Client get => _client ??= new HttpClient(); } - public WebHooksJob(IQueue queue, IProjectRepository projectRepository, SlackService slackService, IWebHookRepository webHookRepository, ICacheClient cacheClient, JsonSerializerSettings settings, AppOptions appOptions, TimeProvider timeProvider, + public WebHooksJob(IQueue queue, IProjectRepository projectRepository, SlackService slackService, IWebHookRepository webHookRepository, ICacheClient cacheClient, ITextSerializer serializer, JsonSerializerOptions jsonOptions, AppOptions appOptions, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) { _projectRepository = projectRepository; _slackService = slackService; _webHookRepository = webHookRepository; _cacheClient = cacheClient; - _jsonSerializerSettings = settings; + _serializer = serializer; + _jsonOptions = jsonOptions; _appOptions = appOptions; } @@ -88,7 +93,7 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex { using (var postCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, timeoutCancellationTokenSource.Token)) { - response = await Client.PostAsJsonAsync(body.Url, body.Data.ToJson(Formatting.Indented, _jsonSerializerSettings), postCancellationTokenSource.Token); + response = await Client.PostAsJsonAsync(body.Url, body.Data, _jsonOptions, postCancellationTokenSource.Token); if (!response.IsSuccessStatusCode) successful = false; else if (consecutiveErrors > 0) @@ -165,7 +170,7 @@ private async Task IsEnabledAsync(WebHookNotification body) return webHook?.IsEnabled ?? false; case WebHookType.Slack: var project = await _projectRepository.GetByIdAsync(body.ProjectId, o => o.Cache()); - var token = project?.GetSlackToken(); + var token = project?.GetSlackToken(_serializer); return token is not null; } @@ -181,7 +186,7 @@ private async Task DisableIntegrationAsync(WebHookNotification body) break; case WebHookType.Slack: var project = await _projectRepository.GetByIdAsync(body.ProjectId); - var token = project?.GetSlackToken(); + var token = project?.GetSlackToken(_serializer); if (token is null) return; diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index f18e70bfcc..f91e2538e9 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -1,11 +1,11 @@ using System.Collections.Concurrent; -using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; using Exceptionless.Core.Queues.Models; using Exceptionless.DateTimeExtensions; using Foundatio.Queues; +using Foundatio.Serializer; using HandlebarsDotNet; using Microsoft.Extensions.Logging; @@ -18,16 +18,16 @@ public class Mailer : IMailer private readonly FormattingPluginManager _pluginManager; private readonly AppOptions _appOptions; private readonly TimeProvider _timeProvider; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; private readonly ILogger _logger; - public Mailer(IQueue queue, FormattingPluginManager pluginManager, JsonSerializerOptions jsonOptions, AppOptions appOptions, TimeProvider timeProvider, ILogger logger) + public Mailer(IQueue queue, FormattingPluginManager pluginManager, ITextSerializer serializer, AppOptions appOptions, TimeProvider timeProvider, ILogger logger) { _queue = queue; _pluginManager = pluginManager; _appOptions = appOptions; _timeProvider = timeProvider; - _jsonOptions = jsonOptions; + _serializer = serializer; _logger = logger; } @@ -59,7 +59,7 @@ public async Task SendEventNoticeAsync(User user, PersistentEvent ev, Proj }; AddDefaultFields(ev, result.Data); - AddUserInfo(ev, messageData, _jsonOptions); + AddUserInfo(ev, messageData, _serializer); const string template = "event-notice"; await QueueMessageAsync(new MailMessage @@ -71,10 +71,10 @@ await QueueMessageAsync(new MailMessage return true; } - private static void AddUserInfo(PersistentEvent ev, Dictionary data, JsonSerializerOptions jsonOptions) + private static void AddUserInfo(PersistentEvent ev, Dictionary data, ITextSerializer serializer) { - var ud = ev.GetUserDescription(jsonOptions); - var ui = ev.GetUserIdentity(jsonOptions); + var ud = ev.GetUserDescription(serializer); + var ui = ev.GetUserIdentity(serializer); if (!String.IsNullOrEmpty(ud?.Description)) data["UserDescription"] = ud.Description; diff --git a/src/Exceptionless.Core/Models/Event.cs b/src/Exceptionless.Core/Models/Event.cs index 3975db18ca..b91326439d 100644 --- a/src/Exceptionless.Core/Models/Event.cs +++ b/src/Exceptionless.Core/Models/Event.cs @@ -1,11 +1,13 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; using Exceptionless.Core.Extensions; namespace Exceptionless.Core.Models; [DebuggerDisplay("Type: {Type}, Date: {Date}, Message: {Message}, Value: {Value}, Count: {Count}")] -public class Event : IData +public class Event : IData, IJsonOnDeserialized { /// /// The event type (ie. error, log message, feature usage). Check Event.KnownTypes for standard event types. @@ -55,11 +57,56 @@ public class Event : IData /// public DataDictionary? Data { get; set; } = new(); + /// + /// Captures unknown JSON properties during deserialization. + /// These are merged into after deserialization. + /// Known data keys like "@error", "@request", "@environment" may appear at root level. + /// + [JsonExtensionData] + [JsonInclude] + internal Dictionary? ExtensionData { get; set; } + /// /// An optional identifier to be used for referencing this event instance at a later time. /// public string? ReferenceId { get; set; } + /// + /// Called after JSON deserialization to merge extension data into the Data dictionary. + /// This handles the case where known data keys like "@error", "@request", "@environment" + /// appear at the JSON root level instead of nested under "data". + /// + void IJsonOnDeserialized.OnDeserialized() + { + if (ExtensionData is null || ExtensionData.Count == 0) + return; + + Data ??= new DataDictionary(); + foreach (var kvp in ExtensionData) + { + Data[kvp.Key] = ConvertJsonElement(kvp.Value); + } + ExtensionData = null; + } + + /// + /// Converts a to a .NET type so downstream code + /// (e.g., value as string) works correctly. + /// + private static object? ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out long l) ? (object)l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + // For objects/arrays, keep as JsonElement — GetValue handles these + _ => element + }; + } + protected bool Equals(Event other) { return String.Equals(Type, other.Type) && String.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && String.Equals(Message, other.Message) && String.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data); diff --git a/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs b/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs index bd506cab2d..cc3508fcb3 100644 --- a/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs +++ b/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs @@ -2,7 +2,7 @@ public record ReleaseNotification { - public required bool Critical { get; set; } - public required DateTime Date { get; set; } - public required string? Message { get; set; } + public bool Critical { get; set; } + public DateTime Date { get; set; } + public string? Message { get; set; } } diff --git a/src/Exceptionless.Core/Models/SlackToken.cs b/src/Exceptionless.Core/Models/SlackToken.cs index 28d89e3a72..6d94160b28 100644 --- a/src/Exceptionless.Core/Models/SlackToken.cs +++ b/src/Exceptionless.Core/Models/SlackToken.cs @@ -1,5 +1,5 @@ -using System.Text.Json; -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using Foundatio.Serializer; namespace Exceptionless.Core.Models; @@ -28,19 +28,19 @@ public SlackMessage(string text) Text = text; } - [JsonProperty("text")] + [JsonPropertyName("text")] public string Text { get; init; } - [JsonProperty("attachments")] + [JsonPropertyName("attachments")] public List Attachments { get; init; } = []; public class SlackAttachment { - public SlackAttachment(PersistentEvent ev, JsonSerializerOptions jsonOptions) + public SlackAttachment(PersistentEvent ev, ITextSerializer serializer) { TimeStamp = ev.Date.ToUnixTimeSeconds(); - var ud = ev.GetUserDescription(jsonOptions); - var ui = ev.GetUserIdentity(jsonOptions); + var ud = ev.GetUserDescription(serializer); + var ui = ev.GetUserIdentity(serializer); Text = ud?.Description; string? displayName = null; @@ -67,34 +67,34 @@ public SlackAttachment(PersistentEvent ev, JsonSerializerOptions jsonOptions) } } - [JsonProperty("title")] + [JsonPropertyName("title")] public string? Title { get; init; } - [JsonProperty("text")] + [JsonPropertyName("text")] public string? Text { get; init; } - [JsonProperty("author_name")] + [JsonPropertyName("author_name")] public string? AuthorName { get; init; } - [JsonProperty("author_link")] + [JsonPropertyName("author_link")] public string? AuthorLink { get; init; } - [JsonProperty("author_icon")] + [JsonPropertyName("author_icon")] public string? AuthorIcon { get; init; } - [JsonProperty("color")] + [JsonPropertyName("color")] public string Color { get; set; } = "#5E9A00"; - [JsonProperty("fields")] + [JsonPropertyName("fields")] public List Fields { get; init; } = []; - [JsonProperty("mrkdwn_in")] + [JsonPropertyName("mrkdwn_in")] public string[] SupportedMarkdownFields { get; init; } = ["text", "fields"]; - [JsonProperty("ts")] + [JsonPropertyName("ts")] public long TimeStamp { get; init; } } public record SlackAttachmentFields { - [JsonProperty("title")] + [JsonPropertyName("title")] public string Title { get; init; } = null!; - [JsonProperty("value")] + [JsonPropertyName("value")] public string? Value { get; init; } - [JsonProperty("short")] + [JsonPropertyName("short")] public bool Short { get; init; } } } diff --git a/src/Exceptionless.Core/Models/Stack.cs b/src/Exceptionless.Core/Models/Stack.cs index e9532e099c..c47b56716f 100644 --- a/src/Exceptionless.Core/Models/Stack.cs +++ b/src/Exceptionless.Core/Models/Stack.cs @@ -5,7 +5,6 @@ using System.Text.Json.Serialization; using Exceptionless.Core.Attributes; using Foundatio.Repositories.Models; -using Newtonsoft.Json.Converters; namespace Exceptionless.Core.Models; @@ -131,7 +130,6 @@ public static class KnownTypes } [JsonConverter(typeof(JsonStringEnumConverter))] -[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] public enum StackStatus { [JsonStringEnumMemberName("open")] diff --git a/src/Exceptionless.Core/Models/StackSummaryModel.cs b/src/Exceptionless.Core/Models/StackSummaryModel.cs index 197065fc71..eff763c2b5 100644 --- a/src/Exceptionless.Core/Models/StackSummaryModel.cs +++ b/src/Exceptionless.Core/Models/StackSummaryModel.cs @@ -5,7 +5,7 @@ namespace Exceptionless.Core.Models; [DebuggerDisplay("Id: {Id}, Status: {Status}, Title: {Title}, First: {FirstOccurrence}, Last: {LastOccurrence}")] public record StackSummaryModel : SummaryData { - public required string Title { get; init; } + public string? Title { get; init; } public StackStatus Status { get; init; } public DateTime FirstOccurrence { get; init; } public DateTime LastOccurrence { get; init; } diff --git a/src/Exceptionless.Core/Models/SummaryData.cs b/src/Exceptionless.Core/Models/SummaryData.cs index 7433766a68..7419315017 100644 --- a/src/Exceptionless.Core/Models/SummaryData.cs +++ b/src/Exceptionless.Core/Models/SummaryData.cs @@ -4,5 +4,5 @@ public record SummaryData { public required string Id { get; set; } public required string TemplateKey { get; set; } - public required object Data { get; set; } + public object? Data { get; set; } } diff --git a/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs b/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs index b40c9520bb..715c05be77 100644 --- a/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs @@ -1,19 +1,19 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace Exceptionless.Core.Plugins.EventParser; [Priority(0)] public class JsonEventParserPlugin : PluginBase, IEventParserPlugin { - private readonly JsonSerializerSettings _settings; + private readonly ITextSerializer _serializer; - public JsonEventParserPlugin(AppOptions options, JsonSerializerSettings settings, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public JsonEventParserPlugin(AppOptions options, ITextSerializer serializer, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _settings = settings; + _serializer = serializer; } public List? ParseEvents(string input, int apiVersion, string? userAgent) @@ -26,15 +26,30 @@ public JsonEventParserPlugin(AppOptions options, JsonSerializerSettings settings { case JsonType.Object: { - if (input.TryFromJson(out PersistentEvent? ev, _settings) && ev is not null) - events.Add(ev); + try + { + var ev = _serializer.Deserialize(input); + if (ev is not null) + events.Add(ev); + } + catch + { + // Invalid JSON - ignore + } break; } case JsonType.Array: { - if (input.TryFromJson(out PersistentEvent[]? parsedEvents, _settings) && parsedEvents is { Length: > 0 }) - events.AddRange(parsedEvents); - + try + { + var parsedEvents = _serializer.Deserialize(input); + if (parsedEvents is { Length: > 0 }) + events.AddRange(parsedEvents); + } + catch + { + // Invalid JSON - ignore + } break; } } diff --git a/src/Exceptionless.Core/Plugins/EventParser/Default/LegacyErrorParserPlugin.cs b/src/Exceptionless.Core/Plugins/EventParser/Default/LegacyErrorParserPlugin.cs index 5d8c337249..8500c52329 100644 --- a/src/Exceptionless.Core/Plugins/EventParser/Default/LegacyErrorParserPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventParser/Default/LegacyErrorParserPlugin.cs @@ -1,9 +1,9 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Plugins.EventUpgrader; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace Exceptionless.Core.Plugins.EventParser; @@ -11,12 +11,12 @@ namespace Exceptionless.Core.Plugins.EventParser; public class LegacyErrorParserPlugin : PluginBase, IEventParserPlugin { private readonly EventUpgraderPluginManager _manager; - private readonly JsonSerializerSettings _settings; + private readonly JsonSerializerOptions _jsonOptions; - public LegacyErrorParserPlugin(EventUpgraderPluginManager manager, JsonSerializerSettings settings, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public LegacyErrorParserPlugin(EventUpgraderPluginManager manager, JsonSerializerOptions jsonOptions, AppOptions appOptions, ILoggerFactory loggerFactory) : base(appOptions, loggerFactory) { _manager = manager; - _settings = settings; + _jsonOptions = jsonOptions; } public List? ParseEvents(string input, int apiVersion, string? userAgent) @@ -29,7 +29,7 @@ public LegacyErrorParserPlugin(EventUpgraderPluginManager manager, JsonSerialize var ctx = new EventUpgraderContext(input); _manager.Upgrade(ctx); - return ctx.Documents.FromJson(_settings); + return ctx.Documents.ToList(_jsonOptions); } catch (Exception ex) { diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs index 4b17fd8dc3..54026b14ba 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/03_ManualStackingPlugin.cs @@ -1,6 +1,6 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -8,16 +8,16 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(3)] public sealed class ManualStackingPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public ManualStackingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public ManualStackingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) { - var msi = context.Event.GetManualStackingInfo(_jsonOptions); + var msi = context.Event.GetManualStackingInfo(_serializer); if (msi?.SignatureData is not null) { foreach (var kvp in msi.SignatureData) diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs index 36610ef3a8..8cd28b99c7 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/0_ThrottleBotsPlugin.cs @@ -1,11 +1,11 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models.WorkItems; using Exceptionless.Core.Pipeline; using Exceptionless.DateTimeExtensions; using Foundatio.Caching; using Foundatio.Jobs; using Foundatio.Queues; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -16,15 +16,15 @@ public sealed class ThrottleBotsPlugin : EventProcessorPluginBase private readonly ICacheClient _cache; private readonly IQueue _workItemQueue; private readonly TimeProvider _timeProvider; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; private readonly TimeSpan _throttlingPeriod = TimeSpan.FromMinutes(5); public ThrottleBotsPlugin(ICacheClient cacheClient, IQueue workItemQueue, - JsonSerializerOptions jsonOptions, TimeProvider timeProvider, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + ITextSerializer serializer, TimeProvider timeProvider, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _cache = cacheClient; _workItemQueue = workItemQueue; - _jsonOptions = jsonOptions; + _serializer = serializer; _timeProvider = timeProvider; } @@ -38,7 +38,7 @@ public override async Task EventBatchProcessingAsync(ICollection c return; // Throttle errors by client ip address to no more than X every 5 minutes. - var clientIpAddressGroups = contexts.GroupBy(c => c.Event.GetRequestInfo(_jsonOptions)?.ClientIpAddress); + var clientIpAddressGroups = contexts.GroupBy(c => c.Event.GetRequestInfo(_serializer)?.ClientIpAddress); foreach (var clientIpAddressGroup in clientIpAddressGroups) { if (String.IsNullOrEmpty(clientIpAddressGroup.Key) || clientIpAddressGroup.Key.IsPrivateNetwork()) diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs index 99599dcaa1..de32d65000 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/10_NotFoundPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -9,11 +9,11 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(10)] public sealed class NotFoundPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public NotFoundPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public NotFoundPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) @@ -24,7 +24,7 @@ public override Task EventProcessingAsync(EventContext context) context.Event.Data.Remove(Event.KnownDataKeys.EnvironmentInfo); context.Event.Data.Remove(Event.KnownDataKeys.TraceLog); - var req = context.Event.GetRequestInfo(_jsonOptions); + var req = context.Event.GetRequestInfo(_serializer); if (req is null) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs index f94a57e519..499c1279b5 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/20_ErrorPlugin.cs @@ -1,8 +1,8 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Utility; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -10,11 +10,11 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(20)] public sealed class ErrorPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public ErrorPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public ErrorPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) @@ -22,7 +22,7 @@ public override Task EventProcessingAsync(EventContext context) if (!context.Event.IsError()) return Task.CompletedTask; - var error = context.Event.GetError(_jsonOptions); + var error = context.Event.GetError(_serializer); if (error is null) return Task.CompletedTask; @@ -40,7 +40,7 @@ public override Task EventProcessingAsync(EventContext context) if (context.HasProperty("UserNamespaces")) userNamespaces = context.GetProperty("UserNamespaces")?.SplitAndTrim([',']); - var signature = new ErrorSignature(error, _jsonOptions, userNamespaces, userCommonMethods); + var signature = new ErrorSignature(error, _serializer, userNamespaces, userCommonMethods); if (signature.SignatureInfo.Count <= 0) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs index 5d3da08178..c849bb258a 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/30_SimpleErrorPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -9,11 +9,11 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(30)] public sealed class SimpleErrorPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public SimpleErrorPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public SimpleErrorPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) @@ -21,7 +21,7 @@ public override Task EventProcessingAsync(EventContext context) if (!context.Event.IsError()) return Task.CompletedTask; - var error = context.Event.GetSimpleError(_jsonOptions); + var error = context.Event.GetSimpleError(_serializer); if (error is null) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs index 56d66b938f..00780b7bc9 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs @@ -1,9 +1,9 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Utility; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -25,12 +25,12 @@ public sealed class RequestInfoPlugin : EventProcessorPluginBase ]; private readonly UserAgentParser _parser; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public RequestInfoPlugin(UserAgentParser parser, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public RequestInfoPlugin(UserAgentParser parser, ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _parser = parser; - _jsonOptions = jsonOptions; + _serializer = serializer; } public override async Task EventBatchProcessingAsync(ICollection contexts) @@ -39,13 +39,13 @@ public override async Task EventBatchProcessingAsync(ICollection c var exclusions = DefaultExclusions.Union(project.Configuration.Settings.GetStringCollection(SettingsDictionary.KnownKeys.DataExclusions)).ToList(); foreach (var context in contexts) { - var request = context.Event.GetRequestInfo(_jsonOptions); + var request = context.Event.GetRequestInfo(_serializer); if (request is null) continue; if (context.IncludePrivateInformation) { - var submissionClient = context.Event.GetSubmissionClient(_jsonOptions); + var submissionClient = context.Event.GetSubmissionClient(_serializer); AddClientIpAddress(request, submissionClient); } else @@ -57,7 +57,7 @@ public override async Task EventBatchProcessingAsync(ICollection c } await SetBrowserOsAndDeviceFromUserAgent(request, context); - context.Event.AddRequestInfo(request.ApplyDataExclusions(exclusions, MAX_VALUE_LENGTH)); + context.Event.AddRequestInfo(request.ApplyDataExclusions(_serializer, exclusions, MAX_VALUE_LENGTH)); } } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs index e3fdcd23b6..84ad218246 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/45_EnvironmentInfoPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor.Default; @@ -9,22 +9,22 @@ namespace Exceptionless.Core.Plugins.EventProcessor.Default; [Priority(45)] public sealed class EnvironmentInfoPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public EnvironmentInfoPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public EnvironmentInfoPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) { - var environment = context.Event.GetEnvironmentInfo(_jsonOptions); + var environment = context.Event.GetEnvironmentInfo(_serializer); if (environment is null) return Task.CompletedTask; if (context.IncludePrivateInformation) { - var submissionClient = context.Event.GetSubmissionClient(_jsonOptions); + var submissionClient = context.Event.GetSubmissionClient(_serializer); AddClientIpAddress(environment, submissionClient); } else diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs index 13bea073bf..2baf6ae591 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/50_GeoPlugin.cs @@ -1,8 +1,8 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Geo; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor.Default; @@ -11,12 +11,12 @@ namespace Exceptionless.Core.Plugins.EventProcessor.Default; public sealed class GeoPlugin : EventProcessorPluginBase { private readonly IGeoIpService _geoIpService; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public GeoPlugin(IGeoIpService geoIpService, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public GeoPlugin(IGeoIpService geoIpService, ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _geoIpService = geoIpService; - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventBatchProcessingAsync(ICollection contexts) @@ -35,7 +35,7 @@ public override Task EventBatchProcessingAsync(ICollection context // The geo coordinates are all the same, set the location from the result of any of the ip addresses. if (!String.IsNullOrEmpty(group.Key)) { - var ips = group.SelectMany(c => c.Event.GetIpAddresses(_jsonOptions)).Union(new[] { group.First().EventPostInfo?.IpAddress }).Distinct().ToList(); + var ips = group.SelectMany(c => c.Event.GetIpAddresses(_serializer)).Union(new[] { group.First().EventPostInfo?.IpAddress }).Distinct().ToList(); if (ips.Count > 0) tasks.Add(UpdateGeoInformationAsync(group, ips)); continue; @@ -44,7 +44,7 @@ public override Task EventBatchProcessingAsync(ICollection context // Each event in the group could be a different user; foreach (var context in group) { - var ips = context.Event.GetIpAddresses(_jsonOptions).Union(new[] { context.EventPostInfo?.IpAddress }).ToList(); + var ips = context.Event.GetIpAddresses(_serializer).Union(new[] { context.EventPostInfo?.IpAddress }).ToList(); if (ips.Count > 0) tasks.Add(UpdateGeoInformationAsync(context, ips)); } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs index 42b0169be7..c2af189be4 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs @@ -1,10 +1,10 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Repositories; using Foundatio.Caching; using Foundatio.Repositories.Utility; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor.Default; @@ -18,21 +18,21 @@ public sealed class SessionPlugin : EventProcessorPluginBase private readonly UpdateStatsAction _updateStats; private readonly AssignToStackAction _assignToStack; private readonly LocationPlugin _locationPlugin; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public SessionPlugin(ICacheClient cacheClient, IEventRepository eventRepository, AssignToStackAction assignToStack, UpdateStatsAction updateStats, LocationPlugin locationPlugin, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public SessionPlugin(ICacheClient cacheClient, IEventRepository eventRepository, AssignToStackAction assignToStack, UpdateStatsAction updateStats, LocationPlugin locationPlugin, ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _cache = new ScopedCacheClient(cacheClient, "session"); _eventRepository = eventRepository; _assignToStack = assignToStack; _updateStats = updateStats; _locationPlugin = locationPlugin; - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventBatchProcessingAsync(ICollection contexts) { - var autoSessionEvents = contexts.Where(c => !String.IsNullOrWhiteSpace(c.Event.GetUserIdentity(_jsonOptions)?.Identity) && String.IsNullOrEmpty(c.Event.GetSessionId())).ToList(); + var autoSessionEvents = contexts.Where(c => !String.IsNullOrWhiteSpace(c.Event.GetUserIdentity(_serializer)?.Identity) && String.IsNullOrEmpty(c.Event.GetSessionId())).ToList(); var manualSessionsEvents = contexts.Where(c => !String.IsNullOrEmpty(c.Event.GetSessionId())).ToList(); return Task.WhenAll( @@ -125,7 +125,7 @@ private async Task ProcessAutoSessionsAsync(ICollection contexts) { var identityGroups = contexts .OrderBy(c => c.Event.Date) - .GroupBy(c => c.Event.GetUserIdentity(_jsonOptions)?.Identity); + .GroupBy(c => c.Event.GetUserIdentity(_serializer)?.Identity); foreach (var identityGroup in identityGroups) { @@ -286,7 +286,7 @@ private Task SetIdentitySessionIdAsync(string projectId, string identity, private async Task CreateSessionStartEventAsync(EventContext startContext, DateTime? lastActivityUtc, bool? isSessionEnd) { - var startEvent = startContext.Event.ToSessionStartEvent(_jsonOptions, lastActivityUtc, isSessionEnd, startContext.Organization.HasPremiumFeatures, startContext.IncludePrivateInformation); + var startEvent = startContext.Event.ToSessionStartEvent(_serializer, lastActivityUtc, isSessionEnd, startContext.Organization.HasPremiumFeatures, startContext.IncludePrivateInformation); var startEventContexts = new List { new(startEvent, startContext.Organization, startContext.Project) }; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs index 5d89be33e9..9c84b8dc66 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/80_AngularPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor; @@ -9,11 +9,11 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(80)] public sealed class AngularPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public AngularPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public AngularPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) @@ -21,7 +21,7 @@ public override Task EventProcessingAsync(EventContext context) if (!context.Event.IsError()) return Task.CompletedTask; - var error = context.Event.GetError(_jsonOptions); + var error = context.Event.GetError(_serializer); if (error is null) return Task.CompletedTask; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs index 757ee3cfdd..d870e19d1a 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/90_RemovePrivateInformationPlugin.cs @@ -1,5 +1,5 @@ -using System.Text.Json; -using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.EventProcessor.Default; @@ -7,11 +7,11 @@ namespace Exceptionless.Core.Plugins.EventProcessor.Default; [Priority(90)] public sealed class RemovePrivateInformationPlugin : EventProcessorPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public RemovePrivateInformationPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public RemovePrivateInformationPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task EventProcessingAsync(EventContext context) @@ -21,7 +21,7 @@ public override Task EventProcessingAsync(EventContext context) context.Event.RemoveUserIdentity(); - var description = context.Event.GetUserDescription(_jsonOptions); + var description = context.Event.GetUserDescription(_serializer); if (description is not null) { description.EmailAddress = null; diff --git a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/GetVersion.cs b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/GetVersion.cs index 229a47452f..41844c6175 100644 --- a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/GetVersion.cs +++ b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/GetVersion.cs @@ -1,7 +1,7 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json.Nodes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Plugins.EventUpgrader; @@ -15,14 +15,14 @@ public void Upgrade(EventUpgraderContext ctx) if (ctx.Version is not null) return; - if (ctx.Documents.Count == 0 || !ctx.Documents.First().HasValues) + if (ctx.Documents.Count == 0 || !ctx.Documents.First().HasValues()) { ctx.Version = new Version(); return; } var doc = ctx.Documents.First(); - if (!(doc["ExceptionlessClientInfo"] is JObject { HasValues: true } clientInfo) || clientInfo["Version"] is null) + if (doc is not JsonObject docObj || docObj["ExceptionlessClientInfo"] is not JsonObject { Count: > 0 } clientInfo || clientInfo["Version"] is null) { ctx.Version = new Version(); return; diff --git a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R500_EventUpgrade.cs b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R500_EventUpgrade.cs index 3b69f5bf7f..c3f83320bd 100644 --- a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R500_EventUpgrade.cs +++ b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R500_EventUpgrade.cs @@ -1,6 +1,6 @@ -using Exceptionless.Core.Pipeline; +using System.Text.Json.Nodes; +using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Plugins.EventUpgrader; @@ -19,14 +19,15 @@ public void Upgrade(EventUpgraderContext ctx) foreach (var doc in ctx.Documents) { - if (!(doc["ExceptionlessClientInfo"] is JObject clientInfo) || !clientInfo.HasValues || clientInfo["InstallDate"] is null) + if (doc is not JsonObject docObj || docObj["ExceptionlessClientInfo"] is not JsonObject { Count: > 0 } clientInfo || clientInfo["InstallDate"] is null) return; // This shouldn't hurt using DateTimeOffset to try and parse a date. It insures you won't lose any info. - if (DateTimeOffset.TryParse(clientInfo["InstallDate"]!.ToString(), out var date)) + if (DateTimeOffset.TryParse(clientInfo["InstallDate"]?.ToString(), out var date)) { clientInfo.Remove("InstallDate"); - clientInfo.Add("InstallDate", new JValue(date)); + // Format date as ISO 8601 with offset (matching Newtonsoft behavior) + clientInfo.Add("InstallDate", JsonValue.Create(date.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFFFzzz"))); } else { diff --git a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R844_EventUpgrade.cs b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R844_EventUpgrade.cs index d2db4115c7..5defe8055b 100644 --- a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R844_EventUpgrade.cs +++ b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R844_EventUpgrade.cs @@ -1,6 +1,6 @@ -using Exceptionless.Core.Pipeline; +using System.Text.Json.Nodes; +using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Plugins.EventUpgrader; @@ -16,26 +16,22 @@ public void Upgrade(EventUpgraderContext ctx) foreach (var doc in ctx.Documents) { - - if (!(doc["RequestInfo"] is JObject { HasValues: true } requestInfo)) + if (doc is not JsonObject docObj || docObj["RequestInfo"] is not JsonObject { Count: > 0 } requestInfo) return; - if (requestInfo["Cookies"] is not null && requestInfo["Cookies"]!.HasValues) + if (requestInfo["Cookies"] is JsonObject { Count: > 0 } cookies) { - if (requestInfo["Cookies"] is JObject cookies) - cookies.Remove(""); + cookies.Remove(""); } - if (requestInfo["Form"] is not null && requestInfo["Form"]!.HasValues) + if (requestInfo["Form"] is JsonObject { Count: > 0 } form) { - if (requestInfo["Form"] is JObject form) - form.Remove(""); + form.Remove(""); } - if (requestInfo["QueryString"] is not null && requestInfo["QueryString"]!.HasValues) + if (requestInfo["QueryString"] is JsonObject { Count: > 0 } queryString) { - if (requestInfo["QueryString"] is JObject queryString) - queryString.Remove(""); + queryString.Remove(""); } } } diff --git a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R850_EventUpgrade.cs b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R850_EventUpgrade.cs index 424ef62010..86596e640f 100644 --- a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R850_EventUpgrade.cs +++ b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V1R850_EventUpgrade.cs @@ -1,7 +1,7 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json.Nodes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Plugins.EventUpgrader; @@ -15,12 +15,12 @@ public void Upgrade(EventUpgraderContext ctx) if (ctx.Version > new Version(1, 0, 0, 850)) return; - foreach (var doc in ctx.Documents.OfType()) + foreach (var doc in ctx.Documents.OfType()) { var current = doc; while (current is not null) { - if (doc["ExtendedData"] is JObject extendedData) + if (doc["ExtendedData"] is JsonObject extendedData) { if (extendedData["ExtraExceptionProperties"] is not null) extendedData.Rename("ExtraExceptionProperties", "__ExceptionInfo"); @@ -32,7 +32,7 @@ public void Upgrade(EventUpgraderContext ctx) extendedData.Rename("TraceInfo", "TraceLog"); } - current = current["Inner"] as JObject; + current = current["Inner"] as JsonObject; } } } diff --git a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V2_EventUpgrade.cs b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V2_EventUpgrade.cs index 5611ccb5b4..96f02f172c 100644 --- a/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V2_EventUpgrade.cs +++ b/src/Exceptionless.Core/Plugins/EventUpgrader/Default/V2_EventUpgrade.cs @@ -1,8 +1,9 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using System.Text.Json.Nodes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Plugins.EventUpgrader; @@ -16,7 +17,7 @@ public void Upgrade(EventUpgraderContext ctx) if (ctx.Version > new Version(2, 0)) return; - foreach (var doc in ctx.Documents.OfType()) + foreach (var doc in ctx.Documents.OfType()) { bool isNotFound = doc.GetPropertyStringValue("Code") == "404"; @@ -36,15 +37,18 @@ public void Upgrade(EventUpgraderContext ctx) doc.Remove("ExceptionlessClientInfo"); if (!doc.RemoveIfNullOrEmpty("Tags")) { - var tags = doc.GetValue("Tags"); - if (tags is not null && tags.Type == JTokenType.Array) + var tags = doc["Tags"]; + if (tags is JsonArray tagsArray) { - foreach (var tag in tags.ToList()) + var tagsToRemove = new List(); + foreach (var tag in tagsArray) { - string t = tag.ToString(); + string? t = tag?.ToString(); if (String.IsNullOrEmpty(t) || t.Length > 255) - tag.Remove(); + tagsToRemove.Add(tag); } + foreach (var tag in tagsToRemove) + tagsArray.Remove(tag); } } @@ -58,7 +62,7 @@ public void Upgrade(EventUpgraderContext ctx) doc.RenameAll("ExtendedData", "Data"); - var extendedData = doc.Property("Data") is not null ? doc.Property("Data")!.Value as JObject : null; + var extendedData = doc["Data"] as JsonObject; if (extendedData is not null) { if (!isNotFound) @@ -73,58 +77,62 @@ public void Upgrade(EventUpgraderContext ctx) if (extendedData?["__ExceptionInfo"] is not null) extendedData.Remove("__ExceptionInfo"); - doc.Add("Type", new JValue("404")); + doc.Add("Type", JsonValue.Create("404")); } else { - var error = new JObject(); + var error = new JsonObject(); if (!doc.RemoveIfNullOrEmpty("Message")) - error.Add("Message", doc["Message"]!.Value()); + { + var messageValue = doc["Message"]?.GetValue(); + if (messageValue is not null) + error.Add("Message", JsonValue.Create(messageValue)); + } error.MoveOrRemoveIfNullOrEmpty(doc, "Code", "Type", "Inner", "StackTrace", "TargetMethod", "Modules"); // Copy the exception info from root extended data to the current errors extended data. if (extendedData?["__ExceptionInfo"] is not null) { - error.Add("Data", new JObject()); - ((JObject)error["Data"]!).MoveOrRemoveIfNullOrEmpty(extendedData, "__ExceptionInfo"); + error.Add("Data", new JsonObject()); + ((JsonObject)error["Data"]!).MoveOrRemoveIfNullOrEmpty(extendedData, "__ExceptionInfo"); } - string? id = doc["Id"]?.Value(); + string? id = doc["Id"]?.GetValue(); RenameAndValidateExtraExceptionProperties(id, error); - var inner = error["Inner"] as JObject; + var inner = error["Inner"] as JsonObject; while (inner is not null) { RenameAndValidateExtraExceptionProperties(id, inner); - inner = inner["Inner"] as JObject; + inner = inner["Inner"] as JsonObject; } - doc.Add("Type", new JValue(isNotFound ? "404" : "error")); + doc.Add("Type", JsonValue.Create(isNotFound ? "404" : "error")); doc.Add("@error", error); } string? emailAddress = doc.GetPropertyStringValueAndRemove("UserEmail"); string? userDescription = doc.GetPropertyStringValueAndRemove("UserDescription"); if (!String.IsNullOrWhiteSpace(emailAddress) && !String.IsNullOrWhiteSpace(userDescription)) - doc.Add("@user_description", JObject.FromObject(new UserDescription(emailAddress, userDescription))); + doc.Add("@user_description", JsonSerializer.SerializeToNode(new UserDescription(emailAddress, userDescription))); string? identity = doc.GetPropertyStringValueAndRemove("UserName"); if (!String.IsNullOrWhiteSpace(identity)) - doc.Add("@user", JObject.FromObject(new UserInfo(identity))); + doc.Add("@user", JsonSerializer.SerializeToNode(new UserInfo(identity))); doc.RemoveAllIfNullOrEmpty("Data", "GenericArguments", "Parameters"); } } - private void RenameAndValidateExtraExceptionProperties(string? id, JObject error) + private void RenameAndValidateExtraExceptionProperties(string? id, JsonObject error) { - var extendedData = error?["Data"] as JObject; + var extendedData = error["Data"] as JsonObject; if (extendedData?["__ExceptionInfo"] is null) return; - string json = extendedData["__ExceptionInfo"]!.ToString(); + string? json = extendedData["__ExceptionInfo"]?.ToString(); extendedData.Remove("__ExceptionInfo"); if (String.IsNullOrWhiteSpace(json)) @@ -136,23 +144,25 @@ private void RenameAndValidateExtraExceptionProperties(string? id, JObject error return; } - var ext = new JObject(); + var ext = new JsonObject(); try { - var extraProperties = JObject.Parse(json); - foreach (var property in extraProperties.Properties()) + var extraProperties = JsonNode.Parse(json) as JsonObject; + if (extraProperties is not null) { - if (property.IsNullOrEmpty()) - continue; - - string dataKey = property.Name; - if (extendedData[dataKey] is not null) - dataKey = "_" + dataKey; + foreach (var property in extraProperties.ToList().Where(p => !p.Value.IsNullOrEmpty())) + { + string dataKey = property.Key; + if (extendedData[dataKey] is not null) + dataKey = "_" + dataKey; - ext.Add(dataKey, property.Value); + // Need to detach the node before adding to another parent + extraProperties.Remove(property.Key); + ext.Add(dataKey, property.Value); + } } } - catch (Exception) { } + catch (JsonException) { } if (ext.IsNullOrEmpty()) return; diff --git a/src/Exceptionless.Core/Plugins/EventUpgrader/EventUpgraderContext.cs b/src/Exceptionless.Core/Plugins/EventUpgrader/EventUpgraderContext.cs index 1d51dd4e3d..70eefc6e3d 100644 --- a/src/Exceptionless.Core/Plugins/EventUpgrader/EventUpgraderContext.cs +++ b/src/Exceptionless.Core/Plugins/EventUpgrader/EventUpgraderContext.cs @@ -1,7 +1,6 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json.Nodes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Utility; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Plugins.EventUpgrader; @@ -12,15 +11,15 @@ public EventUpgraderContext(string json, Version? version = null, bool isMigrati var jsonType = json.GetJsonType(); if (jsonType == JsonType.Object) { - var doc = JsonConvert.DeserializeObject(json); + var doc = JsonNode.Parse(json) as JsonObject; if (doc is not null) - Documents = new JArray(doc); + Documents = new JsonArray(doc); else throw new ArgumentException("Invalid json object specified", nameof(json)); } else if (jsonType == JsonType.Array) { - var docs = JsonConvert.DeserializeObject(json); + var docs = JsonNode.Parse(json) as JsonArray; if (docs is not null) Documents = docs; else @@ -35,21 +34,21 @@ public EventUpgraderContext(string json, Version? version = null, bool isMigrati IsMigration = isMigration; } - public EventUpgraderContext(JObject doc, Version? version = null, bool isMigration = false) + public EventUpgraderContext(JsonObject doc, Version? version = null, bool isMigration = false) { - Documents = new JArray(doc); + Documents = new JsonArray(doc); Version = version; IsMigration = isMigration; } - public EventUpgraderContext(JArray docs, Version? version = null, bool isMigration = false) + public EventUpgraderContext(JsonArray docs, Version? version = null, bool isMigration = false) { Documents = docs; Version = version; IsMigration = isMigration; } - public JArray Documents { get; set; } + public JsonArray Documents { get; set; } public Version? Version { get; set; } public bool IsMigration { get; set; } } diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs index 3f3d70dbf5..00b0e53dbf 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/05_ManualStackingFormattingPlugin.cs @@ -1,6 +1,6 @@ -using System.Text.Json; -using Exceptionless.Core.Models; +using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -8,11 +8,11 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(5)] public sealed class ManualStackingFormattingPlugin : FormattingPluginBase { - public ManualStackingFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public ManualStackingFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } public override string? GetStackTitle(PersistentEvent ev) { - var msi = ev.GetManualStackingInfo(_jsonOptions); + var msi = ev.GetManualStackingInfo(_serializer); return !String.IsNullOrWhiteSpace(msi?.Title) ? msi.Title : null; } } diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs index 8b79ac1c28..f45efd0921 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(10)] public sealed class SimpleErrorFormattingPlugin : FormattingPluginBase { - public SimpleErrorFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public SimpleErrorFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -39,7 +39,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(_jsonOptions); + var error = ev.GetSimpleError(_serializer); return error?.Message; } @@ -48,12 +48,12 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(_jsonOptions); + var error = ev.GetSimpleError(_serializer); if (error is null) return null; var data = new Dictionary { { "Message", ev.Message } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); if (!String.IsNullOrEmpty(error.Type)) { @@ -61,7 +61,7 @@ private bool ShouldHandle(PersistentEvent ev) data.Add("TypeFullName", error.Type); } - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (!String.IsNullOrEmpty(requestInfo?.Path)) data.Add("Path", requestInfo.Path); @@ -73,7 +73,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(_jsonOptions); + var error = ev.GetSimpleError(_serializer); if (error is null) return null; @@ -96,7 +96,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!String.IsNullOrEmpty(errorTypeName)) data.Add("Type", errorTypeName); - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -108,7 +108,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetSimpleError(_jsonOptions); + var error = ev.GetSimpleError(_serializer); if (error is null) return null; @@ -126,7 +126,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("critical ", notificationType); - var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) + var attachment = new SlackMessage.SlackAttachment(ev, _serializer) { Color = "#BB423F", Fields = diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs index 250bdf945c..ac0e0e0710 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(20)] public sealed class ErrorFormattingPlugin : FormattingPluginBase { - public ErrorFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public ErrorFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -21,7 +21,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetError(_jsonOptions); + var error = ev.GetError(_serializer); return error?.Message; } @@ -59,12 +59,12 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var stackingTarget = ev.GetStackingTarget(_jsonOptions); + var stackingTarget = ev.GetStackingTarget(_serializer); if (stackingTarget?.Error is null) return null; var data = new Dictionary { { "Message", ev.Message } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); if (!String.IsNullOrEmpty(stackingTarget.Error.Type)) { @@ -78,7 +78,7 @@ private bool ShouldHandle(PersistentEvent ev) data.Add("MethodFullName", stackingTarget.Method.GetFullName()); } - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (!String.IsNullOrEmpty(requestInfo?.Path)) data.Add("Path", requestInfo.Path); @@ -90,7 +90,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetError(_jsonOptions); + var error = ev.GetError(_serializer); var stackingTarget = error?.GetStackingTarget(); if (stackingTarget?.Error is null) return null; @@ -117,7 +117,7 @@ private bool ShouldHandle(PersistentEvent ev) if (stackingTarget.Method?.Name is not null) data.Add("Method", stackingTarget.Method.Name.Truncate(60)); - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -129,7 +129,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var error = ev.GetError(_jsonOptions); + var error = ev.GetError(_serializer); var stackingTarget = error?.GetStackingTarget(); if (stackingTarget?.Error is null) return null; @@ -148,7 +148,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("critical ", notificationType); - var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) + var attachment = new SlackMessage.SlackAttachment(ev, _serializer) { Color = "#BB423F", Fields = diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs index c3f602bf24..bc628e8a8f 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(30)] public sealed class NotFoundFormattingPlugin : FormattingPluginBase { - public NotFoundFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public NotFoundFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -38,9 +38,9 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "Source", ev.Source } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); - var ips = ev.GetIpAddresses(_jsonOptions).ToList(); + var ips = ev.GetIpAddresses(_serializer).ToList(); if (ips.Count > 0) data.Add("IpAddress", ips); @@ -62,7 +62,7 @@ private bool ShouldHandle(PersistentEvent ev) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); string subject = String.Concat(notificationType, ": ", ev.Source).Truncate(120); - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); var data = new Dictionary { { "Url", requestInfo?.GetFullPath(true, true, true) ?? ev.Source?.Truncate(60) } }; @@ -84,8 +84,8 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("critical ", notificationType); - var requestInfo = ev.GetRequestInfo(_jsonOptions); - var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) + var requestInfo = ev.GetRequestInfo(_serializer); + var attachment = new SlackMessage.SlackAttachment(ev, _serializer) { Color = "#BB423F", Fields = diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs index 6b02b61052..e7c0e0d459 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(40)] public sealed class UsageFormattingPlugin : FormattingPluginBase { - public UsageFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public UsageFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -38,7 +38,7 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "Source", ev.Source } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); return new SummaryData { Id = ev.Id, TemplateKey = "event-feature-summary", Data = data }; } @@ -61,7 +61,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!ShouldHandle(ev)) return null; - var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) + var attachment = new SlackMessage.SlackAttachment(ev, _serializer) { Fields = [ diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs index 1dc5710829..f97b799c9f 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/50_SessionFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(50)] public sealed class SessionFormattingPlugin : FormattingPluginBase { - public SessionFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public SessionFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -41,7 +41,7 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "SessionId", ev.GetSessionId() }, { "Type", ev.Type } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); if (ev.IsSessionStart()) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs index 3eca0c290c..faa20ef2d2 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(60)] public sealed class LogFormattingPlugin : FormattingPluginBase { - public LogFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public LogFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } private bool ShouldHandle(PersistentEvent ev) { @@ -50,7 +50,7 @@ private bool ShouldHandle(PersistentEvent ev) return null; var data = new Dictionary { { "Message", ev.Message } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); if (!String.IsNullOrWhiteSpace(ev.Source)) { @@ -92,7 +92,7 @@ private bool ShouldHandle(PersistentEvent ev) if (!String.IsNullOrEmpty(level)) data.Add("Level", level.Truncate(60)); - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -114,7 +114,7 @@ private bool ShouldHandle(PersistentEvent ev) notificationType = String.Concat("critical ", notificationType); string source = !String.IsNullOrEmpty(ev.Source) ? ev.Source : "(Global)"; - var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions) + var attachment = new SlackMessage.SlackAttachment(ev, _serializer) { Fields = [ @@ -149,7 +149,7 @@ private bool ShouldHandle(PersistentEvent ev) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Level", Value = level.Truncate(60) }); } - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (requestInfo is not null) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Url", Value = requestInfo.GetFullPath(true, true, true) }); diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs index 39ec6593e9..8f6398ac90 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; @@ -9,7 +9,7 @@ namespace Exceptionless.Core.Plugins.Formatting; [Priority(99)] public sealed class DefaultFormattingPlugin : FormattingPluginBase { - public DefaultFormattingPlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(jsonOptions, options, loggerFactory) { } + public DefaultFormattingPlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(serializer, options, loggerFactory) { } public override string GetStackTitle(PersistentEvent ev) { @@ -37,7 +37,7 @@ public override SummaryData GetEventSummaryData(PersistentEvent ev) { "Type", ev.Type } }; - AddUserIdentitySummaryData(data, ev.GetUserIdentity(_jsonOptions)); + AddUserIdentitySummaryData(data, ev.GetUserIdentity(_serializer)); return new SummaryData { Id = ev.Id, TemplateKey = "event-summary", Data = data }; } @@ -68,7 +68,7 @@ public override MailMessageData GetEventNotificationMailMessageData(PersistentEv if (!String.IsNullOrEmpty(ev.Source)) data.Add("Source", ev.Source.Truncate(60)); - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (requestInfo is not null) data.Add("Url", requestInfo.GetFullPath(true, true, true)); @@ -90,7 +90,7 @@ public override SlackMessage GetSlackEventNotification(PersistentEvent ev, Proje if (isCritical) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); - var attachment = new SlackMessage.SlackAttachment(ev, _jsonOptions); + var attachment = new SlackMessage.SlackAttachment(ev, _serializer); if (!String.IsNullOrEmpty(ev.Message)) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Message", Value = ev.Message.Truncate(60) }); diff --git a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs index 926a83eb95..f5cb2a3791 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs @@ -1,18 +1,18 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.Formatting; public abstract class FormattingPluginBase : PluginBase, IFormattingPlugin { - protected readonly JsonSerializerOptions _jsonOptions; + protected readonly ITextSerializer _serializer; - public FormattingPluginBase(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public FormattingPluginBase(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public virtual SummaryData? GetStackSummaryData(Stack stack) @@ -42,7 +42,7 @@ public FormattingPluginBase(JsonSerializerOptions jsonOptions, AppOptions option protected void AddDefaultSlackFields(PersistentEvent ev, List attachmentFields, bool includeUrl = true) { - var requestInfo = ev.GetRequestInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); if (requestInfo is not null && includeUrl) attachmentFields.Add(new SlackMessage.SlackAttachmentFields { Title = "Url", Value = requestInfo.GetFullPath(true, true, true) }); diff --git a/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs b/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs index 45c031a826..2d4dc04ec5 100644 --- a/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs +++ b/src/Exceptionless.Core/Plugins/WebHook/Default/005_SlackPlugin.cs @@ -1,6 +1,6 @@ -using System.Text.Json; -using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Pipeline; using Exceptionless.Core.Plugins.Formatting; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.WebHook; @@ -9,12 +9,12 @@ namespace Exceptionless.Core.Plugins.WebHook; public sealed class SlackPlugin : WebHookDataPluginBase { private readonly FormattingPluginManager _pluginManager; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public SlackPlugin(FormattingPluginManager pluginManager, JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public SlackPlugin(FormattingPluginManager pluginManager, ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _pluginManager = pluginManager; - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task CreateFromEventAsync(WebHookDataContext ctx) @@ -22,7 +22,7 @@ public SlackPlugin(FormattingPluginManager pluginManager, JsonSerializerOptions if (String.IsNullOrEmpty(ctx.WebHook.Url) || !ctx.WebHook.Url.EndsWith("/slack")) return Task.FromResult(null); - var error = ctx.Event?.GetError(_jsonOptions); + var error = ctx.Event?.GetError(_serializer); if (error is null) { ctx.IsCancelled = true; diff --git a/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs b/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs index c845dcd872..3ed402c562 100644 --- a/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs +++ b/src/Exceptionless.Core/Plugins/WebHook/Default/010_VersionOnePlugin.cs @@ -1,7 +1,8 @@ -using System.Text.Json; +using System.Text.Json.Serialization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; +using Foundatio.Serializer; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Plugins.WebHook; @@ -9,11 +10,11 @@ namespace Exceptionless.Core.Plugins.WebHook; [Priority(10)] public sealed class VersionOnePlugin : WebHookDataPluginBase { - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; - public VersionOnePlugin(JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public VersionOnePlugin(ITextSerializer serializer, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { - _jsonOptions = jsonOptions; + _serializer = serializer; } public override Task CreateFromEventAsync(WebHookDataContext ctx) @@ -21,13 +22,13 @@ public VersionOnePlugin(JsonSerializerOptions jsonOptions, AppOptions options, I if (!String.Equals(ctx.WebHook.Version, Models.WebHook.KnownVersions.Version1)) return Task.FromResult(null); - var error = ctx.Event?.GetError(_jsonOptions); + var error = ctx.Event?.GetError(_serializer); if (error is null) return Task.FromResult(null); var ev = ctx.Event!; - var requestInfo = ev.GetRequestInfo(_jsonOptions); - var environmentInfo = ev.GetEnvironmentInfo(_jsonOptions); + var requestInfo = ev.GetRequestInfo(_serializer); + var environmentInfo = ev.GetEnvironmentInfo(_serializer); return Task.FromResult(new VersionOneWebHookEvent(_options.BaseURL) { @@ -97,33 +98,61 @@ public VersionOneWebHookEvent(string baseUrl) _baseUrl = baseUrl; } + [JsonPropertyName("Id")] public string Id { get; init; } = null!; + [JsonPropertyName("Url")] public string Url => String.Concat(_baseUrl, "/event/", Id); + [JsonPropertyName("OccurrenceDate")] public DateTimeOffset OccurrenceDate { get; init; } + [JsonPropertyName("Tags")] public TagSet? Tags { get; init; } = null!; + [JsonPropertyName("MachineName")] public string? MachineName { get; init; } + [JsonPropertyName("RequestPath")] public string? RequestPath { get; init; } + [JsonPropertyName("IpAddress")] public string? IpAddress { get; init; } + [JsonPropertyName("Message")] public string? Message { get; init; } = null!; + [JsonPropertyName("Type")] public string? Type { get; init; } = null!; + [JsonPropertyName("Code")] public string? Code { get; init; } = null!; + [JsonPropertyName("TargetMethod")] public string? TargetMethod { get; init; } + [JsonPropertyName("ProjectId")] public string ProjectId { get; init; } = null!; + [JsonPropertyName("ProjectName")] public string ProjectName { get; init; } = null!; + [JsonPropertyName("OrganizationId")] public string OrganizationId { get; init; } = null!; + [JsonPropertyName("OrganizationName")] public string OrganizationName { get; init; } = null!; + [JsonPropertyName("ErrorStackId")] public string ErrorStackId { get; init; } = null!; + [JsonPropertyName("ErrorStackStatus")] public StackStatus ErrorStackStatus { get; init; } + [JsonPropertyName("ErrorStackUrl")] public string ErrorStackUrl => String.Concat(_baseUrl, "/stack/", ErrorStackId); + [JsonPropertyName("ErrorStackTitle")] public string ErrorStackTitle { get; init; } = null!; + [JsonPropertyName("ErrorStackDescription")] public string? ErrorStackDescription { get; init; } = null!; + [JsonPropertyName("ErrorStackTags")] public TagSet ErrorStackTags { get; init; } = null!; + [JsonPropertyName("TotalOccurrences")] public int TotalOccurrences { get; init; } + [JsonPropertyName("FirstOccurrence")] public DateTime FirstOccurrence { get; init; } + [JsonPropertyName("LastOccurrence")] public DateTime LastOccurrence { get; init; } + [JsonPropertyName("DateFixed")] public DateTime? DateFixed { get; init; } + [JsonPropertyName("IsNew")] public bool IsNew { get; init; } + [JsonPropertyName("IsRegression")] public bool IsRegression { get; init; } + [JsonPropertyName("IsCritical")] public bool IsCritical => Tags is not null && Tags.Contains("Critical"); } @@ -136,26 +165,45 @@ public VersionOneWebHookStack(string baseUrl) _baseUrl = baseUrl; } + [JsonPropertyName("Id")] public string Id { get; init; } = null!; + [JsonPropertyName("Status")] public StackStatus Status { get; init; } + [JsonPropertyName("Url")] public string Url => String.Concat(_baseUrl, "/stack/", Id); + [JsonPropertyName("Title")] public string Title { get; init; } = null!; + [JsonPropertyName("Description")] public string? Description { get; init; } = null!; - + [JsonPropertyName("Tags")] public TagSet Tags { get; init; } = null!; + [JsonPropertyName("RequestPath")] public string? RequestPath { get; init; } + [JsonPropertyName("Type")] public string? Type { get; init; } + [JsonPropertyName("TargetMethod")] public string? TargetMethod { get; init; } + [JsonPropertyName("ProjectId")] public string ProjectId { get; init; } = null!; + [JsonPropertyName("ProjectName")] public string ProjectName { get; init; } = null!; + [JsonPropertyName("OrganizationId")] public string OrganizationId { get; init; } = null!; + [JsonPropertyName("OrganizationName")] public string OrganizationName { get; init; } = null!; + [JsonPropertyName("TotalOccurrences")] public int TotalOccurrences { get; init; } + [JsonPropertyName("FirstOccurrence")] public DateTime FirstOccurrence { get; init; } + [JsonPropertyName("LastOccurrence")] public DateTime LastOccurrence { get; init; } + [JsonPropertyName("DateFixed")] public DateTime? DateFixed { get; init; } + [JsonPropertyName("FixedInVersion")] public string? FixedInVersion { get; init; } + [JsonPropertyName("IsRegression")] public bool IsRegression { get; init; } + [JsonPropertyName("IsCritical")] public bool IsCritical { get; init; } } } diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 4641056b31..5da06ca9ad 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -14,19 +14,19 @@ using Foundatio.Resilience; using Microsoft.Extensions.Logging; using Nest; -using Newtonsoft.Json; +using System.Text.Json; namespace Exceptionless.Core.Repositories.Configuration; public sealed class ExceptionlessElasticConfiguration : ElasticConfiguration, IStartupAction { private readonly AppOptions _appOptions; - private readonly JsonSerializerSettings _serializerSettings; + private readonly JsonSerializerOptions _jsonSerializerOptions; public ExceptionlessElasticConfiguration( AppOptions appOptions, IQueue workItemQueue, - JsonSerializerSettings serializerSettings, + JsonSerializerOptions jsonSerializerOptions, ICacheClient cacheClient, IMessageBus messageBus, IServiceProvider serviceProvider, @@ -36,7 +36,7 @@ ILoggerFactory loggerFactory ) : base(workItemQueue, cacheClient, messageBus, timeProvider, resiliencePolicyProvider, loggerFactory) { _appOptions = appOptions; - _serializerSettings = serializerSettings; + _jsonSerializerOptions = jsonSerializerOptions; _logger.LogInformation("All new indexes will be created with {ElasticsearchNumberOfShards} Shards and {ElasticsearchNumberOfReplicas} Replicas", _appOptions.ElasticsearchOptions.NumberOfShards, _appOptions.ElasticsearchOptions.NumberOfReplicas); AddIndex(Stacks = new StackIndex(this)); @@ -78,7 +78,8 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder) protected override IElasticClient CreateElasticClient() { var connectionPool = CreateConnectionPool(); - var settings = new ConnectionSettings(connectionPool, (serializer, values) => new ElasticJsonNetSerializer(serializer, values, _serializerSettings)); + var serializer = new ElasticSystemTextJsonSerializer(_jsonSerializerOptions); + var settings = new ConnectionSettings(connectionPool, (_, _) => serializer); ConfigureSettings(settings); foreach (var index in Indexes) diff --git a/src/Exceptionless.Core/Serialization/DataObjectConverter.cs b/src/Exceptionless.Core/Serialization/DataObjectConverter.cs deleted file mode 100644 index 559c952253..0000000000 --- a/src/Exceptionless.Core/Serialization/DataObjectConverter.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using Exceptionless.Core.Extensions; -using Exceptionless.Core.Models; -using Exceptionless.Core.Reflection; -using Foundatio.Repositories.Extensions; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; - -namespace Exceptionless.Serializer; - -public class DataObjectConverter : CustomCreationConverter where T : IData, new() -{ - private static readonly Type _type = typeof(T); - private static readonly ConcurrentDictionary _propertyAccessors = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _dataTypeRegistry = new(StringComparer.OrdinalIgnoreCase); - private readonly ILogger _logger; - private readonly char[] _filteredChars = ['.', '-', '_']; - - public DataObjectConverter(ILogger logger, IEnumerable>? knownDataTypes = null) - { - _logger = logger; - - if (knownDataTypes is not null) - _dataTypeRegistry.AddRange(knownDataTypes); - - if (_propertyAccessors.Count != 0) - return; - - foreach (var prop in _type.GetProperties(BindingFlags.Instance | BindingFlags.FlattenHierarchy | BindingFlags.Public).Where(p => p.CanWrite)) - _propertyAccessors.TryAdd(prop.Name, LateBinder.GetPropertyAccessor(prop)); - } - - public void AddKnownDataType(string name, Type dataType) - { - _dataTypeRegistry.TryAdd(name, dataType); - } - - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) - { - var target = Create(objectType); - var json = JObject.Load(reader); - - foreach (var p in json.Properties()) - { - string propertyName = p.Name.ToLowerFiltered(_filteredChars); - - if (propertyName == "data" && p.Value is JObject) - { - foreach (var dataProp in ((JObject)p.Value).Properties()) - AddDataEntry(serializer, dataProp, target); - - continue; - } - - var accessor = _propertyAccessors.TryGetValue(propertyName, out var value) ? value : null; - if (accessor is not null) - { - if (p.Value.Type == JTokenType.None || p.Value.Type == JTokenType.Undefined) - continue; - - if (p.Value.Type == JTokenType.Null) - { - accessor.SetValue(target, null); - continue; - } - - if (accessor.MemberType == typeof(DateTime)) - { - if (p.Value.Type == JTokenType.Date || p.Value.Type == JTokenType.String && p.Value.Value()!.Contains("+")) - { - accessor.SetValue(target, p.Value.ToObject(serializer).DateTime); - continue; - } - } - else if (accessor.MemberType == typeof(DateTime?)) - { - if (p.Value.Type == JTokenType.Date || p.Value.Type == JTokenType.String && p.Value.Value()!.Contains("+")) - { - var offset = p.Value.ToObject(serializer); - accessor.SetValue(target, offset?.DateTime); - continue; - } - } - - accessor.SetValue(target, p.Value.ToObject(accessor.MemberType, serializer)); - continue; - } - - AddDataEntry(serializer, p, target); - } - - return target; - } - - private void AddDataEntry(JsonSerializer serializer, JProperty p, T target) - { - if (target.Data is null) - target.Data = new DataDictionary(); - - string dataKey = GetDataKey(target.Data, p.Name); - string unknownTypeDataKey = GetDataKey(target.Data, p.Name, true); - - // when adding items to data, see if they are a known type and deserialize to the registered type - if (_dataTypeRegistry.TryGetValue(p.Name, out var dataType)) - { - try - { - if (p.Value is JValue && p.Value.Type == JTokenType.String) - { - string value = p.Value.ToString(); - if (value.IsJson()) - target.Data[dataKey] = serializer.Deserialize(new StringReader(value), dataType); - else - target.Data[dataType == value.GetType() ? dataKey : unknownTypeDataKey] = value; - } - else - { - target.Data[dataKey] = p.Value.ToObject(dataType, serializer); - } - - return; - } - catch (Exception) - { - _logger.LogInformation("Error deserializing known data type {Name}: {Value}", p.Name, p.Value.ToString()); - } - } - - // Add item to data as a JObject, JArray or native type. - if (p.Value is JObject) - { - target.Data[dataType is null || dataType == typeof(JObject) ? dataKey : unknownTypeDataKey] = p.Value.ToObject(); - } - else if (p.Value is JArray) - { - target.Data[dataType is null || dataType == typeof(JArray) ? dataKey : unknownTypeDataKey] = p.Value.ToObject(); - } - else if (p.Value is JValue jValue && jValue.Type != JTokenType.String) - { - object? value = jValue.Value; - target.Data[dataType is null || dataType == value?.GetType() ? dataKey : unknownTypeDataKey] = value; - } - else - { - string value = p.Value.ToString(); - var jsonType = value.GetJsonType(); - if (jsonType == JsonType.Object) - { - if (value.TryFromJson(out JObject? obj)) - target.Data[dataType is null || dataType == obj?.GetType() ? dataKey : unknownTypeDataKey] = obj; - else - target.Data[dataType is null || dataType == value.GetType() ? dataKey : unknownTypeDataKey] = value; - } - else if (jsonType == JsonType.Array) - { - if (value.TryFromJson(out JArray? obj)) - target.Data[dataType is null || dataType == obj?.GetType() ? dataKey : unknownTypeDataKey] = obj; - else - target.Data[dataType is null || dataType == value.GetType() ? dataKey : unknownTypeDataKey] = value; - } - else - { - target.Data[dataType is null || dataType == value.GetType() ? dataKey : unknownTypeDataKey] = value; - } - } - } - - private string GetDataKey(DataDictionary data, string dataKey, bool isUnknownType = false) - { - if (data.ContainsKey(dataKey) || isUnknownType) - dataKey = dataKey.StartsWith("@") ? "_" + dataKey : dataKey; - - int count = 1; - string key = dataKey; - while (data.ContainsKey(key) || (isUnknownType && _dataTypeRegistry.ContainsKey(key))) - key = dataKey + count++; - - return key; - } - - public override T Create(Type objectType) - { - return new T(); - } - - public override bool CanRead => true; - - public override bool CanWrite => false; - - public override bool CanConvert(Type objectType) - { - return objectType == _type; - } -} diff --git a/src/Exceptionless.Core/Serialization/DynamicTypeContractResolver.cs b/src/Exceptionless.Core/Serialization/DynamicTypeContractResolver.cs deleted file mode 100644 index cde0f17ccb..0000000000 --- a/src/Exceptionless.Core/Serialization/DynamicTypeContractResolver.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Reflection; -using Foundatio.Repositories.Extensions; -using Newtonsoft.Json.Serialization; - -namespace Exceptionless.Serializer; - -public class DynamicTypeContractResolver : IContractResolver -{ - private readonly HashSet _assemblies = new(); - private readonly HashSet _types = new(); - - private readonly IContractResolver _defaultResolver; - private readonly IContractResolver _resolver; - - public DynamicTypeContractResolver(IContractResolver resolver, IContractResolver? defaultResolver = null) - { - _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); - _defaultResolver = defaultResolver ?? new DefaultContractResolver(); - } - - public void UseDefaultResolverFor(params Assembly[] assemblies) - { - _assemblies.AddRange(assemblies); - } - - public void UseDefaultResolverFor(params Type[] types) - { - _types.AddRange(types); - } - - public JsonContract ResolveContract(Type type) - { - if (_types.Contains(type) || _assemblies.Contains(type.Assembly)) - return _defaultResolver.ResolveContract(type); - - return _resolver.ResolveContract(type); - } -} diff --git a/src/Exceptionless.Core/Serialization/ElasticConnectionSettingsAwareContractResolver.cs b/src/Exceptionless.Core/Serialization/ElasticConnectionSettingsAwareContractResolver.cs deleted file mode 100644 index 541befc681..0000000000 --- a/src/Exceptionless.Core/Serialization/ElasticConnectionSettingsAwareContractResolver.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Reflection; -using Exceptionless.Core.Extensions; -using Nest; -using Nest.JsonNetSerializer; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - -namespace Exceptionless.Core.Serialization; - -public class ElasticConnectionSettingsAwareContractResolver : ConnectionSettingsAwareContractResolver -{ - public ElasticConnectionSettingsAwareContractResolver(IConnectionSettingsValues connectionSettings) : base(connectionSettings) { } - - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var property = base.CreateProperty(member, memberSerialization); - - var shouldSerialize = property.ShouldSerialize; - property.ShouldSerialize = obj => (shouldSerialize is null || shouldSerialize(obj)) && !property.IsValueEmptyCollection(obj); - return property; - } - - protected override JsonDictionaryContract CreateDictionaryContract(Type objectType) - { - var contract = base.CreateDictionaryContract(objectType); - contract.DictionaryKeyResolver = propertyName => propertyName; - return contract; - } - - protected override string ResolvePropertyName(string propertyName) - { - return propertyName.ToLowerUnderscoredWords(); - } -} diff --git a/src/Exceptionless.Core/Serialization/ElasticJsonNetSerializer.cs b/src/Exceptionless.Core/Serialization/ElasticJsonNetSerializer.cs deleted file mode 100644 index 403a2329e8..0000000000 --- a/src/Exceptionless.Core/Serialization/ElasticJsonNetSerializer.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Elasticsearch.Net; -using Nest; -using Nest.JsonNetSerializer; -using Newtonsoft.Json; - -namespace Exceptionless.Core.Serialization; - -public class ElasticJsonNetSerializer : JsonNetSerializer -{ - public ElasticJsonNetSerializer( - IElasticsearchSerializer builtinSerializer, - IConnectionSettingsValues connectionSettings, - JsonSerializerSettings serializerSettings - ) : base( - builtinSerializer, - connectionSettings, - () => CreateJsonSerializerSettings(serializerSettings), - contractJsonConverters: serializerSettings.Converters.ToList() - ) - { - } - - private static JsonSerializerSettings CreateJsonSerializerSettings(JsonSerializerSettings serializerSettings) - { - return new JsonSerializerSettings - { - DateParseHandling = serializerSettings.DateParseHandling, - DefaultValueHandling = serializerSettings.DefaultValueHandling, - MissingMemberHandling = serializerSettings.MissingMemberHandling, - NullValueHandling = serializerSettings.NullValueHandling - }; - } - - protected override ConnectionSettingsAwareContractResolver CreateContractResolver() - { - // TODO: Verify we don't need to use the DynamicTypeContractResolver. - var resolver = new ElasticConnectionSettingsAwareContractResolver(ConnectionSettings); - ModifyContractResolver(resolver); - return resolver; - } -} diff --git a/src/Exceptionless.Core/Serialization/ElasticSystemTextJsonSerializer.cs b/src/Exceptionless.Core/Serialization/ElasticSystemTextJsonSerializer.cs new file mode 100644 index 0000000000..8a20598845 --- /dev/null +++ b/src/Exceptionless.Core/Serialization/ElasticSystemTextJsonSerializer.cs @@ -0,0 +1,311 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Elasticsearch.Net; + +namespace Exceptionless.Core.Serialization; + +/// +/// System.Text.Json serializer for Elasticsearch NEST client. +/// +/// This serializer implements to enable the NEST 7.x +/// client to use System.Text.Json instead of Newtonsoft.Json for document serialization. +/// +/// Why custom converters are needed: +/// +/// +/// DynamicDictionary +/// +/// Elasticsearch returns dynamic responses as which STJ +/// doesn't know how to serialize/deserialize. This converter handles the round-trip. +/// +/// +/// +/// DateTime/DateTimeOffset +/// +/// Elasticsearch expects ISO 8601 dates. STJ's default output uses "Z" suffix for UTC +/// while we need explicit "+00:00" for consistency with stored data. +/// +/// +/// +/// +/// Thread Safety: This class is thread-safe. Options are lazily initialized once. +/// +public sealed class ElasticSystemTextJsonSerializer : IElasticsearchSerializer +{ + private readonly Lazy _optionsIndented; + private readonly Lazy _optionsCompact; + + /// + /// Creates a new serializer instance. + /// + /// + /// Optional base options to extend. If provided, these options are cloned and augmented + /// with Elasticsearch-specific converters. If null, default options are used. + /// + public ElasticSystemTextJsonSerializer(JsonSerializerOptions? baseOptions = null) + { + _optionsIndented = new Lazy(() => CreateOptions(baseOptions, writeIndented: true)); + _optionsCompact = new Lazy(() => CreateOptions(baseOptions, writeIndented: false)); + } + + private static JsonSerializerOptions CreateOptions(JsonSerializerOptions? baseOptions, bool writeIndented) + { + var options = baseOptions is not null + ? new JsonSerializerOptions(baseOptions) + : new JsonSerializerOptions(); + + // Elasticsearch convention: don't serialize null values + options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.WriteIndented = writeIndented; + + // Replace the default ObjectToInferredTypesConverter with one that returns Int64 + // for all integers, matching JSON.NET DataObjectConverter behavior. This ensures + // Event.Data values round-trip through Elasticsearch with consistent types. + var defaultConverter = options.Converters.FirstOrDefault(c => c is ObjectToInferredTypesConverter); + if (defaultConverter is not null) + options.Converters.Remove(defaultConverter); + options.Converters.Insert(0, new ObjectToInferredTypesConverter(preferInt64: true)); + + // Insert Elasticsearch converters for priority + // Order matters: more specific converters should come first + options.Converters.Insert(1, new DynamicDictionaryConverter()); + options.Converters.Insert(2, new Iso8601DateTimeOffsetConverter()); + options.Converters.Insert(3, new Iso8601DateTimeConverter()); + + return options; + } + + private JsonSerializerOptions GetOptions(SerializationFormatting formatting) => + formatting == SerializationFormatting.Indented ? _optionsIndented.Value : _optionsCompact.Value; + + #region Synchronous API + + /// + public object? Deserialize(Type type, Stream stream) + { + if (IsEmptyStream(stream)) + return null; + + // Fast path: MemoryStream with accessible buffer avoids buffering + if (stream is MemoryStream ms && ms.TryGetBuffer(out var segment)) + return JsonSerializer.Deserialize(segment.AsSpan((int)ms.Position), type, _optionsCompact.Value); + + return JsonSerializer.Deserialize(stream, type, _optionsCompact.Value); + } + + /// + public T? Deserialize(Stream stream) + { + if (IsEmptyStream(stream)) + return default; + + // Fast path: MemoryStream with accessible buffer avoids buffering + if (stream is MemoryStream ms && ms.TryGetBuffer(out var segment)) + return JsonSerializer.Deserialize(segment.AsSpan((int)ms.Position), _optionsCompact.Value); + + return JsonSerializer.Deserialize(stream, _optionsCompact.Value); + } + + /// + public void Serialize(T data, Stream stream, SerializationFormatting formatting = SerializationFormatting.None) + { + var options = GetOptions(formatting); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Indented = formatting == SerializationFormatting.Indented, + Encoder = options.Encoder + }); + + if (data is null) + { + JsonSerializer.Serialize(writer, (object?)null, typeof(object), options); + } + else + { + // Use runtime type to ensure proper polymorphic serialization + JsonSerializer.Serialize(writer, data, data.GetType(), options); + } + } + + #endregion + + #region Asynchronous API + + /// + public async Task DeserializeAsync(Type type, Stream stream, CancellationToken cancellationToken = default) + { + if (IsEmptyStream(stream)) + return null; + + return await JsonSerializer.DeserializeAsync(stream, type, _optionsCompact.Value, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task DeserializeAsync(Stream stream, CancellationToken cancellationToken = default) + { + if (IsEmptyStream(stream)) + return default; + + return await JsonSerializer.DeserializeAsync(stream, _optionsCompact.Value, cancellationToken) + .ConfigureAwait(false); + } + + /// + public Task SerializeAsync( + T data, + Stream stream, + SerializationFormatting formatting = SerializationFormatting.None, + CancellationToken cancellationToken = default) + { + var options = GetOptions(formatting); + + if (data is null) + { + return JsonSerializer.SerializeAsync(stream, (object?)null, typeof(object), options, cancellationToken); + } + + return JsonSerializer.SerializeAsync(stream, data, data.GetType(), options, cancellationToken); + } + + #endregion + + private static bool IsEmptyStream(Stream? stream) + { + return stream is null || stream == Stream.Null || (stream.CanSeek && stream.Length == 0); + } +} + +#region Elasticsearch-Specific Converters + +/// +/// Converts to/from JSON. +/// +/// Why this converter exists: +/// Elasticsearch.Net uses for dynamic responses (e.g., script fields, +/// aggregation buckets). STJ has no built-in support for this type, so we must provide custom +/// serialization logic. +/// +/// Serialization: Writes as a JSON object with key-value pairs. +/// Deserialization: Reads JSON objects/arrays into DynamicDictionary. +/// +internal sealed class DynamicDictionaryConverter : JsonConverter +{ + public override DynamicDictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartArray => ReadFromArray(ref reader, options), + JsonTokenType.StartObject => ReadFromObject(ref reader, options), + JsonTokenType.Null => null, + _ => throw new JsonException($"Unexpected token type {reader.TokenType} when deserializing DynamicDictionary") + }; + } + + private static DynamicDictionary ReadFromArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var array = JsonSerializer.Deserialize(ref reader, options); + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (array is not null) + { + for (var i = 0; i < array.Length; i++) + { + dict[i.ToString(CultureInfo.InvariantCulture)] = new DynamicValue(array[i]); + } + } + + return DynamicDictionary.Create(dict); + } + + private static DynamicDictionary ReadFromObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var dict = JsonSerializer.Deserialize>(ref reader, options); + return dict is not null ? DynamicDictionary.Create(dict!) : new DynamicDictionary(); + } + + public override void Write(Utf8JsonWriter writer, DynamicDictionary dictionary, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (var (key, dynamicValue) in dictionary.GetKeyValues()) + { + // Skip null values (consistent with DefaultIgnoreCondition.WhenWritingNull) + if (dynamicValue?.Value is null) + continue; + + writer.WritePropertyName(key); + JsonSerializer.Serialize(writer, dynamicValue.Value, options); + } + + writer.WriteEndObject(); + } +} + +/// +/// Converts to/from ISO 8601 format for Elasticsearch. +/// +/// Why this converter exists: +/// Elasticsearch indexes dates in ISO 8601 format. While STJ handles DateTime correctly, +/// this converter ensures consistent UTC conversion and format across the application. +/// +/// Write behavior: Converts to UTC and outputs in round-trip format ("O"). +/// Read behavior: Parses ISO 8601 strings with culture-invariant settings. +/// +internal sealed class Iso8601DateTimeConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return default; + + var dateString = reader.GetString(); + if (string.IsNullOrEmpty(dateString)) + return default; + + // Parse with DateTimeStyles to handle various ISO 8601 formats + return DateTime.Parse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + // Always output in UTC with round-trip format for Elasticsearch compatibility + var utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); + writer.WriteStringValue(utcValue.ToString("O", CultureInfo.InvariantCulture)); + } +} + +/// +/// Converts to/from ISO 8601 format for Elasticsearch. +/// +/// Why this converter exists: +/// DateTimeOffset preserves timezone offset information. This converter ensures the offset +/// is written in the explicit "+HH:mm" format (e.g., "+00:00") rather than "Z" for consistency +/// with historical data serialized by Newtonsoft.Json. +/// +/// Write behavior: Outputs in round-trip format ("O") preserving offset. +/// Read behavior: Parses ISO 8601 strings with culture-invariant settings. +/// +internal sealed class Iso8601DateTimeOffsetConverter : JsonConverter +{ + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return default; + + var dateString = reader.GetString(); + if (string.IsNullOrEmpty(dateString)) + return default; + + return DateTimeOffset.Parse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + // Round-trip format preserves the exact offset (e.g., +00:00, -05:00) + writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); + } +} + +#endregion diff --git a/src/Exceptionless.Core/Serialization/EmptyCollectionModifier.cs b/src/Exceptionless.Core/Serialization/EmptyCollectionModifier.cs new file mode 100644 index 0000000000..e13fe8d599 --- /dev/null +++ b/src/Exceptionless.Core/Serialization/EmptyCollectionModifier.cs @@ -0,0 +1,66 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Exceptionless.Core.Serialization; + +/// +/// A type info modifier that skips empty collections during serialization to match Newtonsoft's behavior. +/// +public static class EmptyCollectionModifier +{ + /// + /// Modifies JSON type info to skip empty collections/dictionaries during serialization. + /// + public static void SkipEmptyCollections(JsonTypeInfo typeInfo) + { + foreach (var property in typeInfo.Properties) + { + // For properties typed as IEnumerable (but not string), check at compile time + if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType) && property.PropertyType != typeof(string)) + { + property.ShouldSerialize = (obj, value) => !IsEmptyCollection(value); + } + // For object-typed properties, check the runtime value + else if (property.PropertyType == typeof(object)) + { + var originalShouldSerialize = property.ShouldSerialize; + property.ShouldSerialize = (obj, value) => + { + // First check original condition if any + if (originalShouldSerialize is not null && !originalShouldSerialize(obj, value)) + return false; + + // Then check if runtime value is an empty collection + return !IsEmptyCollection(value); + }; + } + } + } + + private static bool IsEmptyCollection(object? value) + { + return value switch + { + null => true, + string => false, // strings are IEnumerable but should not be treated as collections + ICollection { Count: 0 } => true, + IEnumerable enumerable => !HasAnyElement(enumerable), + _ => false + }; + } + + private static bool HasAnyElement(IEnumerable enumerable) + { + var enumerator = enumerable.GetEnumerator(); + try + { + return enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } +} diff --git a/src/Exceptionless.Core/Serialization/ExceptionlessNamingStrategy.cs b/src/Exceptionless.Core/Serialization/ExceptionlessNamingStrategy.cs deleted file mode 100644 index d677cf08d8..0000000000 --- a/src/Exceptionless.Core/Serialization/ExceptionlessNamingStrategy.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Exceptionless.Core.Extensions; -using Newtonsoft.Json.Serialization; - -namespace Exceptionless.Core.Serialization; - -public class ExceptionlessNamingStrategy : SnakeCaseNamingStrategy -{ - protected override string ResolvePropertyName(string name) - { - return name.ToLowerUnderscoredWords(); - } -} diff --git a/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs b/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs index 7a05de2ae7..002fb0cd73 100644 --- a/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs +++ b/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs @@ -1,5 +1,8 @@ +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.Unicode; namespace Exceptionless.Core.Serialization; @@ -9,15 +12,34 @@ namespace Exceptionless.Core.Serialization; public static class JsonSerializerOptionsExtensions { /// - /// Configures with Exceptionless conventions: + /// Configures with Exceptionless conventions for WRITING: /// snake_case property naming, null value handling, and dynamic object support. /// + /// + /// + /// IMPORTANT: These options include a that applies + /// to BOTH serialization and deserialization. The options use PropertyNameCaseInsensitive + /// to support matching both PascalCase and snake_case JSON property names. + /// + /// + /// STJ's transforms C# property names + /// before matching against JSON property names. For example, with our snake_case policy, + /// MachineName becomes machine_name, which won't match a JSON property named + /// "MachineName" even with enabled. + /// + /// /// The options to configure. /// The configured options for chaining. public static JsonSerializerOptions ConfigureExceptionlessDefaults(this JsonSerializerOptions options) { options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance; + options.PropertyNameCaseInsensitive = true; + + // XSS-safe encoder: escapes <, >, &, ' while allowing Unicode characters + // This protects against script injection when JSON is embedded in HTML/JavaScript + options.Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); + options.Converters.Add(new ObjectToInferredTypesConverter()); // Ensures tuples and records are serialized with their field names instead of "Item1", "Item2", etc. @@ -27,6 +49,12 @@ public static JsonSerializerOptions ConfigureExceptionlessDefaults(this JsonSeri // If you see "cannot be null" errors, fix the model's nullability annotation or the data. options.RespectNullableAnnotations = true; + // Skip empty collections during serialization to match Newtonsoft behavior + options.TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = { EmptyCollectionModifier.SkipEmptyCollections } + }; + return options; } } diff --git a/src/Exceptionless.Core/Serialization/LowerCaseUnderscorePropertyNamesContractResolver.cs b/src/Exceptionless.Core/Serialization/LowerCaseUnderscorePropertyNamesContractResolver.cs deleted file mode 100644 index 7fe758b24d..0000000000 --- a/src/Exceptionless.Core/Serialization/LowerCaseUnderscorePropertyNamesContractResolver.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Reflection; -using Exceptionless.Core.Extensions; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - -namespace Exceptionless.Core.Serialization; - -public class LowerCaseUnderscorePropertyNamesContractResolver : DefaultContractResolver -{ - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var property = base.CreateProperty(member, memberSerialization); - - var shouldSerialize = property.ShouldSerialize; - property.ShouldSerialize = obj => (shouldSerialize is null || shouldSerialize(obj)) && !property.IsValueEmptyCollection(obj); - return property; - } - - protected override JsonDictionaryContract CreateDictionaryContract(Type objectType) - { - var contract = base.CreateDictionaryContract(objectType); - contract.DictionaryKeyResolver = propertyName => propertyName; - return contract; - } - - protected override string ResolvePropertyName(string propertyName) - { - return propertyName.ToLowerUnderscoredWords(); - } -} diff --git a/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs b/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs index 8d5dd5841f..2313dc93d6 100644 --- a/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs +++ b/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Text.Json; using System.Text.Json.Serialization; using Exceptionless.Core.Models; @@ -17,7 +18,7 @@ namespace Exceptionless.Core.Serialization; /// /// /// true/false -/// Numbers → (if fits) or +/// Numbers → (if fits), , or ; with preferInt64, always for integers and for floats /// Strings with ISO 8601 date format → /// Other strings → /// nullnull @@ -43,6 +44,25 @@ namespace Exceptionless.Core.Serialization; /// public sealed class ObjectToInferredTypesConverter : JsonConverter { + private readonly bool _preferInt64; + + /// + /// Initializes a new instance with default settings (integers that fit Int32 are returned as ). + /// + public ObjectToInferredTypesConverter() : this(preferInt64: false) { } + + /// + /// Initializes a new instance with configurable integer handling. + /// + /// + /// When true, all integers are returned as to match JSON.NET behavior. + /// Used by the Elasticsearch serializer to maintain compatibility with DataObjectConverter. + /// + public ObjectToInferredTypesConverter(bool preferInt64) + { + _preferInt64 = preferInt64; + } + /// public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -90,23 +110,57 @@ public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerO } /// - /// Reads a JSON number, preferring for integers and for decimals. + /// Reads a JSON number, preserving the original representation (integer vs floating-point). /// - private static object ReadNumber(ref Utf8JsonReader reader) + /// + /// This method preserves data integrity by checking the raw JSON text to determine + /// if a number was written with a decimal point (e.g., 0.0) vs as an integer (0). + /// This is critical because: + /// + /// User data must be preserved exactly as provided + /// TryGetInt64 would succeed for 0.0 since 0.0 == 0 mathematically + /// Serializing back would lose the decimal representation + /// + /// + private object ReadNumber(ref Utf8JsonReader reader) { - // Try smallest to largest integer types first for optimal boxing - if (reader.TryGetInt32(out int i)) - return i; + // Check the raw text to preserve decimal vs integer representation + // This is critical for data integrity - 0.0 should stay as double, not become 0L + ReadOnlySpan rawValue = reader.HasValueSequence + ? reader.ValueSequence.ToArray() + : reader.ValueSpan; + + // If the raw text contains a decimal point or exponent, treat as floating-point + if (rawValue.Contains((byte)'.') || rawValue.Contains((byte)'e') || rawValue.Contains((byte)'E')) + { + if (_preferInt64) + return reader.GetDouble(); - if (reader.TryGetInt64(out long l)) - return l; + return reader.GetDecimal(); + } + + // No decimal point - this is an integer + if (_preferInt64) + { + // Match JSON.NET DataObjectConverter behavior: always return Int64 + if (reader.TryGetInt64(out long l)) + return l; + } + else + { + // Default STJ behavior: return smallest fitting integer type + if (reader.TryGetInt32(out int i)) + return i; + + if (reader.TryGetInt64(out long l)) + return l; + } - // Try decimal for precise values (e.g., financial data) before double - if (reader.TryGetDecimal(out decimal d)) - return d; + // For very large integers that don't fit in long, fall back to decimal/double + if (_preferInt64) + return reader.GetDouble(); - // Fall back to double for floating-point - return reader.GetDouble(); + return reader.GetDecimal(); } /// @@ -131,7 +185,7 @@ private static object ReadNumber(ref Utf8JsonReader reader) /// Uses for property name matching, /// consistent with behavior. /// - private static Dictionary ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + private Dictionary ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) { var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -157,7 +211,7 @@ private static object ReadNumber(ref Utf8JsonReader reader) /// /// Recursively reads a JSON array into a of objects. /// - private static List ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + private List ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) { var list = new List(); @@ -175,7 +229,7 @@ private static object ReadNumber(ref Utf8JsonReader reader) /// /// Reads a single JSON value of any type, dispatching to the appropriate reader method. /// - private static object? ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + private object? ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) { return reader.TokenType switch { diff --git a/src/Exceptionless.Core/Services/SlackService.cs b/src/Exceptionless.Core/Services/SlackService.cs index c41ec984c4..4bd610efaf 100644 --- a/src/Exceptionless.Core/Services/SlackService.cs +++ b/src/Exceptionless.Core/Services/SlackService.cs @@ -14,7 +14,7 @@ public class SlackService private readonly HttpClient _client = new(); private readonly IQueue _webHookNotificationQueue; private readonly FormattingPluginManager _pluginManager; - private readonly ISerializer _serializer; + private readonly ITextSerializer _serializer; private readonly AppOptions _appOptions; private readonly ILogger _logger; @@ -111,7 +111,7 @@ public Task SendMessageAsync(string organizationId, string projectId, string url public async Task SendEventNoticeAsync(PersistentEvent ev, Project project, bool isNew, bool isRegression) { - var token = project.GetSlackToken(); + var token = project.GetSlackToken(_serializer); if (token?.IncomingWebhook?.Url is null) return false; diff --git a/src/Exceptionless.Core/Utility/ErrorSignature.cs b/src/Exceptionless.Core/Utility/ErrorSignature.cs index 9aa7d3c9f1..2762922fb2 100644 --- a/src/Exceptionless.Core/Utility/ErrorSignature.cs +++ b/src/Exceptionless.Core/Utility/ErrorSignature.cs @@ -1,8 +1,8 @@ using System.Text; -using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; +using Foundatio.Serializer; namespace Exceptionless.Core.Utility; @@ -10,14 +10,14 @@ public class ErrorSignature { private readonly HashSet _userNamespaces; private readonly HashSet _userCommonMethods; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; private static readonly string[] _defaultNonUserNamespaces = ["System", "Microsoft"]; // TODO: Add support for user public key token on signed assemblies - public ErrorSignature(Error error, JsonSerializerOptions jsonOptions, IEnumerable? userNamespaces = null, IEnumerable? userCommonMethods = null, bool emptyNamespaceIsUserMethod = true, bool shouldFlagSignatureTarget = true) + public ErrorSignature(Error error, ITextSerializer serializer, IEnumerable? userNamespaces = null, IEnumerable? userCommonMethods = null, bool emptyNamespaceIsUserMethod = true, bool shouldFlagSignatureTarget = true) { Error = error ?? throw new ArgumentNullException(nameof(error)); - _jsonOptions = jsonOptions ?? throw new ArgumentNullException(nameof(jsonOptions)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); _userNamespaces = userNamespaces is null ? [] @@ -180,7 +180,7 @@ private void AddSpecialCaseDetails(InnerError error) if (!error.Data.ContainsKey(Error.KnownDataKeys.ExtraProperties)) return; - var extraProperties = error.Data.GetValue>(Error.KnownDataKeys.ExtraProperties, _jsonOptions); + var extraProperties = error.Data.GetValue>(Error.KnownDataKeys.ExtraProperties, _serializer); if (extraProperties is null) { error.Data.Remove(Error.KnownDataKeys.ExtraProperties); diff --git a/src/Exceptionless.Core/Utility/ExtensibleObject.cs b/src/Exceptionless.Core/Utility/ExtensibleObject.cs index 6ac04bddf7..733d026aba 100644 --- a/src/Exceptionless.Core/Utility/ExtensibleObject.cs +++ b/src/Exceptionless.Core/Utility/ExtensibleObject.cs @@ -1,7 +1,6 @@ using System.ComponentModel; +using System.Text.Json; using Exceptionless.Core.Extensions; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Utility; @@ -22,7 +21,6 @@ public interface IExtensibleObject public class ExtensibleObject : INotifyPropertyChanged, IExtensibleObject { - [JsonProperty] private readonly Dictionary _extendedData = new(); public void SetProperty(string name, T value) @@ -44,8 +42,18 @@ public void SetProperty(string name, T value) if (value is T tValue) return tValue; - if (value is JContainer container) - return container.ToObject(); + // Handle JsonElement from STJ deserialization + if (value is JsonElement jsonElement) + { + try + { + return jsonElement.Deserialize(); + } + catch + { + // Fall through to ToType conversion + } + } return value.ToType(); } diff --git a/src/Exceptionless.Core/Utility/TypeHelper.cs b/src/Exceptionless.Core/Utility/TypeHelper.cs index de21067616..d092c87603 100644 --- a/src/Exceptionless.Core/Utility/TypeHelper.cs +++ b/src/Exceptionless.Core/Utility/TypeHelper.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Reflection; -using Newtonsoft.Json.Linq; +using System.Text.Json; namespace Exceptionless.Core.Helpers; @@ -52,8 +52,9 @@ public static bool AreSameValue(object a, object b) catch { } } - if (a is JToken && b is JToken) - return String.Equals(a.ToString(), b.ToString()); + // Handle JsonElement comparison by comparing string representations + if (a is JsonElement jsonA && b is JsonElement jsonB) + return String.Equals(jsonA.GetRawText(), jsonB.GetRawText()); if (a != b && !a.Equals(b)) return false; diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index 6daf3ef7bd..6837392bf6 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -26,10 +26,10 @@ using Foundatio.Repositories.Elasticsearch.Extensions; using Foundatio.Repositories.Extensions; using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; -using Newtonsoft.Json; namespace Exceptionless.Web.Controllers; @@ -47,7 +47,7 @@ public class EventController : RepositoryApiController _userDescriptionValidator; private readonly FormattingPluginManager _formattingPluginManager; private readonly ICacheClient _cache; - private readonly JsonSerializerSettings _jsonSerializerSettings; + private readonly ITextSerializer _serializer; private readonly AppOptions _appOptions; public EventController(IEventRepository repository, @@ -59,7 +59,7 @@ public EventController(IEventRepository repository, IValidator userDescriptionValidator, FormattingPluginManager formattingPluginManager, ICacheClient cacheClient, - JsonSerializerSettings jsonSerializerSettings, + ITextSerializer serializer, IMapper mapper, PersistentEventQueryValidator validator, AppOptions appOptions, @@ -75,7 +75,7 @@ ILoggerFactory loggerFactory _userDescriptionValidator = userDescriptionValidator; _formattingPluginManager = formattingPluginManager; _cache = cacheClient; - _jsonSerializerSettings = jsonSerializerSettings; + _serializer = serializer; _appOptions = appOptions; AllowedDateFields.Add(EventIndex.Alias.Date); @@ -1115,7 +1115,7 @@ private async Task GetSubmitEventAsync(string? projectId = null, i charSet = contentTypeHeader.Charset.ToString(); } - var stream = new MemoryStream(ev.GetBytes(_jsonSerializerSettings)); + using var stream = new MemoryStream(ev.GetBytes(_serializer)); await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive) { ApiVersion = apiVersion, diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index cf3b6d6c86..842462b36a 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -16,6 +16,7 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using DataDictionary = Exceptionless.Core.Models.DataDictionary; @@ -34,6 +35,7 @@ public class ProjectController : RepositoryApiController _workItemQueue; private readonly BillingManager _billingManager; private readonly SlackService _slackService; + private readonly ITextSerializer _serializer; private readonly AppOptions _options; private readonly UsageService _usageService; @@ -46,6 +48,7 @@ public ProjectController( IQueue workItemQueue, BillingManager billingManager, SlackService slackService, + ITextSerializer serializer, IMapper mapper, IAppQueryValidator validator, AppOptions options, @@ -62,6 +65,7 @@ ILoggerFactory loggerFactory _workItemQueue = workItemQueue; _billingManager = billingManager; _slackService = slackService; + _serializer = serializer; _options = options; _usageService = usageService; } @@ -669,7 +673,7 @@ public async Task RemoveSlackAsync(string id) if (project is null) return NotFound(); - var token = project.GetSlackToken(); + var token = project.GetSlackToken(_serializer); using var _ = _logger.BeginScope(new ExceptionlessState().Property("Token", token).Tag("Slack").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)); if (token is not null) diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 3571be3493..6d92a6d59f 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -17,7 +17,6 @@ - diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 0dd8842560..c8db2b2ad1 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -25,6 +25,7 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Microsoft.Net.Http.Headers; using Xunit; using MediaTypeHeaderValue = System.Net.Http.Headers.MediaTypeHeaderValue; @@ -68,7 +69,7 @@ protected override async Task ResetDataAsync() [Fact] public async Task PostEvent_WithValidPayload_EnqueuesAndProcessesEventAsync() { - var jsonOptions = GetService(); + var serializer = GetService(); /* language=json */ const string json = """{"message":"test","reference_id":"TestReferenceId","@user":{"identity":"Test user","name":null}}"""; await SendRequestAsync(r => r @@ -95,12 +96,12 @@ await SendRequestAsync(r => r Assert.Equal("test", ev.Message); Assert.Equal("TestReferenceId", ev.ReferenceId); - var identity = ev.GetUserIdentity(jsonOptions); + var identity = ev.GetUserIdentity(serializer); Assert.NotNull(identity); Assert.Equal("Test user", identity.Identity); Assert.Null(identity.Name); Assert.Null(identity.Name); - Assert.Null(ev.GetUserDescription(jsonOptions)); + Assert.Null(ev.GetUserDescription(serializer)); // post description await _eventUserDescriptionQueue.DeleteQueueAsync(); @@ -125,13 +126,13 @@ await SendRequestAsync(r => r Assert.Equal(1, stats.Completed); ev = await _eventRepository.GetByIdAsync(ev.Id); - identity = ev.GetUserIdentity(jsonOptions); + identity = ev.GetUserIdentity(serializer); Assert.NotNull(identity); Assert.Equal("Test user", identity.Identity); Assert.Null(identity.Name); Assert.Null(identity.Name); - var description = ev.GetUserDescription(jsonOptions); + var description = ev.GetUserDescription(serializer); Assert.NotNull(description); Assert.Equal("Test Description", description.Description); Assert.Equal(TestConstants.UserEmail, description.EmailAddress); @@ -227,7 +228,7 @@ public async Task CanPostCompressedStringAsync() [Fact] public async Task CanPostJsonWithUserInfoAsync() { - var jsonOptions = GetService(); + var serializer = GetService(); /* language=json */ const string json = """{"message":"test","@user":{"identity":"Test user","name":null}}"""; await SendRequestAsync(r => r @@ -253,7 +254,7 @@ await SendRequestAsync(r => r var ev = events.Documents.Single(e => String.Equals(e.Type, Event.KnownTypes.Log)); Assert.Equal("test", ev.Message); - var userInfo = ev.GetUserIdentity(jsonOptions); + var userInfo = ev.GetUserIdentity(serializer); Assert.NotNull(userInfo); Assert.Equal("Test user", userInfo.Identity); Assert.Null(userInfo.Name); @@ -1676,7 +1677,7 @@ await SendRequestAsync(r => r await processEventsJob.RunAsync(TestCancellationToken); await RefreshDataAsync(); - var jsonOptions = GetService(); + var serializer = GetService(); // Assert var events = await _eventRepository.GetAllAsync(); @@ -1686,7 +1687,7 @@ await SendRequestAsync(r => r Assert.Equal("Error with mixed data", ev.Message); // Verify known data is properly deserialized - var userInfo = ev.GetUserIdentity(jsonOptions); + var userInfo = ev.GetUserIdentity(serializer); Assert.NotNull(userInfo); Assert.Equal("user@example.com", userInfo.Identity); Assert.Equal("Test User", userInfo.Name); diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 041d5fe22b..f99a2c0720 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -8,8 +8,7 @@ - - + diff --git a/tests/Exceptionless.Tests/IntegrationTestsBase.cs b/tests/Exceptionless.Tests/IntegrationTestsBase.cs index f486fbe473..c8f901da0c 100644 --- a/tests/Exceptionless.Tests/IntegrationTestsBase.cs +++ b/tests/Exceptionless.Tests/IntegrationTestsBase.cs @@ -10,7 +10,6 @@ using Exceptionless.Tests.Mail; using Exceptionless.Tests.Utility; using FluentRest; -using FluentRest.NewtonsoftJson; using Foundatio.Caching; using Foundatio.Jobs; using Foundatio.Messaging; @@ -23,7 +22,7 @@ using Foundatio.Xunit; using Microsoft.AspNetCore.TestHost; using Nest; -using Newtonsoft.Json; +using System.Text.Json; using Xunit; using HttpMethod = System.Net.Http.HttpMethod; using LogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -206,8 +205,8 @@ protected HttpClient CreateHttpClient() protected FluentClient CreateFluentClient() { - var settings = GetService(); - return new FluentClient(CreateHttpClient(), new NewtonsoftJsonSerializer(settings)); + var options = GetService(); + return new FluentClient(CreateHttpClient(), new JsonContentSerializer(options)); } protected async Task SendRequestAsync(Action configure) @@ -236,6 +235,13 @@ protected async Task SendRequestAsync(Action(ensureSuccess); diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 5f381bb418..582953a94b 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -11,6 +10,7 @@ using Exceptionless.Core.Utility; using Exceptionless.Tests.Utility; using Foundatio.Queues; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Mail; @@ -40,7 +40,7 @@ public MailerTests(ITestOutputHelper output) : base(output) _plans = GetService(); if (_mailer is NullMailer) - _mailer = new Mailer(GetService>(), GetService(), GetService(), _options, TimeProvider, Log.CreateLogger()); + _mailer = new Mailer(GetService>(), GetService(), GetService(), _options, TimeProvider, Log.CreateLogger()); } [Fact] diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index 74fff49ebc..f8b71e5e59 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Globalization; using System.Text; -using System.Text.Json; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -18,6 +17,7 @@ using Exceptionless.Tests.Utility; using Foundatio.Repositories; using Foundatio.Repositories.Extensions; +using Foundatio.Serializer; using Foundatio.Storage; using McSherry.SemanticVersioning; using Xunit; @@ -39,7 +39,7 @@ public sealed class EventPipelineTests : IntegrationTestsBase private readonly IUserRepository _userRepository; private readonly BillingManager _billingManager; private readonly BillingPlans _plans; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; public EventPipelineTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { @@ -55,7 +55,7 @@ public EventPipelineTests(ITestOutputHelper output, AppWebHostFactory factory) : _pipeline = GetService(); _billingManager = GetService(); _plans = GetService(); - _jsonOptions = GetService(); + _serializer = GetService(); } protected override async Task ResetDataAsync() @@ -224,19 +224,19 @@ public async Task UpdateAutoSessionLastActivityAsync() var results = await _eventRepository.GetAllAsync(o => o.PageLimit(15)); Assert.Equal(9, results.Total); Assert.Equal(2, results.Documents.Where(e => !String.IsNullOrEmpty(e.GetSessionId())).Select(e => e.GetSessionId()).Distinct().Count()); - Assert.Equal(1, results.Documents.Count(e => e.IsSessionEnd() && e.GetUserIdentity(_jsonOptions)?.Identity == "blake@exceptionless.io")); - Assert.Single(results.Documents.Where(e => !String.IsNullOrEmpty(e.GetSessionId()) && e.GetUserIdentity(_jsonOptions)?.Identity == "eric@exceptionless.io").Select(e => e.GetSessionId()).Distinct()); + Assert.Equal(1, results.Documents.Count(e => e.IsSessionEnd() && e.GetUserIdentity(_serializer)?.Identity == "blake@exceptionless.io")); + Assert.Single(results.Documents.Where(e => !String.IsNullOrEmpty(e.GetSessionId()) && e.GetUserIdentity(_serializer)?.Identity == "eric@exceptionless.io").Select(e => e.GetSessionId()).Distinct()); Assert.Equal(1, results.Documents.Count(e => String.IsNullOrEmpty(e.GetSessionId()))); Assert.Equal(1, results.Documents.Count(e => e.IsSessionEnd())); var sessionStarts = results.Documents.Where(e => e.IsSessionStart()).ToList(); Assert.Equal(2, sessionStarts.Count); - var firstUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity(_jsonOptions)?.Identity == "blake@exceptionless.io"); + var firstUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity(_serializer)?.Identity == "blake@exceptionless.io"); Assert.Equal((decimal)(lastEventDate - firstEventDate).TotalSeconds, firstUserSessionStartEvents.Value); Assert.True(firstUserSessionStartEvents.HasSessionEndTime()); - var secondUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity(_jsonOptions)?.Identity == "eric@exceptionless.io"); + var secondUserSessionStartEvents = sessionStarts.Single(e => e.GetUserIdentity(_serializer)?.Identity == "eric@exceptionless.io"); Assert.Equal((decimal)(lastEventDate - firstEventDate).TotalSeconds, secondUserSessionStartEvents.Value); Assert.False(secondUserSessionStartEvents.HasSessionEndTime()); } @@ -893,10 +893,10 @@ public async Task EnsureIncludePrivateInformationIsRespectedAsync(bool includePr var context = contexts.Single(); Assert.False(context.HasError); - var requestInfo = context.Event.GetRequestInfo(_jsonOptions); - var environmentInfo = context.Event.GetEnvironmentInfo(_jsonOptions); - var userInfo = context.Event.GetUserIdentity(_jsonOptions); - var userDescription = context.Event.GetUserDescription(_jsonOptions); + var requestInfo = context.Event.GetRequestInfo(_serializer); + var environmentInfo = context.Event.GetEnvironmentInfo(_serializer); + var userInfo = context.Event.GetUserIdentity(_serializer); + var userDescription = context.Event.GetUserDescription(_serializer); Assert.Equal("/test", requestInfo?.Path); Assert.Equal("Windows", environmentInfo?.OSName); @@ -1163,7 +1163,7 @@ public async Task GeneratePerformanceDataAsync() ev.Data.Remove(key); ev.Data.Remove(Event.KnownDataKeys.UserDescription); - var identity = ev.GetUserIdentity(_jsonOptions); + var identity = ev.GetUserIdentity(_serializer); if (identity?.Identity is not null) { if (!mappedUsers.ContainsKey(identity.Identity)) @@ -1172,7 +1172,7 @@ public async Task GeneratePerformanceDataAsync() ev.SetUserIdentity(mappedUsers[identity.Identity]); } - var request = ev.GetRequestInfo(_jsonOptions); + var request = ev.GetRequestInfo(_serializer); if (request is not null) { request.Cookies?.Clear(); @@ -1192,7 +1192,7 @@ public async Task GeneratePerformanceDataAsync() } } - InnerError? error = ev.GetError(_jsonOptions); + InnerError? error = ev.GetError(_serializer); while (error is not null) { error.Message = RandomData.GetSentence(); @@ -1202,13 +1202,13 @@ public async Task GeneratePerformanceDataAsync() error = error.Inner; } - var environment = ev.GetEnvironmentInfo(_jsonOptions); + var environment = ev.GetEnvironmentInfo(_serializer); environment?.Data?.Clear(); } // inject random session start events. if (currentBatchCount % 10 == 0) - events.Insert(0, events[0].ToSessionStartEvent(_jsonOptions)); + events.Insert(0, events[0].ToSessionStartEvent(_serializer)); await storage.SaveObjectAsync(Path.Combine(dataDirectory, $"{currentBatchCount++}.json"), events, TestCancellationToken); } diff --git a/tests/Exceptionless.Tests/Plugins/EventParserTests.cs b/tests/Exceptionless.Tests/Plugins/EventParserTests.cs index 5e0f215f23..20e0a76851 100644 --- a/tests/Exceptionless.Tests/Plugins/EventParserTests.cs +++ b/tests/Exceptionless.Tests/Plugins/EventParserTests.cs @@ -1,7 +1,7 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.EventParser; -using Newtonsoft.Json; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Plugins; @@ -9,10 +9,12 @@ namespace Exceptionless.Tests.Plugins; public sealed class EventParserTests : TestWithServices { private readonly EventParserPluginManager _parser; + private readonly ITextSerializer _serializer; public EventParserTests(ITestOutputHelper output) : base(output) { _parser = GetService(); + _serializer = GetService(); } public static IEnumerable EventData => new[] { @@ -52,9 +54,21 @@ public void VerifyEventParserSerialization(string eventsFilePath) var events = _parser.ParseEvents(json, 2, "exceptionless/2.0.0.0"); Assert.Single(events); + var ev = events.First(); - string expectedContent = File.ReadAllText(eventsFilePath); - Assert.Equal(expectedContent, events.First().ToJson(Formatting.Indented, GetService())); + // Verify round-trip: parse → serialize → deserialize preserves all data. + // Must deserialize as PersistentEvent (same type the parser produces) so + // PersistentEvent-specific properties don't leak into Data via JsonExtensionData. + string serialized = _serializer.SerializeToString(ev); + Assert.NotNull(serialized); + var roundTripped = _serializer.Deserialize(serialized); + Assert.NotNull(roundTripped); + Assert.Equal(ev.Type, roundTripped.Type); + Assert.Equal(ev.Message, roundTripped.Message); + Assert.Equal(ev.Source, roundTripped.Source); + Assert.Equal(ev.ReferenceId, roundTripped.ReferenceId); + Assert.Equal(ev.Tags?.Count ?? 0, roundTripped.Tags?.Count ?? 0); + Assert.Equal(ev.Data?.Count ?? 0, roundTripped.Data?.Count ?? 0); } [Theory] @@ -63,7 +77,7 @@ public void CanDeserializeEvents(string eventsFilePath) { string json = File.ReadAllText(eventsFilePath); - var ev = json.FromJson(GetService()); + var ev = _serializer.Deserialize(json); Assert.NotNull(ev); } diff --git a/tests/Exceptionless.Tests/Plugins/EventUpgraderTests.cs b/tests/Exceptionless.Tests/Plugins/EventUpgraderTests.cs index 2459e1b4ac..1550fd846d 100644 --- a/tests/Exceptionless.Tests/Plugins/EventUpgraderTests.cs +++ b/tests/Exceptionless.Tests/Plugins/EventUpgraderTests.cs @@ -1,3 +1,6 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Plugins.EventParser; using Exceptionless.Core.Plugins.EventUpgrader; using Xunit; @@ -8,11 +11,13 @@ public sealed class EventUpgraderTests : TestWithServices { private readonly EventUpgraderPluginManager _upgrader; private readonly EventParserPluginManager _parser; + private readonly JsonSerializerOptions _jsonOptions; public EventUpgraderTests(ITestOutputHelper output) : base(output) { _upgrader = GetService(); _parser = GetService(); + _jsonOptions = GetService(); } [Theory] @@ -24,9 +29,12 @@ public void ParseErrors(string errorFilePath) _upgrader.Upgrade(ctx); string expectedContent = File.ReadAllText(Path.ChangeExtension(errorFilePath, ".expected.json")); - Assert.Equal(expectedContent, ctx.Documents.First?.ToString()); + var expected = JsonNode.Parse(expectedContent); + var actual = JsonNode.Parse(ctx.Documents.First().ToFormattedString(_jsonOptions)); + Assert.True(JsonNode.DeepEquals(expected, actual), + $"File: {Path.GetFileName(errorFilePath)}\nExpected:\n{expected?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}\n\nActual:\n{actual?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}"); - var events = _parser.ParseEvents(ctx.Documents.ToString(), 2, "exceptionless/2.0.0.0"); + var events = _parser.ParseEvents(ctx.Documents.ToFormattedString(_jsonOptions), 2, "exceptionless/2.0.0.0"); Assert.Single(events); } diff --git a/tests/Exceptionless.Tests/Plugins/GeoTests.cs b/tests/Exceptionless.Tests/Plugins/GeoTests.cs index fe6c576fb5..72492967f0 100644 --- a/tests/Exceptionless.Tests/Plugins/GeoTests.cs +++ b/tests/Exceptionless.Tests/Plugins/GeoTests.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Geo; using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; @@ -13,6 +13,7 @@ using Exceptionless.Tests.Utility; using Foundatio.Caching; using Foundatio.Resilience; +using Foundatio.Serializer; using Foundatio.Storage; using Xunit; @@ -29,7 +30,7 @@ public sealed class GeoTests : TestWithServices private readonly AppOptions _options; private readonly OrganizationData _organizationData; private readonly ProjectData _projectData; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; public GeoTests(ITestOutputHelper output) : base(output) { @@ -38,7 +39,7 @@ public GeoTests(ITestOutputHelper output) : base(output) _options = GetService(); _organizationData = GetService(); _projectData = GetService(); - _jsonOptions = GetService(); + _serializer = GetService(); } private async Task GetResolverAsync(ILoggerFactory loggerFactory) @@ -74,12 +75,12 @@ public async Task WillNotSetLocation() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent { Geo = GREEN_BAY_COORDINATES }; await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.Equal(GREEN_BAY_COORDINATES, ev.Geo); - Assert.Null(ev.GetLocation(_jsonOptions)); + Assert.Null(ev.GetLocation(_serializer)); } [Theory] @@ -94,12 +95,12 @@ public async Task WillResetLocation(string? geo) if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent { Geo = geo }; await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.Null(ev.Geo); - Assert.Null(ev.GetLocation(_jsonOptions)); + Assert.Null(ev.GetLocation(_serializer)); } [Fact] @@ -109,14 +110,14 @@ public async Task WillSetLocationFromGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent { Geo = GREEN_BAY_IP }; await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.NotNull(ev.Geo); Assert.NotEqual(GREEN_BAY_IP, ev.Geo); - var location = ev.GetLocation(_jsonOptions); + var location = ev.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -129,14 +130,14 @@ public async Task WillSetLocationFromRequestInfo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent(); ev.AddRequestInfo(new RequestInfo { ClientIpAddress = GREEN_BAY_IP }); await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.NotNull(ev.Geo); - var location = ev.GetLocation(_jsonOptions); + var location = ev.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -149,14 +150,14 @@ public async Task WillSetLocationFromEnvironmentInfoInfo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent(); ev.SetEnvironmentInfo(new EnvironmentInfo { IpAddress = $"127.0.0.1,{GREEN_BAY_IP}" }); await plugin.EventBatchProcessingAsync(new List { new(ev, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()) }); Assert.NotNull(ev.Geo); - var location = ev.GetLocation(_jsonOptions); + var location = ev.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -169,7 +170,7 @@ public async Task WillSetFromSingleGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var contexts = new List { new(new PersistentEvent { Geo = GREEN_BAY_IP }, _organizationData.GenerateSampleOrganization(_billingManager, _plans), _projectData.GenerateSampleProject()), @@ -182,7 +183,7 @@ public async Task WillSetFromSingleGeo() { AssertCoordinatesAreEqual(GREEN_BAY_COORDINATES, context.Event.Geo); - var location = context.Event.GetLocation(_jsonOptions); + var location = context.Event.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); @@ -196,7 +197,7 @@ public async Task WillNotSetFromMultipleGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent(); var greenBayEvent = new PersistentEvent { Geo = GREEN_BAY_IP }; @@ -208,13 +209,13 @@ await plugin.EventBatchProcessingAsync(new List { }); AssertCoordinatesAreEqual(GREEN_BAY_COORDINATES, greenBayEvent.Geo); - var location = greenBayEvent.GetLocation(_jsonOptions); + var location = greenBayEvent.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); AssertCoordinatesAreEqual(IRVING_COORDINATES, irvingEvent.Geo); - location = irvingEvent.GetLocation(_jsonOptions); + location = irvingEvent.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("TX", location?.Level1); Assert.Equal("Irving", location?.Locality); @@ -242,7 +243,7 @@ public async Task WillSetMultipleFromEmptyGeo() if (resolver is NullGeoIpService) return; - var plugin = new GeoPlugin(resolver, _jsonOptions, _options, Log); + var plugin = new GeoPlugin(resolver, _serializer, _options, Log); var ev = new PersistentEvent(); var greenBayEvent = new PersistentEvent(); @@ -256,13 +257,13 @@ await plugin.EventBatchProcessingAsync(new List { }); AssertCoordinatesAreEqual(GREEN_BAY_COORDINATES, greenBayEvent.Geo); - var location = greenBayEvent.GetLocation(_jsonOptions); + var location = greenBayEvent.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("WI", location?.Level1); Assert.Equal("Green Bay", location?.Locality); AssertCoordinatesAreEqual(IRVING_COORDINATES, irvingEvent.Geo); - location = irvingEvent.GetLocation(_jsonOptions); + location = irvingEvent.GetLocation(_serializer); Assert.Equal("US", location?.Country); Assert.Equal("TX", location?.Level1); Assert.Equal("Irving", location?.Locality); diff --git a/tests/Exceptionless.Tests/Plugins/SummaryDataTests.cs b/tests/Exceptionless.Tests/Plugins/SummaryDataTests.cs index 9e0ffad6e6..464c411370 100644 --- a/tests/Exceptionless.Tests/Plugins/SummaryDataTests.cs +++ b/tests/Exceptionless.Tests/Plugins/SummaryDataTests.cs @@ -1,26 +1,31 @@ -using Exceptionless.Core.Extensions; +using System.Text.Json; +using System.Text.Json.Nodes; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; -using Newtonsoft.Json; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Plugins; public class SummaryDataTests : TestWithServices { - public SummaryDataTests(ITestOutputHelper output) : base(output) { } + private readonly ITextSerializer _serializer; + private readonly JsonSerializerOptions _jsonOptions; + + public SummaryDataTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + _jsonOptions = GetService(); + } [Theory] [MemberData(nameof(Events))] public async Task EventSummaryData(string path) { - var settings = GetService(); - settings.Formatting = Formatting.Indented; - string json = await File.ReadAllTextAsync(path, TestCancellationToken); Assert.NotNull(json); - var ev = json.FromJson(settings); + var ev = _serializer.Deserialize(json); Assert.NotNull(ev); var data = GetService().GetEventSummaryData(ev); @@ -33,20 +38,20 @@ public async Task EventSummaryData(string path) }; string expectedContent = await File.ReadAllTextAsync(Path.ChangeExtension(path, "summary.json"), TestCancellationToken); - Assert.Equal(expectedContent, JsonConvert.SerializeObject(summary, settings)); + var expected = JsonNode.Parse(expectedContent); + var actual = JsonNode.Parse(JsonSerializer.Serialize(summary, _jsonOptions)); + Assert.True(JsonNode.DeepEquals(expected, actual), + $"File: {Path.GetFileName(path)}\nExpected:\n{expected?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}\n\nActual:\n{actual?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}"); } [Theory] [MemberData(nameof(Stacks))] public async Task StackSummaryData(string path) { - var settings = GetService(); - settings.Formatting = Formatting.Indented; - string json = await File.ReadAllTextAsync(path, TestCancellationToken); Assert.NotNull(json); - var stack = json.FromJson(settings); + var stack = _serializer.Deserialize(json); Assert.NotNull(stack); var data = GetService().GetStackSummaryData(stack); @@ -61,7 +66,10 @@ public async Task StackSummaryData(string path) }; string expectedContent = await File.ReadAllTextAsync(Path.ChangeExtension(path, "summary.json"), TestCancellationToken); - Assert.Equal(expectedContent, JsonConvert.SerializeObject(summary, settings)); + var expected = JsonNode.Parse(expectedContent); + var actual = JsonNode.Parse(JsonSerializer.Serialize(summary, _jsonOptions)); + Assert.True(JsonNode.DeepEquals(expected, actual), + $"File: {Path.GetFileName(path)}\nExpected:\n{expected?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}\n\nActual:\n{actual?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}"); } public static IEnumerable Events diff --git a/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs b/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs index 86e353f1e0..adf24cb28d 100644 --- a/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs +++ b/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs @@ -1,15 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Nodes; using Exceptionless.Core.Billing; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; using Exceptionless.Core.Plugins.WebHook; using Exceptionless.Tests.Utility; -using Newtonsoft.Json; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Plugins; public sealed class WebHookDataTests : TestWithServices { + private readonly ITextSerializer _serializer; + private readonly JsonSerializerOptions _jsonOptions; private readonly OrganizationData _organizationData; private readonly ProjectData _projectData; private readonly StackData _stackData; @@ -18,6 +22,8 @@ public sealed class WebHookDataTests : TestWithServices public WebHookDataTests(ITestOutputHelper output) : base(output) { + _serializer = GetService(); + _jsonOptions = GetService(); _organizationData = GetService(); _projectData = GetService(); _stackData = GetService(); @@ -29,15 +35,12 @@ public WebHookDataTests(ITestOutputHelper output) : base(output) [MemberData(nameof(WebHookData))] public async Task CreateFromEventAsync(string version, bool expectData) { - var settings = GetService(); - settings.Formatting = Formatting.Indented; object? data = await _webHookData.CreateFromEventAsync(GetWebHookDataContext(version)); if (expectData) { string filePath = Path.GetFullPath(Path.Combine("..", "..", "..", "Plugins", "WebHookData", $"{version}.event.expected.json")); string expectedContent = await File.ReadAllTextAsync(filePath, TestCancellationToken); - string actualContent = JsonConvert.SerializeObject(data, settings); - Assert.Equal(expectedContent, actualContent); + AssertJsonEquivalent(expectedContent, JsonSerializer.Serialize(data, _jsonOptions)); } else { @@ -49,15 +52,12 @@ public async Task CreateFromEventAsync(string version, bool expectData) [MemberData(nameof(WebHookData))] public async Task CanCreateFromStackAsync(string version, bool expectData) { - var settings = GetService(); - settings.Formatting = Formatting.Indented; object? data = await _webHookData.CreateFromStackAsync(GetWebHookDataContext(version)); if (expectData) { string filePath = Path.GetFullPath(Path.Combine("..", "..", "..", "Plugins", "WebHookData", $"{version}.stack.expected.json")); string expectedContent = await File.ReadAllTextAsync(filePath, TestCancellationToken); - string actualContent = JsonConvert.SerializeObject(data, settings); - Assert.Equal(expectedContent, actualContent); + AssertJsonEquivalent(expectedContent, JsonSerializer.Serialize(data, _jsonOptions)); } else { @@ -76,9 +76,6 @@ private WebHookDataContext GetWebHookDataContext(string version) { string json = File.ReadAllText(Path.GetFullPath(Path.Combine("..", "..", "..", "ErrorData", "1477.expected.json"))); - var settings = GetService(); - settings.Formatting = Formatting.Indented; - var hook = new WebHook { Id = TestConstants.WebHookId, @@ -93,7 +90,7 @@ private WebHookDataContext GetWebHookDataContext(string version) var organization = _organizationData.GenerateSampleOrganization(GetService(), GetService()); var project = _projectData.GenerateSampleProject(); - var ev = JsonConvert.DeserializeObject(json, settings); + var ev = _serializer.Deserialize(json); Assert.NotNull(ev); ev.OrganizationId = TestConstants.OrganizationId; ev.ProjectId = TestConstants.ProjectId; @@ -110,4 +107,48 @@ private WebHookDataContext GetWebHookDataContext(string version) return new WebHookDataContext(hook, organization, project, stack, ev); } + + /// + /// Compares two JSON strings semantically, ignoring null properties that may be + /// present in expected but omitted by WhenWritingNull in actual. + /// + private static void AssertJsonEquivalent(string expectedJson, string actualJson) + { + var expected = JsonNode.Parse(expectedJson); + var actual = JsonNode.Parse(actualJson); + RemoveNullProperties(expected); + RemoveNullProperties(actual); + Assert.True(JsonNode.DeepEquals(expected, actual), + $"Expected:\n{expected?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}\n\nActual:\n{actual?.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}"); + } + + private static void RemoveNullProperties(JsonNode? node) + { + if (node is not JsonObject obj) + { + return; + } + + var keysToRemove = new List(); + foreach (var prop in obj) + { + if (prop.Value is null) + { + keysToRemove.Add(prop.Key); + } + else if (prop.Value is JsonArray arr && arr.Count == 0) + { + keysToRemove.Add(prop.Key); + } + else + { + RemoveNullProperties(prop.Value); + } + } + + foreach (string key in keysToRemove) + { + obj.Remove(key); + } + } } diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index c8e0cf9528..d1e38bc37d 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; @@ -8,6 +9,7 @@ using Exceptionless.Tests.Utility; using Foundatio.Repositories; using Foundatio.Repositories.Utility; +using Foundatio.Serializer; using Xunit; using LogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -21,7 +23,7 @@ public sealed class EventRepositoryTests : IntegrationTestsBase private readonly IEventRepository _repository; private readonly StackData _stackData; private readonly IStackRepository _stackRepository; - private readonly JsonSerializerOptions _jsonOptions; + private readonly ITextSerializer _serializer; public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { @@ -30,7 +32,7 @@ public EventRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) _repository = GetService(); _stackData = GetService(); _stackRepository = GetService(); - _jsonOptions = GetService(); + _serializer = GetService(); } [Fact(Skip = "https://github.com/elastic/elasticsearch-net/issues/2463")] @@ -219,7 +221,7 @@ public async Task RemoveAllByClientIpAndDateAsync() Assert.Equal(NUMBER_OF_EVENTS_TO_CREATE, events.Count); events.ForEach(e => { - var ri = e.GetRequestInfo(_jsonOptions); + var ri = e.GetRequestInfo(_serializer); Assert.NotNull(ri); Assert.Equal(_clientIpAddress, ri.ClientIpAddress); }); diff --git a/tests/Exceptionless.Tests/Repositories/ProjectRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/ProjectRepositoryTests.cs index 5f8dd59dab..8f2ab4d8fc 100644 --- a/tests/Exceptionless.Tests/Repositories/ProjectRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/ProjectRepositoryTests.cs @@ -7,6 +7,7 @@ using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Repositories; @@ -17,6 +18,7 @@ public sealed class ProjectRepositoryTests : IntegrationTestsBase private readonly OrganizationData _organizationData; private readonly ProjectData _projectData; private readonly IProjectRepository _repository; + private readonly ITextSerializer _serializer; public ProjectRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { @@ -24,6 +26,7 @@ public ProjectRepositoryTests(ITestOutputHelper output, AppWebHostFactory factor _projectData = GetService(); _cache = GetService(); _repository = GetService(); + _serializer = GetService(); } [Fact] @@ -137,13 +140,13 @@ public async Task CanRoundTripWithCaching() var actual = await _repository.GetByIdAsync(project.Id, o => o.Cache()); Assert.NotNull(actual); Assert.Equal(project.Name, actual.Name); - var actualToken = actual.GetSlackToken(); + var actualToken = actual.GetSlackToken(_serializer); Assert.Equal(token.AccessToken, actualToken?.AccessToken); var actualCache = await _cache.GetAsync>>("Project:" + project.Id); Assert.True(actualCache.HasValue); Assert.Equal(project.Name, actualCache.Value.Single().Document.Name); - var actualCacheToken = actual.GetSlackToken(); + var actualCacheToken = actual.GetSlackToken(_serializer); Assert.Equal(token.AccessToken, actualCacheToken?.AccessToken); } } diff --git a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs index bd765b8129..67151af91e 100644 --- a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs @@ -8,6 +8,7 @@ using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Options; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Repositories; @@ -15,12 +16,14 @@ namespace Exceptionless.Tests.Repositories; public sealed class StackRepositoryTests : IntegrationTestsBase { private readonly InMemoryCacheClient _cache; + private readonly ITextSerializer _serializer; private readonly StackData _stackData; private readonly IStackRepository _repository; public StackRepositoryTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _cache = GetService() as InMemoryCacheClient ?? throw new InvalidOperationException(); + _serializer = GetService(); _stackData = GetService(); _repository = GetService(); } @@ -79,7 +82,7 @@ public async Task CanGetByStackHashAsync() Assert.Equal(misses, _cache.Misses); var result = await _repository.GetStackBySignatureHashAsync(stack.ProjectId, stack.SignatureHash); - Assert.Equal(stack.ToJson(), result.ToJson()); + Assert.Equal(_serializer.SerializeToString(stack), _serializer.SerializeToString(result)); Assert.Equal(count + 2, _cache.Count); Assert.Equal(hits + 1, _cache.Hits); Assert.Equal(misses, _cache.Misses); diff --git a/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs b/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs index cc91b0837c..24aadbf6aa 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/DataDictionaryTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -15,12 +14,10 @@ namespace Exceptionless.Tests.Serializer.Models; public class DataDictionaryTests : TestWithServices { private readonly ITextSerializer _serializer; - private readonly JsonSerializerOptions _jsonOptions; public DataDictionaryTests(ITestOutputHelper output) : base(output) { _serializer = GetService(); - _jsonOptions = GetService(); } [Fact] @@ -31,7 +28,7 @@ public void GetValue_DirectUserInfoType_ReturnsTypedValue() var data = new DataDictionary { { "user", userInfo } }; // Act - var result = data.GetValue("user", _jsonOptions); + var result = data.GetValue("user", _serializer); // Assert Assert.NotNull(result); @@ -46,7 +43,7 @@ public void GetValue_DirectStringType_ReturnsStringValue() var data = new DataDictionary { { "version", "1.0.0" } }; // Act - string? result = data.GetValue("version", _jsonOptions); + string? result = data.GetValue("version", _serializer); // Assert Assert.Equal("1.0.0", result); @@ -59,7 +56,7 @@ public void GetValue_DirectIntType_ReturnsIntValue() var data = new DataDictionary { { "count", 42 } }; // Act - int result = data.GetValue("count", _jsonOptions); + int result = data.GetValue("count", _serializer); // Assert Assert.Equal(42, result); @@ -73,7 +70,7 @@ public void GetValue_JObjectWithUserInfo_ReturnsTypedUserInfo() var data = new DataDictionary { { "user", jObject } }; // Act - var result = data.GetValue("user", _jsonOptions); + var result = data.GetValue("user", _serializer); // Assert Assert.NotNull(result); @@ -97,7 +94,7 @@ public void GetValue_JObjectWithError_ReturnsTypedError() var data = new DataDictionary { { "@error", jObject } }; // Act - var result = data.GetValue("@error", _jsonOptions); + var result = data.GetValue("@error", _serializer); // Assert Assert.NotNull(result); @@ -123,7 +120,7 @@ public void GetValue_JObjectWithRequestInfo_ReturnsTypedRequestInfo() var data = new DataDictionary { { "@request", jObject } }; // Act - var result = data.GetValue("@request", _jsonOptions); + var result = data.GetValue("@request", _serializer); // Assert Assert.NotNull(result); @@ -149,7 +146,7 @@ public void GetValue_JObjectWithEnvironmentInfo_ReturnsTypedEnvironmentInfo() var data = new DataDictionary { { "@environment", jObject } }; // Act - var result = data.GetValue("@environment", _jsonOptions); + var result = data.GetValue("@environment", _serializer); // Assert Assert.NotNull(result); @@ -176,7 +173,7 @@ public void GetValue_JObjectWithNestedError_ReturnsNestedHierarchy() var data = new DataDictionary { { "@error", jObject } }; // Act - var result = data.GetValue("@error", _jsonOptions); + var result = data.GetValue("@error", _serializer); // Assert Assert.NotNull(result); @@ -194,7 +191,7 @@ public void GetValue_JsonStringWithUserInfo_ReturnsTypedUserInfo() var data = new DataDictionary { { "user", json } }; // Act - var result = data.GetValue("user", _jsonOptions); + var result = data.GetValue("user", _serializer); // Assert Assert.NotNull(result); @@ -211,7 +208,7 @@ public void GetValue_JsonStringWithError_ReturnsTypedError() var data = new DataDictionary { { "@error", json } }; // Act - var result = data.GetValue("@error", _jsonOptions); + var result = data.GetValue("@error", _serializer); // Assert Assert.NotNull(result); @@ -228,7 +225,7 @@ public void GetValue_JsonStringWithRequestInfo_ReturnsTypedRequestInfo() var data = new DataDictionary { { "@request", json } }; // Act - var result = data.GetValue("@request", _jsonOptions); + var result = data.GetValue("@request", _serializer); // Assert Assert.NotNull(result); @@ -245,7 +242,7 @@ public void GetValue_JsonStringWithEnvironmentInfo_ReturnsTypedEnvironmentInfo() var data = new DataDictionary { { "@environment", json } }; // Act - var result = data.GetValue("@environment", _jsonOptions); + var result = data.GetValue("@environment", _serializer); // Assert Assert.NotNull(result); @@ -262,7 +259,7 @@ public void GetValue_JsonStringWithSimpleError_ReturnsTypedSimpleError() var data = new DataDictionary { { "@simple_error", json } }; // Act - var result = data.GetValue("@simple_error", _jsonOptions); + var result = data.GetValue("@simple_error", _serializer); // Assert Assert.NotNull(result); @@ -279,7 +276,7 @@ public void GetValue_JsonStringWithNestedError_ReturnsNestedHierarchy() var data = new DataDictionary { { "@error", json } }; // Act - var result = data.GetValue("@error", _jsonOptions); + var result = data.GetValue("@error", _serializer); // Assert Assert.NotNull(result); @@ -295,7 +292,7 @@ public void GetValue_NonJsonString_ReturnsNull() var data = new DataDictionary { { "text", "not json" } }; // Act - var result = data.GetValue("text", _jsonOptions); + var result = data.GetValue("text", _serializer); // Assert Assert.Null(result); @@ -308,7 +305,7 @@ public void GetValue_MissingKey_ThrowsKeyNotFoundException() var data = new DataDictionary(); // Act & Assert - Assert.Throws(() => data.GetValue("nonexistent", _jsonOptions)); + Assert.Throws(() => data.GetValue("nonexistent", _serializer)); } [Fact] @@ -318,7 +315,7 @@ public void GetValue_NullValue_ReturnsNull() var data = new DataDictionary { { "nullable", null! } }; // Act - var result = data.GetValue("nullable", _jsonOptions); + var result = data.GetValue("nullable", _serializer); // Assert Assert.Null(result); @@ -331,7 +328,7 @@ public void GetValue_IncompatibleType_ReturnsNull() var data = new DataDictionary { { "number", 42 } }; // Act - var result = data.GetValue("number", _jsonOptions); + var result = data.GetValue("number", _serializer); // Assert Assert.Null(result); @@ -346,7 +343,7 @@ public void GetValue_MalformedJsonString_ReturnsDefaultProperties() var data = new DataDictionary { { "user", json } }; // Act - var result = data.GetValue("user", _jsonOptions); + var result = data.GetValue("user", _serializer); // Assert Assert.NotNull(result); @@ -369,7 +366,7 @@ public void Deserialize_DataDictionaryWithUserInfoAfterRoundTrip_PreservesTypedD // Assert Assert.NotNull(deserialized); Assert.True(deserialized.ContainsKey("@user")); - var userInfo = deserialized.GetValue("@user", _jsonOptions); + var userInfo = deserialized.GetValue("@user", _serializer); Assert.NotNull(userInfo); Assert.Equal("user@test.com", userInfo.Identity); Assert.Equal("Test User", userInfo.Name); @@ -429,7 +426,7 @@ public void Deserialize_UserInfoAfterRoundTrip_PreservesAllProperties() // Assert Assert.NotNull(deserialized); - var result = deserialized.GetValue("@user", _jsonOptions); + var result = deserialized.GetValue("@user", _serializer); Assert.NotNull(result); Assert.Equal("stj@test.com", result.Identity); Assert.Equal("STJ Test User", result.Name); @@ -463,7 +460,7 @@ public void Deserialize_ErrorAfterRoundTrip_PreservesComplexStructure() // Assert Assert.NotNull(deserialized); - var result = deserialized.GetValue("@error", _jsonOptions); + var result = deserialized.GetValue("@error", _serializer); Assert.NotNull(result); Assert.Equal("Test Exception", result.Message); Assert.Equal("System.InvalidOperationException", result.Type); @@ -495,7 +492,7 @@ public void Deserialize_RequestInfoAfterRoundTrip_PreservesAllProperties() // Assert Assert.NotNull(deserialized); - var result = deserialized.GetValue("@request", _jsonOptions); + var result = deserialized.GetValue("@request", _serializer); Assert.NotNull(result); Assert.Equal("POST", result.HttpMethod); Assert.Equal("/api/events", result.Path); @@ -525,7 +522,7 @@ public void Deserialize_EnvironmentInfoAfterRoundTrip_PreservesAllProperties() // Assert Assert.NotNull(deserialized); - var result = deserialized.GetValue("@environment", _jsonOptions); + var result = deserialized.GetValue("@environment", _serializer); Assert.NotNull(result); Assert.Equal("TEST-MACHINE", result.MachineName); Assert.Equal(16, result.ProcessorCount); @@ -555,7 +552,7 @@ public void Deserialize_NestedErrorAfterRoundTrip_PreservesInnerError() // Assert Assert.NotNull(deserialized); - var result = deserialized.GetValue("@error", _jsonOptions); + var result = deserialized.GetValue("@error", _serializer); Assert.NotNull(result); Assert.Equal("Outer exception", result.Message); Assert.NotNull(result.Inner); @@ -582,7 +579,7 @@ public void Deserialize_MixedDataTypesAfterRoundTrip_PreservesAllTypes() // Assert Assert.NotNull(deserialized); - var userInfo = deserialized.GetValue("@user", _jsonOptions); + var userInfo = deserialized.GetValue("@user", _serializer); Assert.NotNull(userInfo); Assert.Equal("user@test.com", userInfo.Identity); @@ -611,7 +608,7 @@ public void Deserialize_NestedDataDictionaryAfterRoundTrip_PreservesNestedData() // Assert Assert.NotNull(deserialized); - var result = deserialized.GetValue("@user", _jsonOptions); + var result = deserialized.GetValue("@user", _serializer); Assert.NotNull(result); Assert.Equal("user@test.com", result.Identity); Assert.NotNull(result.Data); @@ -631,7 +628,7 @@ public void GetValue_DictionaryOfStringObject_DeserializesToTypedObject() var data = new DataDictionary { { "@user", dictionary } }; // Act - var result = data.GetValue("@user", _jsonOptions); + var result = data.GetValue("@user", _serializer); // Assert Assert.NotNull(result); @@ -651,7 +648,7 @@ public void GetValue_ListOfObjects_DeserializesToTypedCollection() var data = new DataDictionary { { "frames", list } }; // Act - var result = data.GetValue>("frames", _jsonOptions); + var result = data.GetValue>("frames", _serializer); // Assert Assert.NotNull(result); diff --git a/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs index 8d77001c8a..180d1b8c31 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Foundatio.Serializer; @@ -14,13 +15,11 @@ namespace Exceptionless.Tests.Serializer.Models; public class PersistentEventSerializerTests : TestWithServices { private readonly ITextSerializer _serializer; - private readonly JsonSerializerOptions _jsonOptions; private static readonly DateTimeOffset FixedDate = new(2024, 1, 15, 12, 0, 0, TimeSpan.Zero); public PersistentEventSerializerTests(ITestOutputHelper output) : base(output) { _serializer = GetService(); - _jsonOptions = GetService(); TimeProvider.SetUtcNow(FixedDate); } @@ -105,7 +104,7 @@ public void Deserialize_EventWithUserInfo_PreservesTypedUserInfo() // Assert Assert.NotNull(deserialized); - var userInfo = deserialized.GetUserIdentity(_jsonOptions); + var userInfo = deserialized.GetUserIdentity(_serializer); Assert.NotNull(userInfo); Assert.Equal("user@example.com", userInfo.Identity); Assert.Equal("Test User", userInfo.Name); @@ -146,7 +145,7 @@ public void Deserialize_EventWithError_PreservesTypedError() // Assert Assert.NotNull(deserialized); - var error = deserialized.GetError(_jsonOptions); + var error = deserialized.GetError(_serializer); Assert.NotNull(error); Assert.Equal("Test exception", error.Message); Assert.Equal("System.InvalidOperationException", error.Type); @@ -183,7 +182,7 @@ public void Deserialize_EventWithRequestInfo_PreservesTypedRequestInfo() // Assert Assert.NotNull(deserialized); - var request = deserialized.GetRequestInfo(_jsonOptions); + var request = deserialized.GetRequestInfo(_serializer); Assert.NotNull(request); Assert.Equal("POST", request.HttpMethod); Assert.Equal("/api/events", request.Path); @@ -215,7 +214,7 @@ public void Deserialize_EventWithEnvironmentInfo_PreservesTypedEnvironmentInfo() // Assert Assert.NotNull(deserialized); - var env = deserialized.GetEnvironmentInfo(_jsonOptions); + var env = deserialized.GetEnvironmentInfo(_serializer); Assert.NotNull(env); Assert.Equal("PROD-SERVER-01", env.MachineName); Assert.Equal(8, env.ProcessorCount); @@ -270,9 +269,9 @@ public void Deserialize_EventWithAllKnownDataKeys_PreservesAllTypes() // Assert Assert.NotNull(deserialized); - Assert.NotNull(deserialized.GetUserIdentity(_jsonOptions)); - Assert.NotNull(deserialized.GetRequestInfo(_jsonOptions)); - Assert.NotNull(deserialized.GetEnvironmentInfo(_jsonOptions)); + Assert.NotNull(deserialized.GetUserIdentity(_serializer)); + Assert.NotNull(deserialized.GetRequestInfo(_serializer)); + Assert.NotNull(deserialized.GetEnvironmentInfo(_serializer)); Assert.Equal("1.0.0", deserialized.GetVersion()); Assert.Equal("Error", deserialized.GetLevel()); } @@ -328,7 +327,7 @@ public void Deserialize_JsonWithTypedUserData_RetrievesTypedUserInfo() // Assert Assert.NotNull(ev); - var userInfo = ev.GetUserIdentity(_jsonOptions); + var userInfo = ev.GetUserIdentity(_serializer); Assert.NotNull(userInfo); Assert.Equal("parsed@example.com", userInfo.Identity); Assert.Equal("Parsed User", userInfo.Name); diff --git a/tests/Exceptionless.Tests/Serializer/SerializerTests.cs b/tests/Exceptionless.Tests/Serializer/SerializerTests.cs index 9d9d6f51f5..17904fe30b 100644 --- a/tests/Exceptionless.Tests/Serializer/SerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/SerializerTests.cs @@ -1,12 +1,9 @@ +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Services; -using Exceptionless.Serializer; -using Foundatio.Repositories.Extensions; using Foundatio.Serializer; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Xunit; namespace Exceptionless.Tests.Serializer; @@ -20,37 +17,54 @@ public SerializerTests(ITestOutputHelper output) : base(output) _serializer = GetService(); } + [Fact] + public void CanDeserializeEventWithData() + { + // Arrange + /* language=json */ + const string json = """{"message":"Hello","data":{"Blah":"SomeVal"}}"""; + + // Act + var ev = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(ev); + Assert.NotNull(ev.Data); + Assert.Single(ev.Data); + Assert.Equal("Hello", ev.Message); + Assert.Equal("SomeVal", ev.Data["Blah"]); + } + [Fact] public void CanDeserializeEventWithUnknownNamesAndProperties() { - const string json = @"{""tags"":[""One"",""Two""],""reference_id"":""12"",""Message"":""Hello"",""SomeString"":""Hi"",""SomeBool"":false,""SomeNum"":1,""UnknownProp"":{""Blah"":""SomeVal""},""Some"":{""Blah"":""SomeVal""},""@error"":{""Message"":""SomeVal"",""SomeProp"":""SomeVal""},""Some2"":""{\""Blah\"":\""SomeVal\""}"",""UnknownSerializedProp"":""{\""Blah\"":\""SomeVal\""}""}"; - var settings = new JsonSerializerSettings(); - var knownDataTypes = new Dictionary - { - { "Some", typeof(SomeModel) }, - { "Some2", typeof(SomeModel) }, - { Event.KnownDataKeys.Error, typeof(Error) } - }; - settings.Converters.Add(new DataObjectConverter(_logger, knownDataTypes)); - settings.Converters.Add(new DataObjectConverter(_logger)); + // Arrange - unknown root properties go through [JsonExtensionData] → ConvertJsonElement. + // With STJ, unknown nested objects stay as JsonElement (GetValue handles typed access). + // Primitives are converted: strings, bools, numbers. Objects/arrays stay as JsonElement. + /* language=json */ + const string json = """{"tags":["One","Two"],"reference_id":"12","message":"Hello","SomeString":"Hi","SomeBool":false,"SomeNum":1,"UnknownProp":{"Blah":"SomeVal"},"UnknownSerializedProp":"{\"Blah\":\"SomeVal\"}"}"""; - var ev = json.FromJson(settings); - Assert.NotNull(ev?.Data); + // Act + var ev = _serializer.Deserialize(json); + + // Assert — verify all properties captured correctly + Assert.NotNull(ev); + Assert.NotNull(ev.Data); + Assert.Equal(5, ev.Data.Count); - Assert.Equal(8, ev.Data.Count); - Assert.Equal("Hi", ev.Data.GetString("SomeString")); - Assert.False(ev.Data.GetBoolean("SomeBool")); + // Primitive types are converted by ConvertJsonElement + Assert.Equal("Hi", ev.Data["SomeString"]); + Assert.Equal(false, ev.Data["SomeBool"]); Assert.Equal(1L, ev.Data["SomeNum"]); - Assert.Equal(typeof(JObject), ev.Data["UnknownProp"]?.GetType()); - Assert.Equal(typeof(JObject), ev.Data["UnknownSerializedProp"]?.GetType()); - Assert.Equal("SomeVal", (string)((dynamic)ev.Data["UnknownProp"]!)?.Blah!); - Assert.Equal(typeof(SomeModel), ev.Data["Some"]?.GetType()); - Assert.Equal(typeof(SomeModel), ev.Data["Some2"]?.GetType()); - Assert.Equal("SomeVal", (ev.Data["Some"] as SomeModel)?.Blah); - Assert.Equal(typeof(Error), ev.Data[Event.KnownDataKeys.Error]?.GetType()); - Assert.Equal("SomeVal", ((Error)ev.Data[Event.KnownDataKeys.Error]!)?.Message); - Assert.Single(((Error)ev.Data[Event.KnownDataKeys.Error]!)?.Data!); - Assert.Equal("SomeVal", ((Error)ev.Data[Event.KnownDataKeys.Error]!)?.Data?["SomeProp"]); + + // Unknown nested objects stay as JsonElement for deferred typed access + Assert.IsType(ev.Data["UnknownProp"]); + var unknownProp = (JsonElement)ev.Data["UnknownProp"]!; + Assert.Equal("SomeVal", unknownProp.GetProperty("Blah").GetString()); + + // Serialized JSON strings stay as strings + Assert.IsType(ev.Data["UnknownSerializedProp"]); + Assert.Equal("Hello", ev.Message); Assert.NotNull(ev.Tags); Assert.Equal(2, ev.Tags.Count); @@ -58,55 +72,65 @@ public void CanDeserializeEventWithUnknownNamesAndProperties() Assert.Contains("Two", ev.Tags); Assert.Equal("12", ev.ReferenceId); - const string expectedjson = @"{""Tags"":[""One"",""Two""],""Message"":""Hello"",""Data"":{""SomeString"":""Hi"",""SomeBool"":false,""SomeNum"":1,""UnknownProp"":{""Blah"":""SomeVal""},""Some"":{""Blah"":""SomeVal""},""@error"":{""Modules"":[],""Message"":""SomeVal"",""Data"":{""SomeProp"":""SomeVal""},""StackTrace"":[]},""Some2"":{""Blah"":""SomeVal""},""UnknownSerializedProp"":{""Blah"":""SomeVal""}},""ReferenceId"":""12""}"; - string newjson = ev.ToJson(Formatting.None, new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore }); - Assert.Equal(expectedjson, newjson); + // Verify round-trip preserves data + string roundTrippedJson = _serializer.SerializeToString(ev); + var roundTripped = _serializer.Deserialize(roundTrippedJson); + Assert.NotNull(roundTripped); + Assert.Equal(ev.Message, roundTripped.Message); + Assert.Equal(ev.ReferenceId, roundTripped.ReferenceId); + Assert.Equal(ev.Tags, roundTripped.Tags); + Assert.Equal(ev.Data.Count, roundTripped.Data?.Count); } [Fact] - public void CanDeserializeEventWithInvalidKnownDataTypes() + public void CanRoundTripEventWithKnownDataTypes() { - const string json = @"{""Message"":""Hello"",""Some"":""{\""Blah\"":\""SomeVal\""}"",""@Some"":""{\""Blah\"":\""SomeVal\""}""}"; - const string jsonWithInvalidDataType = @"{""Message"":""Hello"",""@Some"":""Testing"",""@string"":""Testing""}"; - - var settings = new JsonSerializerSettings(); - var knownDataTypes = new Dictionary { - { "Some", typeof(SomeModel) }, - { "@Some", typeof(SomeModel) }, - { "_@Some", typeof(SomeModel) }, - { "@string", typeof(string) } - }; - settings.Converters.Add(new DataObjectConverter(_logger, knownDataTypes)); - - var ev = json.FromJson(settings); - Assert.NotNull(ev?.Data); - Assert.Equal(2, ev.Data.Count); - Assert.True(ev.Data.ContainsKey("Some")); - Assert.Equal("SomeVal", (ev.Data["Some"] as SomeModel)?.Blah); - Assert.True(ev.Data.ContainsKey("@Some")); - Assert.Equal("SomeVal", (ev.Data["@Some"] as SomeModel)?.Blah); - - ev = jsonWithInvalidDataType.FromJson(settings); - Assert.NotNull(ev?.Data); - Assert.Equal(2, ev.Data.Count); - Assert.True(ev.Data.ContainsKey("_@Some1")); - Assert.Equal("Testing", ev.Data["_@Some1"] as string); - Assert.True(ev.Data.ContainsKey("@string")); - Assert.Equal("Testing", ev.Data["@string"] as string); - } + // Arrange - Event with known data types (error, request info) + var originalError = new Error + { + Message = "Something went wrong", + Type = "System.Exception", + Data = new DataDictionary { { "SomeProp", "SomeVal" } } + }; + var originalRequest = new RequestInfo { HttpMethod = "GET", Path = "/api/test" }; - [Fact] - public void CanDeserializeEventWithData() - { - const string json = @"{""Message"":""Hello"",""Data"":{""Blah"":""SomeVal""}}"; - var settings = new JsonSerializerSettings(); - settings.Converters.Add(new DataObjectConverter(_logger)); + var ev = new Event + { + Message = "Test error", + Type = Event.KnownTypes.Error, + Data = new DataDictionary + { + { Event.KnownDataKeys.Error, originalError }, + { Event.KnownDataKeys.RequestInfo, originalRequest } + } + }; - var ev = json.FromJson(settings); - Assert.NotNull(ev?.Data); - Assert.Single(ev.Data); - Assert.Equal("Hello", ev.Message); - Assert.Equal("SomeVal", ev.Data["Blah"]); + // Act + string json = _serializer.SerializeToString(ev); + var roundTripped = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(roundTripped); + Assert.Equal(ev.Message, roundTripped.Message); + Assert.Equal(ev.Type, roundTripped.Type); + Assert.NotNull(roundTripped.Data); + Assert.Equal(2, roundTripped.Data.Count); + Assert.True(roundTripped.Data.ContainsKey(Event.KnownDataKeys.Error)); + Assert.True(roundTripped.Data.ContainsKey(Event.KnownDataKeys.RequestInfo)); + + // Verify error data round-tripped with values intact + var error = roundTripped.Data.GetValue(Event.KnownDataKeys.Error, _serializer); + Assert.NotNull(error); + Assert.Equal(originalError.Message, error.Message); + Assert.Equal(originalError.Type, error.Type); + Assert.NotNull(error.Data); + Assert.Equal("SomeVal", error.Data["SomeProp"]); + + // Verify request info round-tripped + var request = roundTripped.Data.GetValue(Event.KnownDataKeys.RequestInfo, _serializer); + Assert.NotNull(request); + Assert.Equal(originalRequest.HttpMethod, request.HttpMethod); + Assert.Equal(originalRequest.Path, request.Path); } [Fact] @@ -131,6 +155,7 @@ public void CanDeserializeWebHook() [Fact] public void CanDeserializeProject() { + /* language=json */ string json = "{\"last_event_date_utc\":\"2020-10-18T20:54:04.3457274+01:00\", \"created_utc\":\"0001-01-01T00:00:00\",\"updated_utc\":\"2020-09-21T04:41:32.7458321Z\"}"; var model = _serializer.Deserialize(json); @@ -333,8 +358,3 @@ public class SampleClass public int Count { get; set; } } } - -public record SomeModel -{ - public required string Blah { get; set; } -} diff --git a/tests/Exceptionless.Tests/Utility/DataBuilder.cs b/tests/Exceptionless.Tests/Utility/DataBuilder.cs index d5997b6dca..1c8bd74f8a 100644 --- a/tests/Exceptionless.Tests/Utility/DataBuilder.cs +++ b/tests/Exceptionless.Tests/Utility/DataBuilder.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Plugins.Formatting; @@ -36,9 +35,8 @@ public EventDataBuilder Event() public class EventDataBuilder { private readonly FormattingPluginManager _formattingPluginManager; - private readonly ISerializer _serializer; + private readonly ITextSerializer _serializer; private readonly TimeProvider _timeProvider; - private readonly JsonSerializerOptions _jsonOptions; private readonly ICollection> _stackMutations; private int _additionalEventsToCreate = 0; private readonly PersistentEvent _event = new(); @@ -46,12 +44,11 @@ public class EventDataBuilder private EventDataBuilder? _stackEventBuilder; private bool _isFirstOccurrenceSet = false; - public EventDataBuilder(FormattingPluginManager formattingPluginManager, ISerializer serializer, JsonSerializerOptions jsonOptions, TimeProvider timeProvider) + public EventDataBuilder(FormattingPluginManager formattingPluginManager, ITextSerializer serializer, TimeProvider timeProvider) { _stackMutations = new List>(); _formattingPluginManager = formattingPluginManager; _serializer = serializer; - _jsonOptions = jsonOptions; _timeProvider = timeProvider; } @@ -534,7 +531,7 @@ public EventDataBuilder Snooze(DateTime? snoozeUntil = null) if (_stack.FirstOccurrence < _event.Date) _event.IsFirstOccurrence = false; - var msi = _event.GetManualStackingInfo(_jsonOptions); + var msi = _event.GetManualStackingInfo(_serializer); if (msi is not null) { _stack.Title = msi.Title!;