Skip to content

Commit 4fb9405

Browse files
tippmar-nrclaude
andauthored
feat: add array support for custom attributes in .NET Agent API (#3456)
* feat: add array support to JsonSerializerHelpers with null filtering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add integration tests for array custom attributes - Add comprehensive integration test classes for both .NET Framework and .NET Core - Extend test applications with new array attribute endpoints - Add RemoteServiceFixture helper methods for array testing - Update Assertions.cs to support array validation in tests - Verify arrays work in transaction traces, transaction events, and error telemetry - Test empty array filtering and null element removal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: Minor refactoring and cleanup * fix: improve array validation in integration test assertions - Add support for string[], int[], bool[] typed arrays in test assertions - Extract array validation logic into ValidateArrayAttribute helper method - Handle JArray deserialization from JSON properly - Improve error messages with type information - Add regular attributes to empty array test endpoints to ensure transaction capture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add comprehensive integration tests for custom attributes array support - Add array attribute endpoints to both .NET Framework and .NET Core test applications - Create integration test classes for testing array serialization end-to-end - Test string, int, and bool arrays in transaction traces and events - Verify empty arrays and null-only arrays are properly skipped - Test null filtering within arrays (nulls are excluded from serialized arrays) - Support both ASP.NET Framework (WebAPI) and ASP.NET Core applications - Add remote service fixture methods for exercising array endpoints - Fix test reliability by using transaction events for multi-endpoint scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add array support to JsonSerializerHelpers with null filtering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add integration tests for array custom attributes - Add comprehensive integration test classes for both .NET Framework and .NET Core - Extend test applications with new array attribute endpoints - Add RemoteServiceFixture helper methods for array testing - Update Assertions.cs to support array validation in tests - Verify arrays work in transaction traces, transaction events, and error telemetry - Test empty array filtering and null element removal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: Minor refactoring and cleanup * fix: improve array validation in integration test assertions - Add support for string[], int[], bool[] typed arrays in test assertions - Extract array validation logic into ValidateArrayAttribute helper method - Handle JArray deserialization from JSON properly - Improve error messages with type information - Add regular attributes to empty array test endpoints to ensure transaction capture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add comprehensive integration tests for custom attributes array support - Add array attribute endpoints to both .NET Framework and .NET Core test applications - Create integration test classes for testing array serialization end-to-end - Test string, int, and bool arrays in transaction traces and events - Verify empty arrays and null-only arrays are properly skipped - Test null filtering within arrays (nulls are excluded from serialized arrays) - Support both ASP.NET Framework (WebAPI) and ASP.NET Core applications - Add remote service fixture methods for exercising array endpoints - Fix test reliability by using transaction events for multi-endpoint scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: enhance JsonSerializerHelpers test coverage for complete code path coverage - Add tests for all numeric types: double, float, decimal, long, char - Add tests for unsigned types: ushort, uint, ulong - Add tests for signed types: short, sbyte, byte - Add tests for character and boolean arrays - Add tests for non-serializable objects to cover exception handling path - Add tests for mixed numeric types in arrays - Achieve 100% branch coverage for JsonSerializerHelpers.WriteValue() method - All 27 unit tests passing with comprehensive validation of all execution paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * cleanup: remove CustomArrayErrorAttributes method from ASP.NET Core test controller - Remove leftover CustomArrayErrorAttributes method that attempted to pass arrays to NoticeError() - Error attributes do not support arrays and this functionality will not be implemented - Ensures test applications only contain valid array attribute endpoints - Maintains clean separation between AddCustomAttribute (supports arrays) and NoticeError (does not) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: revert to discard pattern for switch case variables in JsonSerializerHelpers - Revert unnecessary ReSharper refactor that introduced inconsistent variable naming - Use discard pattern (_) consistently across all switch cases - Maintain original value parameter usage for cleaner, more consistent code - Addresses review feedback about variable naming inconsistencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ccc871 commit 4fb9405

9 files changed

Lines changed: 1036 additions & 19 deletions

File tree

src/Agent/NewRelic/Agent/Core/JsonConverters/JsonSerializerHelpers.cs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright 2020 New Relic, Inc. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
using System.Collections;
45
using System.Collections.Generic;
56
using System.Linq;
67
using NewRelic.Agent.Core.Attributes;
@@ -22,12 +23,13 @@ public static void WriteCollection(JsonWriter writer, IEnumerable<IAttributeValu
2223
//which can result in a null value
2324
var outputValue = attribVal.Value;
2425

25-
if (outputValue == null)
26+
if (ShouldSkipValue(outputValue))
2627
{
2728
continue;
2829
}
2930

30-
WriteJsonKeyAndValue(writer, attribVal.AttributeDefinition.Name, outputValue); }
31+
WriteJsonKeyAndValue(writer, attribVal.AttributeDefinition.Name, outputValue);
32+
}
3133
}
3234

