From e61d72fc07dcff6d505ebd8475d4be0c6efc1c02 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 6 Jun 2024 16:31:12 +0200 Subject: [PATCH] Custom converter to support ServiceControl.Plugin.Nsb5.Heartbeat that sends arrays of those messages (#4211) * Custom converter to support ServiceControl.Plugin.Nsb5.Heartbeat that sends arrays of those messages Co-authored-by: danielmarbach Co-authored-by: Ramon Smits --- ...tbeatTypesArrayToInstanceConverterTests.cs | 172 ++++++++++++++++++ .../Infrastructure/NServiceBusFactory.cs | 6 + .../HeartbeatSerializationContext.cs | 11 ++ .../HeartbeatTypesArrayToInstanceConverter.cs | 108 +++++++++++ 4 files changed, 297 insertions(+) create mode 100644 src/ServiceControl.UnitTests/Monitoring/HeartbeatTypesArrayToInstanceConverterTests.cs create mode 100644 src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatSerializationContext.cs create mode 100644 src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatTypesArrayToInstanceConverter.cs diff --git a/src/ServiceControl.UnitTests/Monitoring/HeartbeatTypesArrayToInstanceConverterTests.cs b/src/ServiceControl.UnitTests/Monitoring/HeartbeatTypesArrayToInstanceConverterTests.cs new file mode 100644 index 0000000000..80a9d4a905 --- /dev/null +++ b/src/ServiceControl.UnitTests/Monitoring/HeartbeatTypesArrayToInstanceConverterTests.cs @@ -0,0 +1,172 @@ +namespace ServiceControl.UnitTests.Monitoring +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using HeartbeatMonitoring.InternalMessages; + using NUnit.Framework; + using Plugin.Heartbeat.Messages; + using ServiceControl.Monitoring.HeartbeatMonitoring; + + [TestFixture] + public class HeartbeatTypesArrayToInstanceConverterTests + { + JsonSerializerOptions options; + + [SetUp] + public void Setup() => + options = new JsonSerializerOptions + { + Converters = + { + new HeartbeatTypesArrayToInstanceConverter() + }, + TypeInfoResolverChain = + { + HeartbeatSerializationContext.Default + } + }; + + [Test] + public void Should_deserialize_heartbeat_arrays() + { + var heartbeat = JsonSerializer.Deserialize(""" + [ + { + "$type": "ServiceControl.Plugin.Heartbeat.Messages.EndpointHeartbeat, ServiceControl", + "ExecutedAt": "2024-06-02T12:03:41.780", + "EndpointName": "Test", + "HostId": "1865830e-71b0-dc6c-e146-62cdd0034e6e", + "Host": "Machine" + } + ] + """, options); + + Assert.IsNotNull(heartbeat); + Assert.AreEqual("Test", heartbeat.EndpointName); + Assert.AreEqual("Machine", heartbeat.Host); + Assert.AreEqual(new DateTime(2024, 6, 2, 12, 3, 41, 780, System.DateTimeKind.Utc), heartbeat.ExecutedAt); + Assert.AreEqual(new Guid("1865830e-71b0-dc6c-e146-62cdd0034e6e"), heartbeat.HostId); + } + + [Test] + public void Should_deserialize_single_heartbeat() + { + var heartbeat = JsonSerializer.Deserialize(""" + { + "$type": "ServiceControl.Plugin.Heartbeat.Messages.EndpointHeartbeat, ServiceControl", + "ExecutedAt": "2024-06-02T12:03:41.780", + "EndpointName": "Test", + "HostId": "1865830e-71b0-dc6c-e146-62cdd0034e6e", + "Host": "Machine" + } + """, options); + + Assert.IsNotNull(heartbeat); + Assert.AreEqual("Test", heartbeat.EndpointName); + Assert.AreEqual("Machine", heartbeat.Host); + Assert.AreEqual(new DateTime(2024, 6, 2, 12, 3, 41, 780, System.DateTimeKind.Utc), heartbeat.ExecutedAt); + Assert.AreEqual(new Guid("1865830e-71b0-dc6c-e146-62cdd0034e6e"), heartbeat.HostId); + } + + [Test] + public void Should_deserialize_register_endpoint_startup_arrays() + { + // sample json for RegisterEndpointStartup + var endpointStartup = JsonSerializer.Deserialize(""" + [ + { + "HostId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "Endpoint": "SampleEndpoint", + "StartedAt": "2022-12-01T12:00:00Z", + "HostProperties": { + "Property1": "Value1", + "Property2": "Value2" + }, + "HostDisplayName": "SampleHostDisplayName", + "Host": "SampleHost" + } + ] + """, options); + + Assert.IsNotNull(endpointStartup); + Assert.AreEqual(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"), endpointStartup.HostId); + Assert.AreEqual("SampleEndpoint", endpointStartup.Endpoint); + Assert.AreEqual(new DateTime(2022, 12, 1, 12, 0, 0, 0, System.DateTimeKind.Utc), endpointStartup.StartedAt); + Assert.AreEqual("SampleHostDisplayName", endpointStartup.HostDisplayName); + Assert.AreEqual("SampleHost", endpointStartup.Host); + CollectionAssert.AreEqual(new Dictionary + { + { "Property1", "Value1" }, + { "Property2", "Value2" } + }, endpointStartup.HostProperties); + } + + [Test] + public void Should_deserialize_single_register_endpoint_startup() + { + // sample json for RegisterEndpointStartup + var endpointStartup = JsonSerializer.Deserialize(""" + { + "HostId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "Endpoint": "SampleEndpoint", + "StartedAt": "2022-12-01T12:00:00Z", + "HostProperties": { + "Property1": "Value1", + "Property2": "Value2" + }, + "HostDisplayName": "SampleHostDisplayName", + "Host": "SampleHost" + } + """, options); + + Assert.IsNotNull(endpointStartup); + Assert.AreEqual(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"), endpointStartup.HostId); + Assert.AreEqual("SampleEndpoint", endpointStartup.Endpoint); + Assert.AreEqual(new DateTime(2022, 12, 1, 12, 0, 0, 0, System.DateTimeKind.Utc), endpointStartup.StartedAt); + Assert.AreEqual("SampleHostDisplayName", endpointStartup.HostDisplayName); + Assert.AreEqual("SampleHost", endpointStartup.Host); + CollectionAssert.AreEqual(new Dictionary + { + { "Property1", "Value1" }, + { "Property2", "Value2" } + }, endpointStartup.HostProperties); + } + + [Test] + public void Should_deserialize_register_potentially_missing_heartbeat_arrays() + { + var potentiallyMissingHeartbeats = JsonSerializer.Deserialize(""" + [ + { + "DetectedAt": "2024-06-02T12:03:41.780", + "LastHeartbeatAt": "2024-06-02T12:03:38.780", + "EndpointInstanceId": "1865830e-71b0-dc6c-e146-62cdd0034e6e" + } + ] + """, options); + + Assert.IsNotNull(potentiallyMissingHeartbeats); + Assert.AreEqual(new DateTime(2024, 6, 2, 12, 3, 41, 780, System.DateTimeKind.Utc), potentiallyMissingHeartbeats.DetectedAt); + Assert.AreEqual(new DateTime(2024, 6, 2, 12, 3, 38, 780, System.DateTimeKind.Utc), potentiallyMissingHeartbeats.LastHeartbeatAt); + Assert.AreEqual(new Guid("1865830e-71b0-dc6c-e146-62cdd0034e6e"), potentiallyMissingHeartbeats.EndpointInstanceId); + } + + [Test] + public void Should_deserialize_single_register_potentially_missing_heartbeat() + { + var potentiallyMissingHeartbeats = JsonSerializer.Deserialize(""" + { + "DetectedAt": "2024-06-02T12:03:41.780", + "LastHeartbeatAt": "2024-06-02T12:03:38.780", + "EndpointInstanceId": "1865830e-71b0-dc6c-e146-62cdd0034e6e" + } + """, options); + + Assert.IsNotNull(potentiallyMissingHeartbeats); + Assert.AreEqual(new DateTime(2024, 6, 2, 12, 3, 41, 780, System.DateTimeKind.Utc), potentiallyMissingHeartbeats.DetectedAt); + Assert.AreEqual(new DateTime(2024, 6, 2, 12, 3, 38, 780, System.DateTimeKind.Utc), potentiallyMissingHeartbeats.LastHeartbeatAt); + Assert.AreEqual(new Guid("1865830e-71b0-dc6c-e146-62cdd0034e6e"), potentiallyMissingHeartbeats.EndpointInstanceId); + } + } +} \ No newline at end of file diff --git a/src/ServiceControl/Infrastructure/NServiceBusFactory.cs b/src/ServiceControl/Infrastructure/NServiceBusFactory.cs index 42589c38d2..6179245690 100644 --- a/src/ServiceControl/Infrastructure/NServiceBusFactory.cs +++ b/src/ServiceControl/Infrastructure/NServiceBusFactory.cs @@ -8,6 +8,7 @@ namespace ServiceBus.Management.Infrastructure using ServiceControl.ExternalIntegrations; using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Subscriptions; + using ServiceControl.Monitoring.HeartbeatMonitoring; using ServiceControl.Notifications.Email; using ServiceControl.Operations; using ServiceControl.SagaAudit; @@ -50,9 +51,14 @@ public static void Configure(Settings.Settings settings, ITransportCustomization var serializer = configuration.UseSerialization(); serializer.Options(new JsonSerializerOptions { + Converters = + { + new HeartbeatTypesArrayToInstanceConverter() + }, TypeInfoResolverChain = { SagaAuditMessagesSerializationContext.Default, + HeartbeatSerializationContext.Default, // This is required until we move all known message types over to source generated contexts new DefaultJsonTypeInfoResolver() } diff --git a/src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatSerializationContext.cs b/src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatSerializationContext.cs new file mode 100644 index 0000000000..46d5da9a84 --- /dev/null +++ b/src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatSerializationContext.cs @@ -0,0 +1,11 @@ +namespace ServiceControl.Monitoring.HeartbeatMonitoring +{ + using System.Text.Json.Serialization; + using Plugin.Heartbeat.Messages; + using ServiceControl.HeartbeatMonitoring.InternalMessages; + + [JsonSerializable(typeof(EndpointHeartbeat))] + [JsonSerializable(typeof(RegisterEndpointStartup))] + [JsonSerializable(typeof(RegisterPotentiallyMissingHeartbeats))] + partial class HeartbeatSerializationContext : JsonSerializerContext; +} \ No newline at end of file diff --git a/src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatTypesArrayToInstanceConverter.cs b/src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatTypesArrayToInstanceConverter.cs new file mode 100644 index 0000000000..018d440f47 --- /dev/null +++ b/src/ServiceControl/Monitoring/HeartbeatMonitoring/HeartbeatTypesArrayToInstanceConverter.cs @@ -0,0 +1,108 @@ +#nullable enable + +namespace ServiceControl.Monitoring.HeartbeatMonitoring +{ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + using Plugin.Heartbeat.Messages; + using ServiceControl.HeartbeatMonitoring.InternalMessages; + + // ServiceControl.Plugin.Nsb5.Heartbeat used to send RegisterEndpointStartup and EndpointHeartbeat messages wrapped in an array. + // In order to stay backward compatible, we need to convert the array to an instance. + public class HeartbeatTypesArrayToInstanceConverter : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(RegisterEndpointStartup) || + typeToConvert == typeof(EndpointHeartbeat) || + typeToConvert == typeof(RegisterPotentiallyMissingHeartbeats); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + typeToConvert switch + { + _ when typeToConvert == typeof(RegisterEndpointStartup) => new Converter(options), + _ when typeToConvert == typeof(EndpointHeartbeat) => new Converter(options), + _ when typeToConvert == typeof(RegisterPotentiallyMissingHeartbeats) => new Converter(options), + _ => throw new NotSupportedException() + }; + + sealed class Converter(JsonSerializerOptions options) : JsonConverter + { + // Currently we want to rely on deserializing the actual value by using the standard json serializer + // To make sure we are not recursively invoking the converter we need to remove it from the options + // Removing from the options is currently only possible when the converter is added to the options directly + // and not supported when the converter is added to the type + readonly JsonSerializerOptions optionsWithoutCustomConverter = options.FromWithout(); + + public override TValue? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var readArray = false; + if (reader.TokenType == JsonTokenType.StartArray) + { + readArray = true; + reader.Read(); + + if (reader.TokenType != JsonTokenType.StartObject) + { + // No need to enrich message as this is autogenerated: + // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/converters-how-to#jsonexception + throw new JsonException(); + } + } + + var value = JsonSerializer.Deserialize(ref reader, optionsWithoutCustomConverter); + + if (!readArray) + { + return value; + } + + if (reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException(); + } + + _ = reader.Read(); + + if (reader.TokenType != JsonTokenType.EndArray) + { + throw new JsonException(); + } + + _ = reader.Read(); // Converter need to consume all data + + return value; + } + + // we only ever use it to read + public override void Write(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) => + throw new NotSupportedException(); + } + } + + static class JsonSerializerOptionsExtensions + { + public static JsonSerializerOptions FromWithout(this JsonSerializerOptions options) + where TConverter : JsonConverter + { + var newOptions = new JsonSerializerOptions(options); + JsonConverter? converterToRemove = null; + foreach (var converter in newOptions.Converters) + { + if (converter is not TConverter) + { + continue; + } + + converterToRemove = converter; + break; + } + + if (converterToRemove != null) + { + newOptions.Converters.Remove(converterToRemove); + } + + return newOptions; + } + } +} \ No newline at end of file