Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
f5df2e1
Merge branch 'main' into feature/system-text-json-v2
niemyjski Mar 30, 2026
0f0a62e
Fix GetValue<T> deserialization for PascalCase dictionary keys
niemyjski Mar 30, 2026
aeac7b8
Fix nested snake_case deserialization in Error.Inner
niemyjski Mar 30, 2026
6e9c72d
Fix STJ review findings: case-insensitive dicts, DateTime.MinValue Ki…
niemyjski Mar 30, 2026
716533b
Eliminate reflection-based property counting in DataDictionary.GetVal…
niemyjski Mar 31, 2026
f2183e1
Replace ElasticSystemTextJsonSerializer with DefaultSourceSerializer
niemyjski Mar 31, 2026
5bdb52e
Use FieldEquals and StackStatus enum instead of raw TermQuery/strings
niemyjski Mar 31, 2026
b80f6f2
Fix OrganizationRepository suspended filter logic
niemyjski Mar 31, 2026
1629973
Use JsonSerializerOptions directly in JsonEventParserPlugin instead o…
niemyjski Mar 31, 2026
d2466e4
Remove dead Foundatio.JsonNet dependency; use type-safe converter rem…
niemyjski Mar 31, 2026
e7004d6
Remove unnecessary DateTime converters; restore test assertions
niemyjski Apr 2, 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.23" />
<PackageReference Include="Foundatio.JsonNet" Version="13.0.0-beta3.23" />
<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.27" 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>
162 changes: 92 additions & 70 deletions src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,69 @@
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>
/// Fallback options for deserializing legacy PascalCase JSON without a naming policy.
/// Without a naming policy, C# property names match JSON keys directly (case-insensitively),
/// which handles PascalCase data that the snake_case primary serializer cannot match.
/// </summary>
private static readonly JsonSerializerOptions CaseInsensitiveFallbackOptions = new()
{
PropertyNameCaseInsensitive = true
};

/// <summary>
/// Attempts deserialization using the primary serializer (snake_case naming policy),
/// then falls back to a case-insensitive deserializer (for legacy PascalCase data).
/// Returns whichever result populated more properties (longer serialized output).
/// </summary>
private static T? TryDeserializeWithFallback<T>(string json, ITextSerializer serializer)
{
var primary = serializer.Deserialize<T>(json);

if (primary is null)
return JsonSerializer.Deserialize<T>(json, CaseInsensitiveFallbackOptions);

var fallback = JsonSerializer.Deserialize<T>(json, CaseInsensitiveFallbackOptions);
if (fallback is not null)
{
string primaryJson = serializer.SerializeToString(primary) ?? "";
string fallbackJson = serializer.SerializeToString(fallback) ?? "";
return fallbackJson.Length > primaryJson.Length ? fallback : primary;
}

return primary;
}

/// <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,39 +76,65 @@ 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;
}

string elementJson = jsonElement.GetRawText();
return TryDeserializeWithFallback<T>(elementJson, serializer);
}
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);
if (result is not null)
return result;
string jsonString = jsonNode.ToJsonString();
return TryDeserializeWithFallback<T>(jsonString, serializer);
}
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
// Dictionary<string, object?> from ObjectToInferredTypesConverter.
// Dictionary keys preserve the original JSON casing, which may be snake_case (current format)
// or PascalCase (legacy data). The primary serializer (snake_case naming policy) handles
// snake_case keys; the fallback (no naming policy, case-insensitive) handles PascalCase.
// We try both and pick the one that populated more properties (longer serialized output).
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)
{
return TryDeserializeWithFallback<T>(dictJson, serializer);
}
}
catch
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
// Ignored - fall through to next handler
}
Expand All @@ -85,12 +145,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)
{
return TryDeserializeWithFallback<T>(listJson, serializer);
}
}
catch
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
// Ignored - fall through to next handler
}
Expand All @@ -111,12 +172,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,50 +203,11 @@ 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);
}

}
8 changes: 4 additions & 4 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 @@ -46,7 +46,7 @@ public static StackingTarget GetStackingTarget(this Error error)
// fallback to default
var defaultError = error.GetInnermostError();
var defaultMethod = defaultError.StackTrace?.FirstOrDefault();
if (defaultMethod is null && error.StackTrace is not null)
if (defaultMethod is null && error.StackTrace is { Count: > 0 })
{
defaultMethod = error.StackTrace?.FirstOrDefault();
defaultError = error;
Expand All @@ -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