3335
writer.WriteEndObject();
@@ -38,7 +40,7 @@ public static void WriteObjectCollection(JsonWriter writer, IEnumerable<KeyValue
3840
writer.WriteStartObject();
3941
foreach (var kvp in collection)
4042
{
41-
if (kvp.Value == null)
43+
if (ShouldSkipValue(kvp.Value))
4244
{
4345
continue;
4446
}
@@ -52,50 +54,73 @@ public static void WriteObjectCollection(JsonWriter writer, IEnumerable<KeyValue
5254
private static void WriteJsonKeyAndValue(JsonWriter writer, string key, object value)
5355
{
5456
writer.WritePropertyName(key);
57+
WriteValue(writer, value, key);
58+
}
59+
60+
private static bool ShouldSkipValue(object value)
61+
{
62+
// Skip null values and collections that only contain null values. This prevents us from sending empty arrays to New Relic,
63+
// which can cause issues with some of our backend processing.
64+
return value == null || value is IEnumerable enumerable and not string &&
65+
enumerable.Cast<object>().All(element => element == null);
66+
}
5567

68+
private static void WriteValue(JsonWriter writer, object value, string contextKey = null)
69+
{
5670
switch (value)
5771
{
5872
case string _:
59-
writer.WriteValue((string)value);
73+
writer.WriteValue(value);
6074
break;
6175
case long _:
62-
writer.WriteValue((long)value);
76+
writer.WriteValue(value);
6377
break;
6478
case int _:
65-
writer.WriteValue((int)value);
79+
writer.WriteValue(value);
6680
break;
6781
case bool _:
68-
writer.WriteValue((bool)value);
82+
writer.WriteValue(value);
6983
break;
7084
case double _:
71-
writer.WriteValue((double)value);
85+
writer.WriteValue(value);
7286
break;
7387
case float _:
74-
writer.WriteValue((float)value);
88+
writer.WriteValue(value);
7589
break;
7690
case decimal _:
77-
writer.WriteValue((decimal)value);
91+
writer.WriteValue(value);
7892
break;
7993
case char _:
80-
writer.WriteValue((char)value);
94+
writer.WriteValue(value);
8195
break;
8296
case ushort _:
83-
writer.WriteValue((ushort)value);
97+
writer.WriteValue(value);
8498
break;
8599
case uint _:
86-
writer.WriteValue((uint)value);
100+
writer.WriteValue(value);
87101
break;
88102
case ulong _:
89-
writer.WriteValue((ulong)value);
103+
writer.WriteValue(value);
90104
break;
91105
case short _:
92-
writer.WriteValue((short)value);
106+
writer.WriteValue(value);
93107
break;
94108
case sbyte _:
95-
writer.WriteValue((sbyte)value);
109+
writer.WriteValue(value);
96110
break;
97111
case byte _:
98-
writer.WriteValue((byte)value);
112+
writer.WriteValue(value);
113+
break;
114+
case IEnumerable enumerable:
115+
writer.WriteStartArray();
116+
foreach (var element in enumerable)
117+
{
118+
if (element != null)
119+
{
120+
WriteValue(writer, element);
121+
}
122+
}
123+
writer.WriteEndArray();
99124
break;
100125
default:
101126
try
@@ -107,8 +132,9 @@ private static void WriteJsonKeyAndValue(JsonWriter writer, string key, object v
107132
writer.WriteValue(value.ToString());
108133

109134
var type = value.GetType().FullName;
135+
var context = contextKey != null ? $"property {contextKey}" : "array element";
110136

111-
Log.Debug($"Unable to properly serialize property {key} of type {type}. The agent will use the value from calling ToString() on the object of {type} type.");
137+
Log.Debug($"Unable to properly serialize {context} of type {type}. The agent will use the value from calling ToString() on the object of {type} type.");
112138
}
113139
break;
114140
}

tests/Agent/IntegrationTests/Applications/AspNetCoreWebApiCustomAttributesApplication/Controllers/AttributeTestingController.cs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,43 @@ public string CustomAttributesValueNull()
5757
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("keywithnullvalue", null);
5858
return "success";
5959
}
60-
}
60+
61+
[HttpGet]
62+
[Route("api/CustomArrayAttributes")]
63+
public string CustomArrayAttributes()
64+
{
65+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("stringArray", new[] { "red", "green", "blue" });
66+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("intArray", new[] { 1, 2, 3, 4, 5 });
67+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("boolArray", new[] { true, false, true });
68+
69+
// Attempt to force this as the captured transaction trace.
70+
System.Threading.Thread.Sleep(1000);
71+
72+
return "success";
73+
}
74+
75+
[HttpGet]
76+
[Route("api/CustomEmptyArrayAttributes")]
77+
public string CustomEmptyArrayAttributes()
78+
{
79+
// Add a regular attribute to ensure transaction gets traced
80+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("test", "empty-arrays");
81+
82+
// These should be skipped by our array logic
83+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("emptyArray", new string[] { });
84+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("nullOnlyArray", new object[] { null, null });
85+
86+
return "success";
87+
}
88+
89+
[HttpGet]
90+
[Route("api/CustomArrayWithNulls")]
91+
public string CustomArrayWithNulls()
92+
{
93+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("arrayWithNulls", new object[] { "first", null, "third" });
94+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("listAttribute", new List<string> { "list1", "list2", "list3" });
95+
96+
return "success";
97+
}
98+
99+
}

tests/Agent/IntegrationTests/Applications/CustomAttributesWebApi/MyController.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,45 @@ public string CustomAttributesValueNull()
6161
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("keywithnullvalue", null);
6262
return "success";
6363
}
64+
65+
[HttpGet]
66+
[Route("api/CustomArrayAttributes")]
67+
public string CustomArrayAttributes()
68+
{
69+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("stringArray", new[] { "red", "green", "blue" });
70+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("intArray", new[] { 1, 2, 3, 4, 5 });
71+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("boolArray", new[] { true, false, true });
72+
73+
// Attempt to force this as the captured transaction trace.
74+
Thread.Sleep(1000);
75+
76+
return "success";
77+
}
78+
79+
[HttpGet]
80+
[Route("api/CustomEmptyArrayAttributes")]
81+
public string CustomEmptyArrayAttributes()
82+
{
83+
// Add a regular attribute to ensure transaction gets traced
84+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("test", "empty-arrays");
85+
86+
// These should be skipped by our array logic
87+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("emptyArray", new string[] { });
88+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("nullOnlyArray", new object[] { null, null });
89+
90+
return "success";
91+
}
92+
93+
[HttpGet]
94+
[Route("api/CustomArrayWithNulls")]
95+
public string CustomArrayWithNulls()
96+
{
97+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("arrayWithNulls", new object[] { "first", null, "third" });
98+
NewRelic.Api.Agent.NewRelic.GetAgent().CurrentTransaction.AddCustomAttribute("listAttribute", new List<string> { "list1", "list2", "list3" });
99+
100+
// Attempt to force this as the captured transaction trace.
101+
Thread.Sleep(1000);
102+
103+
return "success";
104+
}
64105
}

