Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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<EndpointHeartbeat>("""
[
{
"$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<EndpointHeartbeat>("""
{
"$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<RegisterEndpointStartup>("""
[
{
"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<string, string>
{
{ "Property1", "Value1" },
{ "Property2", "Value2" }
}, endpointStartup.HostProperties);
}

[Test]
public void Should_deserialize_single_register_endpoint_startup()
{
// sample json for RegisterEndpointStartup
var endpointStartup = JsonSerializer.Deserialize<RegisterEndpointStartup>("""
{
"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<string, string>
{
{ "Property1", "Value1" },
{ "Property2", "Value2" }
}, endpointStartup.HostProperties);
}

[Test]
public void Should_deserialize_register_potentially_missing_heartbeat_arrays()
{
var potentiallyMissingHeartbeats = JsonSerializer.Deserialize<RegisterPotentiallyMissingHeartbeats>("""
[
{
"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<RegisterPotentiallyMissingHeartbeats>("""
{
"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);
}
}
}
6 changes: 6 additions & 0 deletions src/ServiceControl/Infrastructure/NServiceBusFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,9 +51,14 @@ public static void Configure(Settings.Settings settings, ITransportCustomization
var serializer = configuration.UseSerialization<SystemJsonSerializer>();
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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<RegisterEndpointStartup>(options),
_ when typeToConvert == typeof(EndpointHeartbeat) => new Converter<EndpointHeartbeat>(options),
_ when typeToConvert == typeof(RegisterPotentiallyMissingHeartbeats) => new Converter<RegisterPotentiallyMissingHeartbeats>(options),
_ => throw new NotSupportedException()
};

sealed class Converter<TValue>(JsonSerializerOptions options) : JsonConverter<TValue>
{
// 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<HeartbeatTypesArrayToInstanceConverter>();

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<TValue>(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<TConverter>(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;
}
}
}