Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
24bd01b
Replace SwashBuckle with Scalar, Replace JSON.NET from API Surface wi…
niemyjski Dec 6, 2025
284609c
Removes duplicate package references
niemyjski Jan 16, 2026
646fe83
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 16, 2026
70580ff
Removed json.net
niemyjski Jan 16, 2026
af322d5
Removes Delta operation filter.
niemyjski Jan 16, 2026
4d43aae
Updates Scalar.AspNetCore package
niemyjski Jan 16, 2026
b69261a
Improves docs and authentication setup
niemyjski Jan 16, 2026
90bbf20
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 17, 2026
6d19c4b
update deps
niemyjski Jan 17, 2026
ac1a6d9
Uses System.Text.Json for WebSocket serialization.
niemyjski Jan 17, 2026
9d1c919
Configures STJ for tests
niemyjski Jan 17, 2026
0520e74
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 19, 2026
41a2fcf
reverted changes
niemyjski Jan 19, 2026
4df5271
Fixed url
niemyjski Jan 19, 2026
57989c1
updated node
niemyjski Jan 19, 2026
c995b4f
Removes required keyword from auth models
niemyjski Jan 19, 2026
ca51ba6
Moves to System.Text.Json serializer
niemyjski Jan 19, 2026
6f27fe3
Configures JsonSerializer to ignore null values
niemyjski Jan 19, 2026
554573a
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 20, 2026
4640b5d
Adds support for inferred types during deserialization
niemyjski Jan 21, 2026
cfed374
Enhances DataDictionary deserialization
niemyjski Jan 21, 2026
f94c5e8
Adds JsonSerializerOptions to EventExtensions
niemyjski Jan 22, 2026
4bd1f7c
Enhances OpenAPI schema generation
niemyjski Jan 22, 2026
5e7c340
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 22, 2026
a36c494
Optimizes number deserialization in JSON converter
niemyjski Jan 22, 2026
3be699d
Updates Foundatio and Scalar dependencies
niemyjski Jan 22, 2026
06f16ae
Migrates to System.Text.Json
niemyjski Jan 22, 2026
b837f52
Removes ITextSerializer contract tests
niemyjski Jan 22, 2026
e9fd971
Ensures correct type inference in serializer
niemyjski Jan 22, 2026
4ebe289
Improves JSON deserialization accuracy.
niemyjski Jan 22, 2026
86c6882
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 22, 2026
78f0cd1
Updates Scalar.AspNetCore package
niemyjski Jan 22, 2026
f03171c
Adds authentication descriptions to OpenAPI
niemyjski Jan 23, 2026
dff0b27
Fixed Scalar Auth
niemyjski Jan 23, 2026
a340bc4
Merge branch 'main' into feature/scalar-system-text-json
niemyjski Jan 24, 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
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24

- name: Cache node_modules
uses: actions/cache@v4
Expand Down
4 changes: 2 additions & 2 deletions build/generate-client.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
curl -X POST https://generator3.swagger.io/api/generate \
-H 'content-type: application/json' \
-d '{
"specURL" : "https://collector.exceptionless.io/docs/v2/swagger.json",
"specURL" : "https://collector.exceptionless.io/docs/v2/openapi.json",
"lang" : "typescript-fetch",
"type" : "CLIENT",
"options" : {
Expand All @@ -11,4 +11,4 @@ curl -X POST https://generator3.swagger.io/api/generate \
}
},
"codegenVersion" : "V3"
}' --output exceptionless-ts.zip
}' --output exceptionless-ts.zip
37 changes: 22 additions & 15 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using AutoMapper;
using System.Text.Json;
using System.Text.Json.Serialization;
using AutoMapper;
using Exceptionless.Core.Authentication;
using Exceptionless.Core.Billing;
using Exceptionless.Core.Configuration;
Expand Down Expand Up @@ -44,8 +46,6 @@
using Foundatio.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using DataDictionary = Exceptionless.Core.Models.DataDictionary;
using MaintainIndexesJob = Foundatio.Repositories.Elasticsearch.Jobs.MaintainIndexesJob;