tests/Agent/IntegrationTests/IntegrationTestHelpers/Assertions.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,13 +1083,100 @@ private static bool ValidateAttributeValues(KeyValuePair<string, object> expecte
10831083

10841084
break;
10851085
}
1086+
case string[] expectedStringArray:
1087+
{
1088+
ValidateArrayAttribute(expectedAttribute, rawActualValue, expectedStringArray.Cast<object>().ToArray(), builder, wireModelTypeName, ref succeeded);
1089+
break;
1090+
}
1091+
case int[] expectedIntArray:
1092+
{
1093+
ValidateArrayAttribute(expectedAttribute, rawActualValue, expectedIntArray.Cast<object>().ToArray(), builder, wireModelTypeName, ref succeeded);
1094+
break;
1095+
}
1096+
case bool[] expectedBoolArray:
1097+
{
1098+
ValidateArrayAttribute(expectedAttribute, rawActualValue, expectedBoolArray.Cast<object>().ToArray(), builder, wireModelTypeName, ref succeeded);
1099+
break;
1100+
}
1101+
case object[] expectedArray:
1102+
{
1103+
ValidateArrayAttribute(expectedAttribute, rawActualValue, expectedArray, builder, wireModelTypeName, ref succeeded);
1104+
break;
1105+
}
10861106
default:
10871107
throw new NotImplementedException("Attribute handling for your type has not yet been implemented. The method only supports strings and bools. Update to add your type!");
10881108
}
10891109

