Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 1 addition & 29 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Newtonsoft.Json.Serialization.IContractResolver>(_ => GetJsonContractResolver());
services.AddSingleton<Newtonsoft.Json.JsonSerializerSettings>(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<Newtonsoft.Json.Serialization.IContractResolver>()
};

settings.AddModelConverters(s.GetRequiredService<ILogger<Bootstrapper>>());
return settings;
});

// Register System.Text.Json options with Exceptionless defaults (snake_case, null handling)
services.AddSingleton(_ => new JsonSerializerOptions().ConfigureExceptionlessDefaults());

services.AddSingleton<ISerializer>(s => s.GetRequiredService<ITextSerializer>());
Expand Down Expand Up @@ -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<T> CreateQueue<T>(IServiceProvider container, TimeSpan? workItemTimeout = null) where T : class
{
var loggerFactory = container.GetRequiredService<ILoggerFactory>();
Expand Down
2 changes: 0 additions & 2 deletions src/Exceptionless.Core/Exceptionless.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@
<PackageReference Include="Exceptionless.DateTimeExtensions" Version="6.0.1" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="Foundatio.Extensions.Hosting" Version="13.0.0-beta3.5" />
<PackageReference Include="Foundatio.JsonNet" Version="13.0.0-beta3.5" />
<PackageReference Include="MiniValidation" Version="0.9.2" />
<PackageReference Include="NEST.JsonNetSerializer" Version="7.17.5" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="McSherry.SemanticVersioning" Version="1.4.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
Expand Down
133 changes: 68 additions & 65 deletions src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,44 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Exceptionless.Core.Models;
using Foundatio.Serializer;

namespace Exceptionless.Core.Extensions;

public static class DataDictionaryExtensions
{
/// <summary>
/// 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.
/// </summary>
private static readonly JsonSerializerOptions CaseInsensitiveOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Retrieves a typed value from the <see cref="DataDictionary"/>, deserializing if necessary.
/// </summary>
/// <typeparam name="T">The target type to deserialize to.</typeparam>
/// <param name="extendedData">The data dictionary containing the value.</param>
/// <param name="key">The key of the value to retrieve.</param>
/// <param name="options">The JSON serializer options to use for deserialization.</param>
/// <param name="serializer">The text serializer to use for deserialization.</param>
/// <returns>The deserialized value, or <c>default</c> if deserialization fails.</returns>
/// <exception cref="KeyNotFoundException">Thrown when the key is not found in the dictionary.</exception>
/// <remarks>
/// <para>This method handles multiple source formats in priority order:</para>
/// <list type="number">
/// <item><description>Direct type match - returns value directly</description></item>
/// <item><description><see cref="JsonDocument"/> - extracts root element and deserializes</description></item>
/// <item><description><see cref="JsonElement"/> - deserializes using provided options</description></item>
/// <item><description><see cref="JsonNode"/> - deserializes using provided options</description></item>
/// <item><description><see cref="Dictionary{TKey,TValue}"/> - re-serializes to JSON then deserializes (for ObjectToInferredTypesConverter output)</description></item>
/// <item><description><see cref="List{T}"/> of objects - re-serializes to JSON then deserializes</description></item>
/// <item><description><see cref="Newtonsoft.Json.Linq.JObject"/> - uses ToObject for Elasticsearch compatibility (data read from Elasticsearch uses JSON.NET)</description></item>
/// <item><description>JSON string - parses and deserializes</description></item>
/// <item><description><see cref="JsonElement"/> - extracts raw JSON and deserializes via ITextSerializer</description></item>
/// <item><description><see cref="JsonNode"/> - extracts JSON string and deserializes via ITextSerializer</description></item>
/// <item><description><see cref="Dictionary{TKey,TValue}"/> - re-serializes to JSON then deserializes via ITextSerializer</description></item>
/// <item><description><see cref="List{T}"/> of objects - re-serializes to JSON then deserializes via ITextSerializer</description></item>
/// <item><description><see cref="Newtonsoft.Json.Linq.JObject"/> - uses ToObject for Elasticsearch compatibility</description></item>
/// <item><description>JSON string - deserializes via ITextSerializer</description></item>
/// <item><description>Fallback - attempts type conversion via ToType</description></item>
/// </list>
/// </remarks>
public static T? GetValue<T>(this DataDictionary extendedData, string key, JsonSerializerOptions options)
public static T? GetValue<T>(this DataDictionary extendedData, string key, ITextSerializer serializer)
{
if (!extendedData.TryGetValue(key, out object? data))
throw new KeyNotFoundException($"Key \"{key}\" not found in the dictionary.");
Expand All @@ -42,37 +51,68 @@ 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<T>(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)
if (data is JsonNode jsonNode)
{
try
{
var result = jsonNode.Deserialize<T>(options);
string jsonString = jsonNode.ToJsonString();
var result = serializer.Deserialize<T>(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<string, object?> 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<string, object?> dictionary)
{
try
{
string dictJson = JsonSerializer.Serialize(dictionary, options);
var result = JsonSerializer.Deserialize<T>(dictJson, options);
if (result is not null)
return result;
string? dictJson = serializer.SerializeToString(dictionary);
if (dictJson is not null)
{
var result = serializer.Deserialize<T>(dictJson);
if (result is not null)
return result;
}
}
catch
{
Expand All @@ -85,10 +125,13 @@ public static class DataDictionaryExtensions
{
try
{
string listJson = JsonSerializer.Serialize(list, options);
var result = JsonSerializer.Deserialize<T>(listJson, options);
if (result is not null)
return result;
string? listJson = serializer.SerializeToString(list);
if (listJson is not null)
{
var result = serializer.Deserialize<T>(listJson);
if (result is not null)
return result;
}
}
catch
{
Expand All @@ -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<T>(json, options);
var result = serializer.Deserialize<T>(json);
if (result is not null)
return result;
}
Expand All @@ -142,49 +185,9 @@ public static class DataDictionaryExtensions
return default;
}

private static bool TryDeserialize<T>(JsonElement element, JsonSerializerOptions options, out T? result)
{
result = default;

try
{
// Fast-path for common primitives where the element isn't an object/array
// (Deserialize<T> 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<T>(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);
}
Expand Down
6 changes: 3 additions & 3 deletions src/Exceptionless.Core/Extensions/ErrorExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Text.Json;
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Data;
using Foundatio.Serializer;

namespace Exceptionless.Core.Extensions;

Expand Down Expand Up @@ -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();
}

Expand Down
Loading