Skip to content

Commit 2b82d4c

Browse files
niemyjskiCopilot
andcommitted
Switch to SnakeCaseLower, fix DataDictionary roundtrip bug, fix exclusion bug
- Replace custom SnakeCaseNamingPolicy with built-in JsonNamingPolicy.SnakeCaseLower (aligns with server approach in exceptionless/Exceptionless#2135) - Delete unused SnakeCaseNamingPolicy.cs - Fix DataDictionaryConverter.Write: use WriteRawValue for JSON strings that were previously objects (fixes double-escaping on storage roundtrip) - Fix exclusion logic: add TypeInfoResolver = new DefaultJsonTypeInfoResolver() so GetTypeInfo() works and WriteValue can filter properties by name - Update all test assertions to expect snake_case property names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fc3ec8f commit 2b82d4c

7 files changed

Lines changed: 76 additions & 104 deletions

File tree

src/Exceptionless/Serializer/DataDictionaryConverter.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ public override void Write(Utf8JsonWriter writer, DataDictionary value, JsonSeri
7272
writer.WritePropertyName(kvp.Key);
7373
if (kvp.Value == null) {
7474
writer.WriteNullValue();
75+
} else if (kvp.Value is string str && str.Length > 0 && (str[0] == '{' || str[0] == '[')) {
76+
// String values that contain JSON (from roundtripping through storage)
77+
// must be emitted as raw JSON objects, not escaped strings.
78+
try {
79+
writer.WriteRawValue(str);
80+
} catch (JsonException) {
81+
// Not valid JSON - write as a normal string
82+
writer.WriteStringValue(str);
83+
}
7584
} else {
7685
JsonSerializer.Serialize(writer, kvp.Value, kvp.Value.GetType(), options);
7786
}

src/Exceptionless/Serializer/DefaultJsonSerializer.cs

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Collections;
33
using System.Collections.Generic;
44
using System.IO;
5-
using System.Linq;
65
using System.Reflection;
76
using System.Text;
87
using System.Text.Json;
@@ -17,11 +16,10 @@ public class DefaultJsonSerializer : IJsonSerializer, IStorageSerializer {
1716
public DefaultJsonSerializer() {
1817
_serializerOptions = new JsonSerializerOptions {
1918
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
19+
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
2020
PropertyNameCaseInsensitive = true,
2121
NumberHandling = JsonNumberHandling.AllowReadingFromString,
22-
TypeInfoResolver = new DefaultJsonTypeInfoResolver {
23-
Modifiers = { ApplySnakeCaseToExceptionlessModels }
24-
}
22+
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
2523
};
2624

2725
_serializerOptions.Converters.Add(new JsonStringEnumConverter());
@@ -182,23 +180,5 @@ public virtual object Deserialize(string json, Type type) {
182180
return JsonSerializer.Deserialize(json, type, _serializerOptions);
183181
}
184182

185-
private static void ApplySnakeCaseToExceptionlessModels(JsonTypeInfo typeInfo) {
186-
if (typeInfo.Kind != JsonTypeInfoKind.Object)
187-
return;
188-
189-
// Apply snake_case naming to types in Exceptionless.Models namespace
190-
string ns = typeInfo.Type.Namespace;
191-
if (ns == null || !ns.StartsWith("Exceptionless.Models"))
192-
return;
193-
194-
foreach (var property in typeInfo.Properties) {
195-
// Don't override explicit [JsonPropertyName] attributes
196-
var jsonPropAttr = property.AttributeProvider?.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), false);
197-
if (jsonPropAttr != null && jsonPropAttr.Length > 0)
198-
continue;
199-
200-
property.Name = SnakeCaseNamingPolicy.Instance.ConvertName(property.Name);
201-
}
202-
}
203183
}
204184
}

src/Exceptionless/Serializer/SnakeCaseNamingPolicy.cs

Lines changed: 0 additions & 63 deletions
This file was deleted.