Expand All @@ -55,32 +55,39 @@ public class Bootstrapper
{
public static void RegisterServices(IServiceCollection services, AppOptions appOptions)
{
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
// PERF: Word towards getting rid of JSON.NET.
Newtonsoft.Json.JsonConvert.DefaultSettings = () => new Newtonsoft.Json.JsonSerializerSettings
{
DateParseHandling = DateParseHandling.DateTimeOffset
DateParseHandling = Newtonsoft.Json.DateParseHandling.DateTimeOffset
};

services.AddSingleton<IContractResolver>(_ => GetJsonContractResolver());
services.AddSingleton<JsonSerializerSettings>(s =>
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 JsonSerializerSettings
var settings = new Newtonsoft.Json.JsonSerializerSettings
{
MissingMemberHandling = MissingMemberHandling.Ignore,
DateParseHandling = DateParseHandling.DateTimeOffset,
ContractResolver = s.GetRequiredService<IContractResolver>()
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;
});

services.AddSingleton<JsonSerializerOptions>(_ => new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance,
Converters = { new ObjectToInferredTypesConverter() }
});

services.AddSingleton<ISerializer>(s => s.GetRequiredService<ITextSerializer>());
services.AddSingleton<ITextSerializer>(s => new SystemTextJsonSerializer(s.GetRequiredService<JsonSerializerOptions>()));

services.ReplaceSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddSingleton<IResiliencePolicyProvider, ResiliencePolicyProvider>();
services.AddSingleton<JsonSerializer>(s => JsonSerializer.Create(s.GetRequiredService<JsonSerializerSettings>()));
services.AddSingleton<ISerializer>(s => new JsonNetSerializer(s.GetRequiredService<JsonSerializerSettings>()));
services.AddSingleton<ITextSerializer>(s => new JsonNetSerializer(s.GetRequiredService<JsonSerializerSettings>()));

services.AddSingleton<ICacheClient>(s => new InMemoryCacheClient(new InMemoryCacheClientOptions
{
CloneValues = true,
Expand Down
5 changes: 2 additions & 3 deletions src/Exceptionless.Core/Configuration/AppOptions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using System.Diagnostics;
using System.Text.Json.Serialization;
using Exceptionless.Core.Configuration;
using Exceptionless.Core.Extensions;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace Exceptionless.Core;

Expand All @@ -26,7 +25,7 @@ public class AppOptions
/// </summary>
public string? ExceptionlessServerUrl { get; internal set; }

[JsonConverter(typeof(StringEnumConverter))]
[JsonConverter(typeof(JsonStringEnumConverter))]
public AppMode AppMode { get; internal set; }
public string AppScope { get; internal set; } = null!;

Expand Down
7 changes: 3 additions & 4 deletions src/Exceptionless.Core/Configuration/EmailOptions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
using Exceptionless.Core.Extensions;
using System.Text.Json.Serialization;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Utility;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace Exceptionless.Core.Configuration;

Expand All @@ -26,7 +25,7 @@ public class EmailOptions

public int SmtpPort { get; internal set; }

[JsonConverter(typeof(StringEnumConverter))]
[JsonConverter(typeof(JsonStringEnumConverter))]
public SmtpEncryption SmtpEncryption { get; internal set; }

public string? SmtpUser { get; internal set; }
Expand Down
158 changes: 148 additions & 10 deletions src/Exceptionless.Core/Extensions/DataDictionaryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,52 +1,190 @@
using Exceptionless.Core.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Exceptionless.Core.Models;

namespace Exceptionless.Core.Extensions;

public static class DataDictionaryExtensions
{
public static T? GetValue<T>(this DataDictionary extendedData, string key)
/// <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>
/// <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>Fallback - attempts type conversion via ToType</description></item>
/// </list>
/// </remarks>
public static T? GetValue<T>(this DataDictionary extendedData, string key, JsonSerializerOptions options)
{
if (!extendedData.TryGetValue(key, out object? data))
throw new KeyNotFoundException($"Key \"{key}\" not found in the dictionary.");

if (data is T value)
return value;

if (data is JObject jObject)
// JsonDocument -> JsonElement
if (data is JsonDocument jsonDocument)
data = jsonDocument.RootElement;

// JsonElement (from STJ deserialization when ObjectToInferredTypesConverter wasn't used)
if (data is JsonElement jsonElement)
{
if (TryDeserialize(jsonElement, options, out T? result))
return result;
}

// JsonNode (JsonObject/JsonArray/JsonValue)
if (data is JsonNode jsonNode)
{
try
{
var result = jsonNode.Deserialize<T>(options);
if (result is not null)
return result;
}
catch
{
// Ignored - fall through to next handler
}
}

// Dictionary<string, object?> from ObjectToInferredTypesConverter
// Re-serialize to JSON then deserialize to target type with proper naming policy
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;
}
catch
{
// Ignored - fall through to next handler
}
}

// List<object?> from ObjectToInferredTypesConverter (for array values)
if (data is List<object?> list)
{
try
{
string listJson = JsonSerializer.Serialize(list, options);
var result = JsonSerializer.Deserialize<T>(listJson, options);
if (result is not null)
return result;
}
catch
{
// Ignored - fall through to next handler
}
}

// Newtonsoft.Json.Linq.JObject - for Elasticsearch compatibility.
// When data is read from Elasticsearch (which uses JSON.NET via NEST), complex objects
// in DataDictionary are deserialized as JObject. This handler converts them to the target type.
if (data is Newtonsoft.Json.Linq.JObject jObject)
{
try
{
return jObject.ToObject<T>();
}
catch { }
catch
{
// Ignored - fall through to next handler
}
}

// JSON string
if (data is string json && json.IsJson())
{
try
{
return JsonConvert.DeserializeObject<T>(json);
var result = JsonSerializer.Deserialize<T>(json, options);
if (result is not null)
return result;
}
catch
{
// Ignored - fall through to next handler
}
catch { }
}

// Fallback: attempt direct type conversion
try
{
if (data != null)
{
return data.ToType<T>();
}
}
catch { }
catch
{
// Ignored
}

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('-')).ToArray();
foreach (string key in removeKeys)
extendedData.Remove(key);
}
Expand Down
5 changes: 3 additions & 2 deletions src/Exceptionless.Core/Extensions/ErrorExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Data;

Expand Down Expand Up @@ -58,9 +59,9 @@ public static StackingTarget GetStackingTarget(this Error error)
};
}

public static StackingTarget? GetStackingTarget(this Event ev)
public static StackingTarget? GetStackingTarget(this Event ev, JsonSerializerOptions options)
{
var error = ev.GetError();
var error = ev.GetError(options);
return error?.GetStackingTarget();
}

Expand Down
Loading
Loading