Skip to content
Open
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
32 changes: 32 additions & 0 deletions src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Particular.LicensingComponent.Contracts;

/// <summary>
/// One key in a usage report's environment data, together with how to read its value.
/// </summary>
/// <remarks>
/// The value is deferred rather than supplied so that reading it can be isolated. A datum that
/// cannot be read costs only its own key, never a sibling's, and providers therefore carry no
/// error handling of their own.
/// </remarks>
public sealed record EnvironmentDatum(string Key, Func<CancellationToken, ValueTask<string>> ReadValue)
{
/// <summary>
/// Reported in place of a value whose read threw. Deliberately not a word that could pass for a
/// state the instance is legitimately in: it always means the read failed, never that the thing
/// being described is absent or switched off.
/// </summary>
public const string ReadFailed = "ReadFailed";

/// <summary>
/// A value that is already at hand, such as one read from configuration. Still deferred, so
/// that nothing is evaluated while a provider is listing what it offers.
/// </summary>
public static EnvironmentDatum Value(string key, Func<string> readValue) =>
new(key, _ => new ValueTask<string>(readValue()));

/// <summary>
/// A value that has to be fetched, such as one read from storage or from the database itself.
/// </summary>
public static EnvironmentDatum Deferred(string key, Func<CancellationToken, ValueTask<string>> readValue) =>
new(key, readValue);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
/// </summary>
public interface IEnvironmentDataProvider
{
IEnumerable<(string key, string value)> GetData();
IEnumerable<EnvironmentDatum> GetData();
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
namespace Particular.LicensingComponent.UnitTests;

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
Expand Down Expand Up @@ -35,9 +34,7 @@ public async Task Should_include_additional_environment_data_in_throughput_repor

class TestAdditionalEnvironmentDataProvider : IEnvironmentDataProvider
{
public IEnumerable<(string key, string value)> GetData()
{
yield return ("TestKey", "TestValue");
}
public IEnumerable<EnvironmentDatum> GetData() =>
[EnvironmentDatum.Value("TestKey", () => "TestValue")];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
namespace Particular.LicensingComponent.UnitTests;

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Particular.LicensingComponent.Contracts;
using Particular.LicensingComponent.UnitTests.Infrastructure;

[TestFixture]
class ThroughputCollector_EnvironmentDataFailure_Tests : ThroughputCollectorTestFixture
{
public override Task Setup()
{
SetExtraDependencies = services =>
{
services.AddSingleton<IEnvironmentDataProvider, ProviderWithOneUnreadableDatum>();
services.AddSingleton<IEnvironmentDataProvider, ProviderThatCannotListItsData>();
};

return base.Setup();
}

[Test]
public async Task Should_keep_the_siblings_of_a_datum_that_cannot_be_read()
{
var report = await ThroughputCollector.GenerateThroughputReport(null, null);

var environmentData = report.ReportData.EnvironmentInformation.EnvironmentData;

Assert.Multiple(() =>
{
Assert.That(environmentData["Readable.Before"], Is.EqualTo("value"),
"A datum listed before the failing one has already been read");
Assert.That(environmentData["Readable.After"], Is.EqualTo("value"),
"A datum listed after the failing one must still be read, unlike an iterator that has faulted");
Assert.That(environmentData["Unreadable"], Is.EqualTo("ReadFailed"),
"The failure is recorded rather than leaving the key absent");
});
}

[Test]
public async Task Should_still_report_when_a_provider_cannot_list_its_data()
{
var report = await ThroughputCollector.GenerateThroughputReport(null, null);

Assert.That(report.ReportData.EnvironmentInformation.EnvironmentData, Does.ContainKey("Readable.Before"),
"One broken provider must not cost another provider its data");
}

class ProviderWithOneUnreadableDatum : IEnvironmentDataProvider
{
public IEnumerable<EnvironmentDatum> GetData() =>
[
EnvironmentDatum.Value("Readable.Before", () => "value"),
EnvironmentDatum.Deferred("Unreadable", _ => throw new InvalidOperationException("the storage read failed")),
EnvironmentDatum.Value("Readable.After", () => "value")
];
}

class ProviderThatCannotListItsData : IEnvironmentDataProvider
{
public IEnumerable<EnvironmentDatum> GetData() => throw new InvalidOperationException("the provider is broken");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Particular.LicensingComponent.Contracts;
using Particular.LicensingComponent.UnitTests.Infrastructure;
Expand Down Expand Up @@ -35,7 +36,7 @@ await DataStore.CreateBuilder()
.WithThroughput(data: [60])
.Build();

var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse());
var throughputCollector = new ThroughputCollector(NullLogger<ThroughputCollector>.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse());

// Act
var summary = await throughputCollector.GetThroughputSummary();
Expand All @@ -61,7 +62,7 @@ await DataStore.CreateBuilder()
.WithThroughput(data: [60])
.Build();

var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse());
var throughputCollector = new ThroughputCollector(NullLogger<ThroughputCollector>.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse());

// Act
var report = await throughputCollector.GenerateThroughputReport(null, null);
Expand All @@ -88,7 +89,7 @@ await DataStore.CreateBuilder()
.WithThroughput(data: [60])
.Build();

var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse());
var throughputCollector = new ThroughputCollector(NullLogger<ThroughputCollector>.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse());

// Act
var summary = await throughputCollector.GetThroughputSummary();
Expand All @@ -114,7 +115,7 @@ await DataStore.CreateBuilder()
.WithThroughput(data: [60])
.Build();

var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse());
var throughputCollector = new ThroughputCollector(NullLogger<ThroughputCollector>.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse());

// Act
var report = await throughputCollector.GenerateThroughputReport(null, null);
Expand Down
31 changes: 28 additions & 3 deletions src/Particular.LicensingComponent/ThroughputCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading;
using AuditThroughput;
using Contracts;
using Microsoft.Extensions.Logging;
using MonitoringThroughput;
using Particular.LicensingComponent.Report.Utility;
using Persistence;
Expand All @@ -13,7 +14,7 @@
using Shared;
using QueueThroughput = Report.QueueThroughput;

public class ThroughputCollector(ILicensingDataStore dataStore, ThroughputSettings throughputSettings, IAuditQuery auditQuery, MonitoringService monitoringService, IEnumerable<IEnvironmentDataProvider> environmentDataProviders, IBrokerThroughputQuery? throughputQuery = null)
public class ThroughputCollector(ILogger<ThroughputCollector> logger, ILicensingDataStore dataStore, ThroughputSettings throughputSettings, IAuditQuery auditQuery, MonitoringService monitoringService, IEnumerable<IEnvironmentDataProvider> environmentDataProviders, IBrokerThroughputQuery? throughputQuery = null)
: IThroughputCollector
{
public async Task<ThroughputConnectionSettings> GetThroughputConnectionSettingsInformation(CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -188,9 +189,33 @@ public async Task<SignedReport> GenerateThroughputReport(string spVersion, DateT

foreach (var environmentDataProvider in environmentDataProviders)
{
foreach (var (key, value) in environmentDataProvider.GetData())
EnvironmentDatum[] environmentData;

try
{
environmentData = [.. environmentDataProvider.GetData()];
}
catch (Exception e)
{
logger.LogWarning(e, "Environment data provider {EnvironmentDataProvider} could not list what it offers, so none of its data is in the report", environmentDataProvider.GetType().Name);
continue;
}

foreach (var datum in environmentData)
{
report.EnvironmentInformation.EnvironmentData[key] = value;
try
{
report.EnvironmentInformation.EnvironmentData[datum.Key] = await datum.ReadValue(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception e)
{
logger.LogWarning(e, "Environment datum {EnvironmentDatum} could not be read", datum.Key);
report.EnvironmentInformation.EnvironmentData[datum.Key] = EnvironmentDatum.ReadFailed;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
namespace ServiceControl.AcceptanceTests.Licensing
{
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.EndpointTemplates;
using NServiceBus;
using NServiceBus.AcceptanceTesting;
using NServiceBus.Routing;
using NServiceBus.Transport;
using NUnit.Framework;
using Particular.LicensingComponent.Contracts;
using Particular.LicensingComponent.MonitoringThroughput;
using Particular.LicensingComponent.Shared;

class When_reporting_the_environment : AcceptanceTest
{
[Test]
public async Task Should_describe_how_the_instance_is_deployed()
{
JsonDocument report = null;

await Define<Context>()
.WithEndpoint<MonitoringInstance>()
.Do("Wait for the throughput data to be recorded", async _ =>
{
var available = await this.TryGet<ReportGenerationState>(
"/api/licensing/report/available", state => state.ReportCanBeGenerated);

return available.HasResult;
})
.Do("Download the report", async _ =>
{
var archive = await this.DownloadData("/api/licensing/report/file?spVersion=1.2.3");

report = ReadReport(archive);

return true;
})
.Done(_ => true)
.Run();

var data = report.RootElement
.GetProperty("ReportData")
.GetProperty("EnvironmentInformation")
.GetProperty("EnvironmentData")
.EnumerateObject()
.ToDictionary(entry => entry.Name, entry => entry.Value.GetString());

using (Assert.EnterMultipleScope())
{
Assert.That(data.Keys, Is.SupersetOf(ExpectedKeys));

Assert.That(data["Host.Model"], Is.AnyOf("Container", "WindowsService", "Console"));
Assert.That(data["Persistence.Type"], Is.Not.Empty);
Assert.That(data["Persistence.BodyStorage.Type"], Is.Not.Empty);
Assert.That(data["Persistence.BodyStorage.Auth"], Is.AnyOf("ManagedIdentity", "SharedKeyOrSas", "IamRole", "StaticCredentials", "NotApplicable"));
Assert.That(data["Persistence.HostingSource"], Is.AnyOf("Probe", "Configuration", "ConnectionString", "None"));
Assert.That(data["Security.Authentication"], Is.AnyOf("Enabled", "Disabled"));
Assert.That(data["Features.EmailNotifications"], Is.AnyOf("Enabled", "Disabled", "NotConfigured", "ReadFailed"));
Assert.That(int.Parse(data["Retention.ErrorHours"]), Is.GreaterThan(0));

Assert.That(data.Values, Has.None.Contains(Environment.MachineName),
"The report must not carry anything that identifies the customer's machine");
}
}

static readonly string[] ExpectedKeys =
[
"Host.Model",
"Host.Orchestrator",
"Host.OSPlatform",
"Host.OSVersion",
"Host.Architecture",
"Host.RuntimeVersion",
"Host.ProcessorCount",
"Host.AvailableMemoryGB",
"Persistence.Type",
"Persistence.Hosting",
"Persistence.ServerVersion",
"Persistence.HostingSource",
"Persistence.FullTextSearch",
"Persistence.BodyStorage.Type",
"Persistence.BodyStorage.Auth",
"Security.Authentication",
"Security.RoleBasedAuthorization",
"Security.Https",
"Features.IntegratedServicePulse",
"Features.MessageEditing",
"Features.ExternalIntegrationsPublishing",
"Features.ForwardErrorMessages",
"Features.EmailNotifications",
"Retention.ErrorHours",
"Retention.EventsHours"
];

static JsonDocument ReadReport(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
using var entry = zip.Entries.Single().Open();

return JsonDocument.Parse(entry);
}

const string SalesEndpoint = "Particular.Sales";

class Context : ScenarioContext, ISequenceContext
{
public int Step { get; set; }
}

class MonitoringInstance : EndpointConfigurationBuilder
{
public MonitoringInstance() =>
EndpointSetup<DefaultServerWithoutAudit>(c => c.EnableFeature<ReportThroughput>());

class ReportThroughput : DispatchRawMessages<Context>
{
protected override TransportOperations CreateMessage(Context context)
{
var recorded = new RecordEndpointThroughputData
{
StartDateTime = DateTime.UtcNow.AddDays(-1).AddHours(-1),
EndDateTime = DateTime.UtcNow.AddDays(-1),
EndpointThroughputData = [new EndpointThroughputData { Name = SalesEndpoint, Throughput = 42 }]
};

var body = JsonSerializer.SerializeToUtf8Bytes(recorded);
var message = new OutgoingMessage(Guid.NewGuid().ToString(), [], body);

return new TransportOperations(
new TransportOperation(message, new UnicastAddressTag(ServiceControlSettings.ServiceControlThroughputDataQueue)));
}
}
}
}
}
Loading
Loading