Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d64cd62
Replace JSON.NET with System.Text.Json across the codebase
niemyjski Feb 28, 2026
13aa277
fix: address STJ migration bugs and PR review feedback
niemyjski Mar 1, 2026
47f134e
Migrate serializer, config, and bootstrapper to Elastic.Clients.Elast…
niemyjski Mar 22, 2026
9f7914b
Migrate index configurations to new Elasticsearch 8 fluent API
niemyjski Mar 22, 2026
4559885
Migrate queries and repositories to Elastic.Clients.Elasticsearch
niemyjski Mar 22, 2026
6be3932
Migrate jobs, migrations, and controllers to Elastic.Clients.Elastics…
niemyjski Mar 22, 2026
3890cb4
fix: remove duplicate Id mapping in EventIndex
niemyjski Mar 22, 2026
acd90ef
Fix STJ serialization test failures
niemyjski Mar 22, 2026
5494e58
Address PR review feedback
niemyjski Mar 22, 2026
7415334
Fix EmptyCollectionModifier, deserialization, and test quality
niemyjski Mar 22, 2026
9a016f5
Fix return→continue in event upgraders, address review feedback
niemyjski Mar 22, 2026
26e26a4
Quote interpolated values in FilterExpression to prevent query injection
niemyjski Mar 22, 2026
b261663
Fix whitespace body check in DeserializeResponseAsync
niemyjski Mar 22, 2026
7c40963
Replace all Lucene FilterExpression strings with typed queries
niemyjski Mar 22, 2026
18d4850
Fix TokenRepository int enum mapping and SerializerTests
niemyjski Mar 23, 2026
c870828
Add enum serialization, fix response models, improve test coverage
niemyjski Mar 23, 2026
2635b2e
Revert enum string serialization, fix CodeQL issues
niemyjski Mar 23, 2026
2e33229
Fix API contract preservation, code quality, and test migration
niemyjski Mar 23, 2026
86734e3
Fix query visitor ternary, preserve ShouldSerialize predicates
niemyjski Mar 23, 2026
5dece58
Fix JsonExtensionData and SummaryData.Data serialization issues
niemyjski Mar 23, 2026
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
33 changes: 3 additions & 30 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using Elastic.Clients.Elasticsearch;
using Exceptionless.Core.Authentication;
using Exceptionless.Core.Billing;
using Exceptionless.Core.Configuration;
Expand All @@ -24,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 @@ -53,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 All @@ -91,7 +71,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
}));

services.AddSingleton<ExceptionlessElasticConfiguration>();
services.AddSingleton<Nest.IElasticClient>(s => s.GetRequiredService<ExceptionlessElasticConfiguration>().Client);
services.AddSingleton<ElasticsearchClient>(s => s.GetRequiredService<ExceptionlessElasticConfiguration>().Client);
services.AddSingleton<IElasticConfiguration>(s => s.GetRequiredService<ExceptionlessElasticConfiguration>());
services.AddStartupAction<ExceptionlessElasticConfiguration>();

Expand Down Expand Up @@ -278,13 +258,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
4 changes: 1 addition & 3 deletions src/Exceptionless.Core/Exceptionless.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,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.19" />
<PackageReference Include="Foundatio.JsonNet" Version="13.0.0-beta3.19" />
<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.5" />
Expand All @@ -34,7 +32,7 @@
<PackageReference Include="Stripe.net" Version="47.4.0" />
<PackageReference Include="System.DirectoryServices" Version="10.0.5" />
<PackageReference Include="UAParser" Version="3.1.47" />
<PackageReference Include="Foundatio.Repositories.Elasticsearch" Version="7.18.0-beta4.24" Condition="'$(ReferenceFoundatioRepositoriesSource)' == '' OR '$(ReferenceFoundatioRepositoriesSource)' == 'false'" />
<PackageReference Include="Foundatio.Repositories.Elasticsearch" Version="8.0.0-preview.elastic-client.0.79" Condition="'$(ReferenceFoundatioRepositoriesSource)' == '' OR '$(ReferenceFoundatioRepositoriesSource)' == 'false'" />
<ProjectReference Include="..\..\..\..\Foundatio\Foundatio.Repositories\src\Foundatio.Repositories.Elasticsearch\Foundatio.Repositories.Elasticsearch.csproj" Condition="'$(ReferenceFoundatioRepositoriesSource)' == 'true'" />
</ItemGroup>
</Project>
147 changes: 82 additions & 65 deletions src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Exceptionless.Core.Models;
using Exceptionless.Core.Serialization;
using Foundatio.Serializer;

namespace Exceptionless.Core.Extensions;

public static class DataDictionaryExtensions
{
/// <summary>
/// Options for deserializing JsonElement values with snake_case property names (standard format).
/// Uses the naming policy to map C# PascalCase names to snake_case JSON names.
/// </summary>
private static readonly JsonSerializerOptions SnakeCaseOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance
};

/// <summary>
/// Fallback options for deserializing JsonElement values with PascalCase property names.
/// Handles legacy or non-standard input where property names match C# names directly.
/// </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 +62,71 @@ 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 from JsonElement, trying snake_case naming first (standard format)
// then falling back to PascalCase for legacy/non-standard input.
var result = jsonElement.Deserialize<T>(SnakeCaseOptions);
if (result is not null)
return result;

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 +139,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 +168,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 +199,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
Loading