diff --git a/src/Particular.LicensingComponent.Contracts/AuditEnvironmentMetadata.cs b/src/Particular.LicensingComponent.Contracts/AuditEnvironmentMetadata.cs
new file mode 100644
index 0000000000..b0db55fe43
--- /dev/null
+++ b/src/Particular.LicensingComponent.Contracts/AuditEnvironmentMetadata.cs
@@ -0,0 +1,16 @@
+namespace Particular.LicensingComponent.Contracts;
+
+///
+/// The environment data each audit remote served when last polled, one dictionary per remote that
+/// responded. Never saved by versions that predate it, so a null read means unknown and the report
+/// omits the keys.
+///
+public record AuditEnvironmentMetadata(List> Instances)
+{
+ ///
+ /// The key under which the primary stores which database an instance's data came from, so that
+ /// instances sharing a database are counted once when sizes and counts are summed. Underscore
+ /// prefixed keys are collection bookkeeping and never reach the report.
+ ///
+ public const string DatabaseKey = "_DatabaseKey";
+}
diff --git a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs
index 257eef677a..611611fdee 100644
--- a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs
+++ b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs
@@ -14,6 +14,7 @@ public class InMemoryLicensingDataStore : ILicensingDataStore
readonly Dictionary allThroughput = [];
BrokerMetadata brokerMetadata = new(null, []);
AuditServiceMetadata auditServiceMetadata = new([], []);
+ AuditEnvironmentMetadata? auditEnvironmentMetadata;
List reportMasks = [];
LicensedEndpointDetails? endpointDetails = null;
@@ -167,6 +168,14 @@ public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata,
return Task.CompletedTask;
}
+ public Task GetAuditEnvironmentMetadata(CancellationToken cancellationToken = default) => Task.FromResult(auditEnvironmentMetadata);
+
+ public Task SaveAuditEnvironmentMetadata(AuditEnvironmentMetadata auditEnvironmentMetadata, CancellationToken cancellationToken = default)
+ {
+ this.auditEnvironmentMetadata = auditEnvironmentMetadata;
+ return Task.CompletedTask;
+ }
+
public Task> GetReportMasks(CancellationToken cancellationToken = default) => Task.FromResult(reportMasks);
public Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken = default)
diff --git a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs
index 9dc65e1caf..bbf997ecf5 100644
--- a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs
+++ b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs
@@ -36,6 +36,10 @@ Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSo
Task GetAuditServiceMetadata(CancellationToken cancellationToken = default);
Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken = default);
+
+ Task GetAuditEnvironmentMetadata(CancellationToken cancellationToken = default);
+
+ Task SaveAuditEnvironmentMetadata(AuditEnvironmentMetadata auditEnvironmentMetadata, CancellationToken cancellationToken = default);
Task> GetReportMasks(CancellationToken cancellationToken = default);
Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken = default);
diff --git a/src/Particular.LicensingComponent.UnitTests/AuditQuery_Tests.cs b/src/Particular.LicensingComponent.UnitTests/AuditQuery_Tests.cs
index 082fe266ee..b64a0c12c8 100644
--- a/src/Particular.LicensingComponent.UnitTests/AuditQuery_Tests.cs
+++ b/src/Particular.LicensingComponent.UnitTests/AuditQuery_Tests.cs
@@ -192,6 +192,8 @@ public Task GetRemoteConfigs(CancellationToken cancellati
}
public Task GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ public Task GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
}
class ConfigurationApi_ReturningRemotes(params RemoteConfiguration[] remotes) : IConfigurationApi
@@ -201,6 +203,8 @@ class ConfigurationApi_ReturningRemotes(params RemoteConfiguration[] remotes) :
public Task GetRemoteConfigs(CancellationToken cancellationToken = default) => Task.FromResult(remotes);
public Task GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ public Task GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
}
class ConfigurationApi_Configurable : IConfigurationApi
@@ -221,6 +225,8 @@ public Task GetRemoteConfigs(CancellationToken cancellati
public Task GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+ public Task GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
public bool ReturnAuditConfig { get; set; }
public string RemoteStatus { get; set; }
public string RemoteVersion { get; set; }
diff --git a/src/Particular.LicensingComponent.UnitTests/AuditThroughputCollectorHostedService_Tests.cs b/src/Particular.LicensingComponent.UnitTests/AuditThroughputCollectorHostedService_Tests.cs
index a77b84783a..3174f5bea7 100644
--- a/src/Particular.LicensingComponent.UnitTests/AuditThroughputCollectorHostedService_Tests.cs
+++ b/src/Particular.LicensingComponent.UnitTests/AuditThroughputCollectorHostedService_Tests.cs
@@ -272,10 +272,40 @@ public async Task Should_store_how_many_audit_instances_are_configured_and_how_m
}
}
+ [Test]
+ public async Task Should_store_the_environment_data_of_every_responding_audit_instance()
+ {
+ var auditQuery = new AuditQuery_WithRemotes([new RemoteInstanceInformation { Status = "online" }])
+ {
+ Environments =
+ [
+ new Dictionary { ["Storage.Type"] = "RavenDB", ["Host.ProcessorCount"] = "4" },
+ new Dictionary { ["Storage.Type"] = "RavenDB", ["Host.ProcessorCount"] = "8" }
+ ]
+ };
+
+ using var auditThroughputCollectorHostedService = new AuditThroughputCollectorHostedService(
+ NullLogger.Instance, configuration.ThroughputSettings, DataStore,
+ auditQuery, new FakeTimeProvider())
+ { DelayStart = TimeSpan.Zero };
+
+ await auditThroughputCollectorHostedService.StartAsync(CancellationToken.None);
+ await auditQuery.KnownEndpointsRequested.WaitAsync(TimeSpan.FromSeconds(30));
+ await auditThroughputCollectorHostedService.StopAsync(CancellationToken.None);
+
+ var auditEnvironmentMetadata = await DataStore.GetAuditEnvironmentMetadata();
+
+ Assert.That(auditEnvironmentMetadata, Is.Not.Null);
+ Assert.That(auditEnvironmentMetadata.Instances, Is.EqualTo(auditQuery.Environments));
+ }
+
class AuditQuery_NoAuditRemotes : IAuditQuery
{
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
+ public Task>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
+ Task.FromResult>>([]);
+
public Func ValidRemoteInstances => r => true;
public Task> GetAuditCountForEndpoint(string endpointUrlName,
@@ -309,6 +339,9 @@ public AuditQuery_WithOneEndpoint(string endpointName, long throughputCount, Dat
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
+ public Task>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
+ Task.FromResult>>([]);
+
public Func ValidRemoteInstances => r => true;
public Task> GetAuditCountForEndpoint(string endpointUrlName,
@@ -341,6 +374,9 @@ class AuditQuery_ThrowingAnExceptionOnKnownEndpointsCall : IAuditQuery
{
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
+ public Task>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
+ Task.FromResult>>([]);
+
public Func ValidRemoteInstances => r => true;
public Task> GetAuditCountForEndpoint(string endpointUrlName,
@@ -420,6 +456,10 @@ public AuditQuery_WithTwoEndpointsAndSelectiveCounts(
}
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
+
+ public Task>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
+ Task.FromResult>>([]);
+
public Func ValidRemoteInstances => _ => true;
public Task> GetKnownEndpoints(CancellationToken cancellationToken = default) =>
@@ -456,6 +496,12 @@ class AuditQuery_WithRemotes(List remotes) : IAuditQu
public Task KnownEndpointsRequested => knownEndpointsRequested.Task;
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
+
+ public List> Environments { get; init; } = [];
+
+ public Task>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
+ Task.FromResult(Environments);
+
public Func ValidRemoteInstances => _ => true;
public Task> GetAuditRemotes(CancellationToken cancellationToken = default) =>
diff --git a/src/Particular.LicensingComponent.UnitTests/Infrastructure/FakeConfigurationApi.cs b/src/Particular.LicensingComponent.UnitTests/Infrastructure/FakeConfigurationApi.cs
index d7d0893be2..0e18e277ae 100644
--- a/src/Particular.LicensingComponent.UnitTests/Infrastructure/FakeConfigurationApi.cs
+++ b/src/Particular.LicensingComponent.UnitTests/Infrastructure/FakeConfigurationApi.cs
@@ -10,6 +10,7 @@ class FakeConfigurationApi : IConfigurationApi
{
public Task