test/Exceptionless.Tests/Configuration/DataExclusionTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public void CanHandleObject() {
6767
var ev = new Event();
6868
ev.SetProperty(nameof(order), order, excludedPropertyNames: new [] { nameof(order.CardLast4) });
6969
Assert.Single(ev.Data);
70-
Assert.Equal("{\"Id\":\"1234\",\"Data\":{}}", ev.Data.GetString(nameof(order)));
70+
Assert.Equal("{\"id\":\"1234\",\"data\":{}}", ev.Data.GetString(nameof(order)));
7171
}
7272

7373
[InlineData("Credit*", true)]

test/Exceptionless.Tests/Serializer/JsonSerializerTests.cs

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ public void Serialize_Error_IsValidJSON() {
156156
string json = serializer.Serialize(error);
157157

158158
// Assert
159-
Assert.Equal("{\"modules\":[{\"module_id\":1,\"name\":\"TestModule\",\"version\":\"1.0.0\",\"is_entry\":true,\"created_date\":\"2023-05-01T12:00:00Z\",\"modified_date\":\"2023-05-02T12:00:00Z\",\"data\":{\"PublicKeyToken\":\"b03f5f7f11d50a3a\"}}],\"message\":\"Test error message\",\"type\":\"System.Exception\",\"code\":\"1001\",\"data\":{\"@ext\":{\"OrderNumber\":10}},\"inner\":{\"message\":\"Inner error message\",\"type\":\"System.ArgumentException\",\"code\":\"2002\",\"data\":{},\"inner\":null,\"stack_trace\":[{\"file_name\":null,\"line_number\":20,\"column\":0,\"is_signature_target\":false,\"declaring_namespace\":null,\"declaring_type\":null,\"name\":\"InnerMethodName\",\"module_id\":0,\"data\":{},\"generic_arguments\":[],\"parameters\":[]}],\"target_method\":null},\"stack_trace\":[{\"file_name\":\"TestFile.cs\",\"line_number\":20,\"column\":5,\"is_signature_target\":true,\"declaring_namespace\":\"TestNamespace\",\"declaring_type\":\"TestClass\",\"name\":\"InnerMethodName\",\"module_id\":1,\"data\":{\"StackFrameKey\":\"StackFrameValue\"},\"generic_arguments\":[\"T\"],\"parameters\":[{\"name\":\"param1\",\"type\":\"System.String\",\"type_namespace\":\"System\",\"data\":{\"ParameterKey\":\"ParameterValue\"},\"generic_arguments\":[\"U\"]}]}],\"target_method\":null}", json);
159+
Assert.Equal("{\"modules\":[{\"module_id\":1,\"name\":\"TestModule\",\"version\":\"1.0.0\",\"is_entry\":true,\"created_date\":\"2023-05-01T12:00:00Z\",\"modified_date\":\"2023-05-02T12:00:00Z\",\"data\":{\"PublicKeyToken\":\"b03f5f7f11d50a3a\"}}],\"message\":\"Test error message\",\"type\":\"System.Exception\",\"code\":\"1001\",\"data\":{\"@ext\":{\"order_number\":10}},\"inner\":{\"message\":\"Inner error message\",\"type\":\"System.ArgumentException\",\"code\":\"2002\",\"data\":{},\"inner\":null,\"stack_trace\":[{\"file_name\":null,\"line_number\":20,\"column\":0,\"is_signature_target\":false,\"declaring_namespace\":null,\"declaring_type\":null,\"name\":\"InnerMethodName\",\"module_id\":0,\"data\":{},\"generic_arguments\":[],\"parameters\":[]}],\"target_method\":null},\"stack_trace\":[{\"file_name\":\"TestFile.cs\",\"line_number\":20,\"column\":5,\"is_signature_target\":true,\"declaring_namespace\":\"TestNamespace\",\"declaring_type\":\"TestClass\",\"name\":\"InnerMethodName\",\"module_id\":1,\"data\":{\"StackFrameKey\":\"StackFrameValue\"},\"generic_arguments\":[\"T\"],\"parameters\":[{\"name\":\"param1\",\"type\":\"System.String\",\"type_namespace\":\"System\",\"data\":{\"ParameterKey\":\"ParameterValue\"},\"generic_arguments\":[\"U\"]}]}],\"target_method\":null}", json);
160160
}
161161