10901110
return succeeded;
10911111
}
10921112

1113+
private static void ValidateArrayAttribute(KeyValuePair<string, object> expectedAttribute, object rawActualValue, object[] expectedArray, StringBuilder builder, string wireModelTypeName, ref bool succeeded)
1114+
{
1115+
// Handle different possible types of arrays from JSON deserialization
1116+
object[] actualArray = null;
1117+
1118+
if (rawActualValue is object[] directArray)
1119+
{
1120+
actualArray = directArray;
1121+
}
1122+
else if (rawActualValue is Newtonsoft.Json.Linq.JArray jArray)
1123+
{
1124+
actualArray = jArray.ToObject<object[]>();
1125+
}
1126+
else if (rawActualValue is System.Collections.IEnumerable enumerable)
1127+
{
1128+
actualArray = enumerable.Cast<object>().ToArray();
1129+
}
1130+
1131+
if (actualArray == null)
1132+
{
1133+
builder.AppendFormat("Attribute named {0} in the {3} had an unexpected type. Expected: array [{1}], Actual type: {2}",
1134+
expectedAttribute.Key, string.Join(", ", expectedArray), rawActualValue?.GetType()?.Name ?? "null", wireModelTypeName);
1135+
builder.AppendLine();
1136+
succeeded = false;
1137+
}
1138+
else if (actualArray.Length != expectedArray.Length)
1139+
{
1140+
builder.AppendFormat("Attribute named {0} in the {3} had an unexpected array length. Expected: {1}, Actual: {2}",
1141+
expectedAttribute.Key, expectedArray.Length, actualArray.Length, wireModelTypeName);
1142+
builder.AppendLine();
1143+
succeeded = false;
1144+
}
1145+
else
1146+
{
1147+
// Compare array elements
1148+
for (int i = 0; i < expectedArray.Length; i++)
1149+
{
1150+
var expectedElement = expectedArray[i];
1151+
var actualElement = actualArray[i];
1152+
1153+
// Handle different JSON number types (long vs int)
1154+
bool elementsEqual = false;
1155+
if (expectedElement is int expectedInt && actualElement is long actualLong)
1156+
{
1157+
elementsEqual = expectedInt == actualLong;
1158+
}
1159+
else if (expectedElement is long expectedLong && actualElement is int actualInt)
1160+
{
1161+
elementsEqual = expectedLong == actualInt;
1162+
}
1163+
else
1164+
{
1165+
elementsEqual = Equals(expectedElement, actualElement);
1166+
}
1167+
1168+
if (!elementsEqual)
1169+
{
1170+
builder.AppendFormat("Attribute named {0} in the {3} had an unexpected value at index {4}. Expected: {1} ({5}), Actual: {2} ({6})",
1171+
expectedAttribute.Key, expectedElement, actualElement, wireModelTypeName, i,
1172+
expectedElement?.GetType()?.Name ?? "null", actualElement?.GetType()?.Name ?? "null");
1173+
builder.AppendLine();
1174+
succeeded = false;
1175+
}
1176+
}
1177+
}
1178+
}
1179+
10931180
private static bool IsNullOrEqual(string expectedValue, string actualValue)
10941181
{
10951182
if (expectedValue == null)

0 commit comments

Comments
 (0)