Skip to content

Commit d64cd62

Browse files
niemyjskiCopilot
andcommitted
Replace JSON.NET with System.Text.Json across the codebase
Remove all Newtonsoft.Json serialization from the application layer, replacing it with System.Text.Json (STJ). NEST still brings in Newtonsoft transitively, but all application-level serialization now uses STJ exclusively. Key changes: - Add ElasticSystemTextJsonSerializer as custom IElasticsearchSerializer for NEST - Add EmptyCollectionModifier to omit empty collections during serialization - Add ObjectToInferredTypesConverter to handle JObject/JToken from NEST reads - Add JsonNodeExtensions as STJ equivalents of JObject helpers for event upgraders - Add IJsonOnDeserialized to Event model to merge [JsonExtensionData] into Data dict - Add [JsonPropertyName] attributes to V1 webhook models for PascalCase compat - Migrate all event upgraders from JObject to JsonObject (System.Text.Json.Nodes) - Migrate all plugins from ISerializer/JsonSerializerOptions DI injection - Use case-insensitive deserialization for DataDictionary.GetValue<T>() from JsonElement - Use semantic comparison (JsonNode.DeepEquals) in tests for fixture validation - Remove DataObjectConverter, ElasticJsonNetSerializer, and related Newtonsoft classes - Remove Foundatio.JsonNet, NEST.JsonNetSerializer, FluentRest.NewtonsoftJson packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ff4305b commit d64cd62

86 files changed

Lines changed: 1767 additions & 1283 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,26 @@ pr-reviewer → security pre-screen (before build!) → dependency audit
8282
- Never commit secrets — use environment variables
8383
- NuGet feeds are in `NuGet.Config` — don't add sources
8484
- Prefer additive documentation updates — don't replace strategic docs wholesale, extend them
85+
86+
## Serialization Architecture
87+
88+
The project uses **System.Text.Json (STJ)** exclusively. NEST still brings in Newtonsoft.Json transitively, but all application-level serialization uses STJ:
89+
90+
| Component | Serializer | Notes |
91+
| -------------- | --------------------------------- | -------------------------------------------- |
92+
| Elasticsearch | `ElasticSystemTextJsonSerializer` | Custom `IElasticsearchSerializer` using STJ |
93+
| Event Upgrader | `System.Text.Json.Nodes` | JsonObject/JsonArray for mutable DOM |
94+
| Data Storage | `SystemTextJsonSerializer` | Via Foundatio's STJ support |
95+
| API | STJ (built-in) | ASP.NET Core default with custom options |
96+
97+
**Key files:**
98+
99+
- `ElasticSystemTextJsonSerializer.cs` - Custom `IElasticsearchSerializer` for NEST
100+
- `JsonNodeExtensions.cs` - STJ equivalents of JObject helpers
101+
- `ObjectToInferredTypesConverter.cs` - Handles JObject/JToken from NEST during STJ serialization
102+
- `V*_EventUpgrade.cs` - Event version upgraders using JsonObject
103+
104+
**Security:**
105+
106+
- Safe JSON encoding used everywhere (escapes `<`, `>`, `&`, `'` for XSS protection)
107+
- No `UnsafeRelaxedJsonEscaping` in the codebase