162162
[Fact]
@@ -263,7 +263,7 @@ public void Serialize_SimpleError_IsValidJSON() {
263263
string json = serializer.Serialize(simpleError);
264264

265265
// Assert
266-
Assert.Equal("{\"modules\":[{\"module_id\":1,\"name\":\"TestModule\",\"version\":\"1.0.0\",\"is_entry\":true,\"created_date\":\"2023-05-01T12:00:00Z\",\"modified_date\":\"2023-05-02T12:00:00Z\",\"data\":{\"PublicKeyToken\":\"b77a5c561934e089\"}}],\"message\":\"Test error message\",\"type\":\"System.Exception\",\"stack_trace\":\"at TestClass.TestMethod()\",\"data\":{\"@ext\":{\"OrderNumber\":10}},\"inner\":{\"message\":\"Inner error message\",\"type\":\"System.NullReferenceException\",\"stack_trace\":\"at InnerTestClass.InnerTestMethod()\",\"data\":{},\"inner\":null}}", json);
266+
Assert.Equal("{\"modules\":[{\"module_id\":1,\"name\":\"TestModule\",\"version\":\"1.0.0\",\"is_entry\":true,\"created_date\":\"2023-05-01T12:00:00Z\",\"modified_date\":\"2023-05-02T12:00:00Z\",\"data\":{\"PublicKeyToken\":\"b77a5c561934e089\"}}],\"message\":\"Test error message\",\"type\":\"System.Exception\",\"stack_trace\":\"at TestClass.TestMethod()\",\"data\":{\"@ext\":{\"order_number\":10}},\"inner\":{\"message\":\"Inner error message\",\"type\":\"System.NullReferenceException\",\"stack_trace\":\"at InnerTestClass.InnerTestMethod()\",\"data\":{},\"inner\":null}}", json);
267267
}
268268

269269
[Fact]
@@ -337,7 +337,7 @@ public void Serialize_ModelWithExclusions_ShouldExcludeProperties() {
337337
string json = serializer.Serialize(data, new[] { nameof(SampleModel.Date), nameof(SampleModel.Number), nameof(SampleModel.Rating), nameof(SampleModel.Bool), nameof(SampleModel.DateOffset), nameof(SampleModel.Direction), nameof(SampleModel.Collection), nameof(SampleModel.Dictionary), nameof(SampleModel.Nested) });
338338

339339
// Assert
340-
Assert.Equal("{\"Message\":\"Testing\"}", json);
340+
Assert.Equal("{\"message\":\"Testing\"}", json);
341341
}
342342

343343
[Fact]
@@ -358,7 +358,7 @@ public void Serialize_ModelWithNestedExclusions_WillExcludeNestedProperties() {
358358
string json = serializer.Serialize(data, new[] { nameof(NestedModel.Number) });
359359

360360
// Assert
361-
Assert.Equal("{\"Message\":\"Testing\",\"Nested\":{\"Message\":\"Nested\",\"Nested\":null}}", json);
361+
Assert.Equal("{\"message\":\"Testing\",\"nested\":{\"message\":\"Nested\",\"nested\":null}}", json);
362362
}
363363

364364
[Fact]
@@ -371,7 +371,7 @@ public void Serialize_ModelWithNullValues_ShouldIncludeNullObjects() {
371371
string json = serializer.Serialize(data);
372372

373373
// Assert
374-
Assert.Equal("{\"Number\":0,\"Bool\":false,\"Message\":null,\"Collection\":null,\"Dictionary\":null,\"DataDictionary\":null}", json);
374+
Assert.Equal("{\"number\":0,\"bool\":false,\"message\":null,\"collection\":null,\"dictionary\":null,\"data_dictionary\":null}", json);
375375
}
376376

377377
[Fact]
@@ -396,7 +396,7 @@ public void Serialize_ModelWithComplexPropertyNames_ShouldExcludeMultiWordProper
396396
string json = serializer.Serialize(user, exclusions, maxDepth: 2);
397397

398398
// Assert
399-
Assert.Equal("{\"FirstName\":\"John\",\"LastName\":\"Doe\",\"Billing\":{\"ExpirationMonth\":10,\"ExpirationYear\":2020}}", json);
399+
Assert.Equal("{\"first_name\":\"John\",\"last_name\":\"Doe\",\"billing\":{\"expiration_month\":10,\"expiration_year\":2020}}", json);
400400
}
401401

402402
[Fact]
@@ -409,7 +409,7 @@ public void Serialize_ModelWithDefaultValues_ShouldIncludeDefaultValues() {
409409
string json = serializer.Serialize(data, new []{ nameof(SampleModel.Date), nameof(SampleModel.DateOffset) });
410410

411411
// Assert
412-
Assert.Equal("{\"Number\":0,\"Rating\":0,\"Bool\":false,\"Direction\":\"North\",\"Message\":null,\"Dictionary\":null,\"Collection\":null,\"Nested\":null}", json);
412+
Assert.Equal("{\"number\":0,\"rating\":0,\"bool\":false,\"direction\":\"North\",\"message\":null,\"dictionary\":null,\"collection\":null,\"nested\":null}", json);
413413

414414
var model = serializer.Deserialize<SampleModel>(json);
415415
Assert.Equal(data.Number, model.Number);
@@ -440,7 +440,7 @@ public void Serialize_ModelWithDataTypes_ShouldSerializeValues() {
440440
string json = serializer.Serialize(data);
441441

442442
// Assert
443-
Assert.Equal("{\"Number\":1,\"Rating\":4.50,\"Bool\":true,\"Direction\":\"North\",\"Date\":\"9999-12-31T23:59:59.9999999\",\"Message\":\"test\",\"DateOffset\":\"9999-12-31T23:59:59.9999999+00:00\",\"Dictionary\":{\"key\":\"value\"},\"Collection\":[\"one\"],\"Nested\":null}", json);
443+
Assert.Equal("{\"number\":1,\"rating\":4.50,\"bool\":true,\"direction\":\"North\",\"date\":\"9999-12-31T23:59:59.9999999\",\"message\":\"test\",\"date_offset\":\"9999-12-31T23:59:59.9999999+00:00\",\"dictionary\":{\"key\":\"value\"},\"collection\":[\"one\"],\"nested\":null}", json);
444444

445445
var model = serializer.Deserialize<SampleModel>(json);
446446
Assert.Equal(data.Number, model.Number);
@@ -469,7 +469,7 @@ public void Serialize_NestedModel_ShouldRespectSetMaxDepth() {
469469
string json = serializer.Serialize(data, new[] { nameof(NestedModel.Number) }, maxDepth: 2);
470470

471471
// Assert
472-
Assert.Equal("{\"Message\":\"Level 1\",\"Nested\":{\"Message\":\"Level 2\"}}", json);
472+
Assert.Equal("{\"message\":\"Level 1\",\"nested\":{\"message\":\"Level 2\"}}", json);
473473
}
474474

475475
[Fact]
@@ -486,7 +486,7 @@ public void Serialize_ModelWithNullCollections_ShouldBeSerialized() {
486486
string json = serializer.Serialize(data, new[] { nameof(DefaultsModel.Message), nameof(DefaultsModel.Bool), nameof(DefaultsModel.Number) });
487487

488488
// Assert
489-
Assert.Equal("{\"Collection\":null,\"Dictionary\":null,\"DataDictionary\":null}", json);
489+
Assert.Equal("{\"collection\":null,\"dictionary\":null,\"data_dictionary\":null}", json);
490490
}
491491

492492
[Fact]
@@ -503,7 +503,7 @@ public void Serialize_ModelWithEmptyCollections_ShouldBeSerialized() {
503503
string json = serializer.Serialize(data, new[] { nameof(DefaultsModel.Message), nameof(DefaultsModel.Bool), nameof(DefaultsModel.Number) });
504504

505505
// Assert
506-
Assert.Equal("{\"Collection\":[],\"Dictionary\":{},\"DataDictionary\":{}}", json);
506+
Assert.Equal("{\"collection\":[],\"dictionary\":{},\"data_dictionary\":{}}", json);
507507
}
508508

509509
[Fact]
@@ -520,7 +520,7 @@ public void Serialize_ModelWithDictionaryValues_ShouldRespectDictionaryKeyNames(
520520
string json = serializer.Serialize(data, new[] { nameof(DefaultsModel.Message), nameof(DefaultsModel.Bool), nameof(DefaultsModel.Number) });
521521

522522
// Assert
523-
Assert.Equal("{\"Collection\":[\"Collection\"],\"Dictionary\":{\"ItEm\":\"Value\"},\"DataDictionary\":{\"ItEm\":\"Value\"}}", json);
523+
Assert.Equal("{\"collection\":[\"Collection\"],\"dictionary\":{\"ItEm\":\"Value\"},\"data_dictionary\":{\"ItEm\":\"Value\"}}", json);
524524
}
525525

526526
[Fact]
@@ -575,7 +575,53 @@ public void Serialize_PostDataConverter_ShouldHandleRequestInfoConverterPostData
575575
string json = serializer.Serialize(requestInfo, propertiesToExclude);
576576

577577
// Assert
578-
Assert.Equal("{\"post_data\":{\"Age\":21}}", json);
578+
Assert.Equal("{\"post_data\":{\"age\":21}}", json);
579+
}
580+
581+
[Fact]
582+
public void Serialize_Event_DataDictionaryRoundTrip_PreservesObjectStructure() {
583+
// Regression test: After roundtripping through storage (serialize → deserialize),
584+
// complex objects in Data become JSON strings. When re-serialized for API submission,
585+
// they must be emitted as JSON objects (not escaped strings).
586+
var serializer = GetSerializer();
587+
588+
// Simulate what plugins do: store an object directly in Data
589+
var ev = new Event {
590+
Type = Event.KnownTypes.Error,
591+
Data = {
592+
[Event.KnownDataKeys.Error] = new Error {
593+
Message = "Test error",
594+
Type = "System.Exception"
595+
},
596+
[Event.KnownDataKeys.EnvironmentInfo] = new EnvironmentInfo {
597+
ProcessorCount = 8,
598+
OSName = "Windows",
599+
OSVersion = "10.0"
600+
}
601+
}
602+
};
603+
604+
// First serialize (to storage) - uses Serialize<T> stream path via string overload
605+
string storageJson = serializer.Serialize(ev);
606+
607+
// Verify first serialization produces JSON objects for data values
608+
Assert.Contains("\"@error\":", storageJson);
609+
Assert.DoesNotContain("\"@error\":\"", storageJson); // should NOT be a string
610+
611+
// Roundtrip through storage (deserialize then re-serialize, simulating queue)
612+
var deserialized = (Event)serializer.Deserialize(storageJson, typeof(Event));
613+
614+
// After deserialization, complex Data values become strings (DataDictionaryConverter behavior)
615+
Assert.IsType<string>(deserialized.Data[Event.KnownDataKeys.Error]);
616+
Assert.IsType<string>(deserialized.Data[Event.KnownDataKeys.EnvironmentInfo]);
617+
618+
// Re-serialize for API submission - JSON strings must be emitted as raw JSON objects
619+
string apiJson = serializer.Serialize(deserialized);
620+
Assert.DoesNotContain("\"@error\":\"", apiJson); // Must NOT be an escaped string
621+
Assert.DoesNotContain("\"@environment\":\"", apiJson);
622+
// Verify roundtripped JSON preserves the object structure
623+
Assert.Contains("\"message\":\"Test error\"", apiJson);
624+
Assert.Contains("\"o_s_name\":\"Windows\"", apiJson);
579625
}
580626

581627
[Fact]

0 commit comments

Comments
 (0)