src/Exceptionless.Core/Bootstrapper.cs

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
using Exceptionless.Core.Services;
2525
using Exceptionless.Core.Utility;
2626
using Exceptionless.Core.Validation;
27-
using Exceptionless.Serializer;
2827
using FluentValidation;
2928
using Foundatio.Caching;
3029
using Foundatio.Extensions.Hosting.Jobs;
@@ -53,27 +52,7 @@ public class Bootstrapper
5352
{
5453
public static void RegisterServices(IServiceCollection services, AppOptions appOptions)
5554
{
56-
// PERF: Work towards getting rid of JSON.NET.
57-
Newtonsoft.Json.JsonConvert.DefaultSettings = () => new Newtonsoft.Json.JsonSerializerSettings
58-
{
59-
DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset
60-
};
61-
62-
services.AddSingleton<Newtonsoft.Json.Serialization.IContractResolver>(_ => GetJsonContractResolver());
63-
services.AddSingleton<Newtonsoft.Json.JsonSerializerSettings>(s =>
64-
{
65-
// NOTE: These settings may need to be synced in the Elastic Configuration.
66-
var settings = new Newtonsoft.Json.JsonSerializerSettings
67-
{
68-
MissingMemberHandling = Newtonsoft.Json.MissingMemberHandling.Ignore,
69-
DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset,
70-
ContractResolver = s.GetRequiredService<Newtonsoft.Json.Serialization.IContractResolver>()
71-
};
72-
73-
settings.AddModelConverters(s.GetRequiredService<ILogger<Bootstrapper>>());
74-
return settings;
75-
});
76-
55+
// Register System.Text.Json options with Exceptionless defaults (snake_case, null handling)
7756
services.AddSingleton(_ => new JsonSerializerOptions().ConfigureExceptionlessDefaults());
7857

7958
services.AddSingleton<ISerializer>(s => s.GetRequiredService<ITextSerializer>());
@@ -278,13 +257,6 @@ public static void AddHostedJobs(IServiceCollection services, ILoggerFactory log
278257
logger.LogWarning("Jobs running in process");
279258
}
280259

281-
public static DynamicTypeContractResolver GetJsonContractResolver()
282-
{
283-
var resolver = new DynamicTypeContractResolver(new LowerCaseUnderscorePropertyNamesContractResolver());
284-
resolver.UseDefaultResolverFor(typeof(DataDictionary), typeof(SettingsDictionary), typeof(VersionOnePlugin.VersionOneWebHookStack), typeof(VersionOnePlugin.VersionOneWebHookEvent));
285-
return resolver;
286-
}
287-
288260
private static IQueue<T> CreateQueue<T>(IServiceProvider container, TimeSpan? workItemTimeout = null) where T : class
289261
{
290262
var loggerFactory = container.GetRequiredService<ILoggerFactory>();

src/Exceptionless.Core/Exceptionless.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
<PackageReference Include="FluentValidation" Version="12.1.1" />
2525
<PackageReference Include="Foundatio.Extensions.Hosting" Version="13.0.0-beta3.19" />
2626
<PackageReference Include="Foundatio.JsonNet" Version="13.0.0-beta3.19" />
27+
<PackageReference Include="Foundatio.Extensions.Hosting" Version="13.0.0-beta3.5" />
2728
<PackageReference Include="MiniValidation" Version="0.9.2" />
28-
<PackageReference Include="NEST.JsonNetSerializer" Version="7.17.5" />
2929
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
3030
<PackageReference Include="McSherry.SemanticVersioning" Version="1.4.1" />
3131
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.5" />

src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs

Lines changed: 67 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,44 @@
11
using System.Text.Json;
22
using System.Text.Json.Nodes;
33
using Exceptionless.Core.Models;
4+
using Foundatio.Serializer;
45

56
namespace Exceptionless.Core.Extensions;
67

78
public static class DataDictionaryExtensions
89
{
10+
/// <summary>
11+
/// Options for deserializing JsonElement values that may use PascalCase or snake_case
12+
/// property names. Uses case-insensitive matching without a naming policy so both formats work.
13+
/// </summary>
14+
private static readonly JsonSerializerOptions CaseInsensitiveOptions = new()
15+
{
16+
PropertyNameCaseInsensitive = true
17+
};
918
/// <summary>
1019
/// Retrieves a typed value from the <see cref="DataDictionary"/>, deserializing if necessary.
1120
/// </summary>
1221
/// <typeparam name="T">The target type to deserialize to.</typeparam>
1322
/// <param name="extendedData">The data dictionary containing the value.</param>
1423
/// <param name="key">The key of the value to retrieve.</param>
15-
/// <param name="options">The JSON serializer options to use for deserialization.</param>
24+
/// <param name="serializer">The text serializer to use for deserialization.</param>
1625
/// <returns>The deserialized value, or <c>default</c> if deserialization fails.</returns>
1726
/// <exception cref="KeyNotFoundException">Thrown when the key is not found in the dictionary.</exception>
1827
/// <remarks>
1928
/// <para>This method handles multiple source formats in priority order:</para>
2029
/// <list type="number">
2130
/// <item><description>Direct type match - returns value directly</description></item>
2231
/// <item><description><see cref="JsonDocument"/> - extracts root element and deserializes</description></item>
23-
/// <item><description><see cref="JsonElement"/> - deserializes using provided options</description></item>
24-
/// <item><description><see cref="JsonNode"/> - deserializes using provided options</description></item>
25-
/// <item><description><see cref="Dictionary{TKey,TValue}"/> - re-serializes to JSON then deserializes (for ObjectToInferredTypesConverter output)</description></item>
26-
/// <item><description><see cref="List{T}"/> of objects - re-serializes to JSON then deserializes</description></item>
27-
/// <item><description><see cref="Newtonsoft.Json.Linq.JObject"/> - uses ToObject for Elasticsearch compatibility (data read from Elasticsearch uses JSON.NET)</description></item>
28-
/// <item><description>JSON string - parses and deserializes</description></item>
32+
/// <item><description><see cref="JsonElement"/> - extracts raw JSON and deserializes via ITextSerializer</description></item>
33+
/// <item><description><see cref="JsonNode"/> - extracts JSON string and deserializes via ITextSerializer</description></item>
34+
/// <item><description><see cref="Dictionary{TKey,TValue}"/> - re-serializes to JSON then deserializes via ITextSerializer</description></item>
35+
/// <item><description><see cref="List{T}"/> of objects - re-serializes to JSON then deserializes via ITextSerializer</description></item>
36+
/// <item><description><see cref="Newtonsoft.Json.Linq.JObject"/> - uses ToObject for Elasticsearch compatibility</description></item>
37+
/// <item><description>JSON string - deserializes via ITextSerializer</description></item>
2938
/// <item><description>Fallback - attempts type conversion via ToType</description></item>
3039
/// </list>
3140
/// </remarks>
32-
public static T? GetValue<T>(this DataDictionary extendedData, string key, JsonSerializerOptions options)
41+
public static T? GetValue<T>(this DataDictionary extendedData, string key, ITextSerializer serializer)
3342
{
3443
if (!extendedData.TryGetValue(key, out object? data))
3544
throw new KeyNotFoundException($"Key \"{key}\" not found in the dictionary.");
@@ -42,18 +51,46 @@ public static class DataDictionaryExtensions
4251
data = jsonDocument.RootElement;
4352

4453
// JsonElement (from STJ deserialization when ObjectToInferredTypesConverter wasn't used)
45-
if (data is JsonElement jsonElement &&
46-
TryDeserialize(jsonElement, options, out T? jsonElementResult))
54+
if (data is JsonElement jsonElement)
4755
{
48-
return jsonElementResult;
56+
try
57+
{
58+
// Fast-path for string type
59+
if (typeof(T) == typeof(string))
60+
{
61+
object? s = jsonElement.ValueKind switch
62+
{
63+
JsonValueKind.String => jsonElement.GetString(),
64+
JsonValueKind.Number => jsonElement.GetRawText(),
65+
JsonValueKind.True => "true",
66+
JsonValueKind.False => "false",
67+
JsonValueKind.Null => null,
68+
_ => jsonElement.GetRawText()
69+
};
70+
71+
return (T?)s;
72+
}
73+
74+
// Deserialize directly from JsonElement using case-insensitive matching.
75+
// This handles both snake_case (from Elasticsearch) and PascalCase (from
76+
// [JsonExtensionData] which preserves original property names).
77+
var result = jsonElement.Deserialize<T>(CaseInsensitiveOptions);
78+
if (result is not null)
79+
return result;
80+
}
81+
catch
82+
{
83+
// Ignored - fall through to next handler
84+
}
4985
}
5086

5187
// JsonNode (JsonObject/JsonArray/JsonValue)
5288
if (data is JsonNode jsonNode)
5389
{
5490
try
5591
{
56-
var result = jsonNode.Deserialize<T>(options);
92+
string jsonString = jsonNode.ToJsonString();
93+
var result = serializer.Deserialize<T>(jsonString);
5794
if (result is not null)
5895
return result;
5996
}
@@ -64,15 +101,18 @@ public static class DataDictionaryExtensions
64101
}
65102

66103
// Dictionary<string, object?> from ObjectToInferredTypesConverter
67-
// Re-serialize to JSON then deserialize to target type with proper naming policy
104+
// Re-serialize to JSON then deserialize to target type via ITextSerializer
68105
if (data is Dictionary<string, object?> dictionary)
69106
{
70107
try
71108
{
72-
string dictJson = JsonSerializer.Serialize(dictionary, options);
73-
var result = JsonSerializer.Deserialize<T>(dictJson, options);
74-
if (result is not null)
75-
return result;
109+
string? dictJson = serializer.SerializeToString(dictionary);
110+
if (dictJson is not null)
111+
{
112+
var result = serializer.Deserialize<T>(dictJson);
113+
if (result is not null)
114+
return result;
115+
}
76116
}
77117
catch
78118
{
@@ -85,10 +125,13 @@ public static class DataDictionaryExtensions
85125
{
86126
try
87127
{
88-
string listJson = JsonSerializer.Serialize(list, options);
89-
var result = JsonSerializer.Deserialize<T>(listJson, options);
90-
if (result is not null)
91-
return result;
128+
string? listJson = serializer.SerializeToString(list);
129+
if (listJson is not null)
130+
{
131+
var result = serializer.Deserialize<T>(listJson);
132+
if (result is not null)
133+
return result;
134+
}
92135
}
93136
catch
94137
{
@@ -111,12 +154,12 @@ public static class DataDictionaryExtensions
111154
}
112155
}
113156

114-
// JSON string
157+
// JSON string - deserialize via ITextSerializer
115158
if (data is string json && json.IsJson())
116159
{
117160
try
118161
{
119-
var result = JsonSerializer.Deserialize<T>(json, options);
162+
var result = serializer.Deserialize<T>(json);
120163
if (result is not null)
121164
return result;
122165
}
@@ -142,49 +185,9 @@ public static class DataDictionaryExtensions
142185
return default;
143186
}
144187

145-
private static bool TryDeserialize<T>(JsonElement element, JsonSerializerOptions options, out T? result)
146-
{
147-
result = default;
148-
149-
try
150-
{
151-
// Fast-path for common primitives where the element isn't an object/array
152-
// (Deserialize<T> also works for these, but this avoids some edge cases and allocations)
153-
if (typeof(T) == typeof(string))
154-
{
155-
object? s = element.ValueKind switch
156-
{
157-
JsonValueKind.String => element.GetString(),
158-
JsonValueKind.Number => element.GetRawText(),
159-
JsonValueKind.True => "true",
160-
JsonValueKind.False => "false",
161-
JsonValueKind.Null => null,
162-
_ => element.GetRawText()
163-
};
164-
165-
result = (T?)s;
166-
return true;
167-
}
168-
169-
// General case
170-
var deserialized = element.Deserialize<T>(options);
171-
if (deserialized is not null)
172-
{
173-
result = deserialized;
174-
return true;
175-
}
176-
}
177-
catch
178-
{
179-
// Ignored
180-
}
181-
182-
return false;
183-
}
184-
185188
public static void RemoveSensitiveData(this DataDictionary extendedData)
186189
{
187-
string[] removeKeys = extendedData.Keys.Where(k => k.StartsWith('-')).ToArray();
190+
string[] removeKeys = [.. extendedData.Keys.Where(k => k.StartsWith('-'))];
188191
foreach (string key in removeKeys)
189192
extendedData.Remove(key);
190193
}

src/Exceptionless.Core/Extensions/ErrorExtensions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
using System.Text.Json;
21
using Exceptionless.Core.Models;
32
using Exceptionless.Core.Models.Data;
3+
using Foundatio.Serializer;
44

55
namespace Exceptionless.Core.Extensions;
66

@@ -59,9 +59,9 @@ public static StackingTarget GetStackingTarget(this Error error)
5959
};
6060
}
6161

62-
public static StackingTarget? GetStackingTarget(this Event ev, JsonSerializerOptions options)
62+
public static StackingTarget? GetStackingTarget(this Event ev, ITextSerializer serializer)
6363
{
64-
var error = ev.GetError(options);
64+
var error = ev.GetError(serializer);
6565
return error?.GetStackingTarget();
6666
}
6767

0 commit comments

Comments
 (0)