diff --git a/src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs b/src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs
new file mode 100644
index 0000000000..ce1f5ac6ce
--- /dev/null
+++ b/src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs
@@ -0,0 +1,32 @@
+namespace Particular.LicensingComponent.Contracts;
+
+///
+/// One key in a usage report's environment data, together with how to read its value.
+///
+///
+/// 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.
+///
+public sealed record EnvironmentDatum(string Key, Func> ReadValue)
+{
+ ///
+ /// 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.
+ ///
+ public const string ReadFailed = "ReadFailed";
+
+ ///
+ /// 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.
+ ///
+ public static EnvironmentDatum Value(string key, Func readValue) =>
+ new(key, _ => new ValueTask(readValue()));
+
+ ///
+ /// A value that has to be fetched, such as one read from storage or from the database itself.
+ ///
+ public static EnvironmentDatum Deferred(string key, Func> readValue) =>
+ new(key, readValue);
+}
diff --git a/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs b/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs
index e7bb79f9c7..1c4e760711 100644
--- a/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs
+++ b/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs
@@ -5,5 +5,5 @@
///
public interface IEnvironmentDataProvider
{
- IEnumerable<(string key, string value)> GetData();
+ IEnumerable GetData();
}
diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs
index 732a6f8122..9b19884b99 100644
--- a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs
+++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs
@@ -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;
@@ -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 GetData() =>
+ [EnvironmentDatum.Value("TestKey", () => "TestValue")];
}
}
diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs
new file mode 100644
index 0000000000..a8325ac7df
--- /dev/null
+++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs
@@ -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();
+ services.AddSingleton();
+ };
+
+ 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 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 GetData() => throw new InvalidOperationException("the provider is broken");
+ }
+}
diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs
index 24cc1783b0..aa92362f64 100644
--- a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs
+++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs
@@ -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;
@@ -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.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse());
// Act
var summary = await throughputCollector.GetThroughputSummary();
@@ -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.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse());
// Act
var report = await throughputCollector.GenerateThroughputReport(null, null);
@@ -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.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse());
// Act
var summary = await throughputCollector.GetThroughputSummary();
@@ -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.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse());
// Act
var report = await throughputCollector.GenerateThroughputReport(null, null);
diff --git a/src/Particular.LicensingComponent/ThroughputCollector.cs b/src/Particular.LicensingComponent/ThroughputCollector.cs
index 6d9567d8ee..f36de2ffbb 100644
--- a/src/Particular.LicensingComponent/ThroughputCollector.cs
+++ b/src/Particular.LicensingComponent/ThroughputCollector.cs
@@ -5,6 +5,7 @@
using System.Threading;
using AuditThroughput;
using Contracts;
+using Microsoft.Extensions.Logging;
using MonitoringThroughput;
using Particular.LicensingComponent.Report.Utility;
using Persistence;
@@ -13,7 +14,7 @@
using Shared;
using QueueThroughput = Report.QueueThroughput;
-public class ThroughputCollector(ILicensingDataStore dataStore, ThroughputSettings throughputSettings, IAuditQuery auditQuery, MonitoringService monitoringService, IEnumerable environmentDataProviders, IBrokerThroughputQuery? throughputQuery = null)
+public class ThroughputCollector(ILogger logger, ILicensingDataStore dataStore, ThroughputSettings throughputSettings, IAuditQuery auditQuery, MonitoringService monitoringService, IEnumerable environmentDataProviders, IBrokerThroughputQuery? throughputQuery = null)
: IThroughputCollector
{
public async Task GetThroughputConnectionSettingsInformation(CancellationToken cancellationToken = default)
@@ -188,9 +189,33 @@ public async Task 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;
+ }
}
}
diff --git a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs
new file mode 100644
index 0000000000..4d8d75b109
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs
@@ -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()
+ .WithEndpoint()
+ .Do("Wait for the throughput data to be recorded", async _ =>
+ {
+ var available = await this.TryGet(
+ "/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(c => c.EnableFeature());
+
+ class ReportThroughput : DispatchRawMessages
+ {
+ 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)));
+ }
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs
new file mode 100644
index 0000000000..e46dd85cae
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs
@@ -0,0 +1,118 @@
+namespace ServiceControl.Persistence.EFCore.PostgreSql;
+
+using System.Data.Common;
+using System.Globalization;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using ServiceControl.Persistence;
+using ServiceControl.Persistence.EFCore.DbContexts;
+using ServiceControl.Persistence.EFCore.Infrastructure;
+
+class PostgreSqlDatabaseHostingProbe(PostgreSqlPersisterSettings settings, IServiceScopeFactory scopeFactory, ILogger logger) : IDatabaseHostingProbe
+{
+ public string StorageName => "PostgreSQL";
+
+ public async Task Probe(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ await using var command = dbContext.Database.GetDbConnection().CreateCommand();
+ command.CommandText = ProbeSql;
+ command.CommandTimeout = ProbeTimeoutSeconds;
+
+ await dbContext.Database.OpenConnectionAsync(cancellationToken);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return HostingFromConnectionString();
+ }
+
+ var hosting = HostingFor(reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), ConfiguredHost);
+
+ return new DatabaseHosting(hosting, MajorVersion(reader), DatabaseHostingSource.Probe);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ logger.LogDebug(e, "Could not ask PostgreSQL how it is hosted, falling back to the connection string");
+
+ return HostingFromConnectionString();
+ }
+ }
+
+ ///
+ /// PostgreSQL has no equivalent of SQL Server's EngineEdition, but every managed offering
+ /// creates a distinctive administrative role that a self-hosted server does not have. A server
+ /// that answered and has none of them is self-hosted, unless its host name names a managed
+ /// service that does not announce itself this way.
+ ///
+ internal static string HostingFor(bool azure, bool rds, bool cloudSql, string? host)
+ {
+ if (azure)
+ {
+ return "AzurePostgres";
+ }
+
+ if (rds)
+ {
+ return "AwsRds";
+ }
+
+ if (cloudSql)
+ {
+ return "GoogleCloudSql";
+ }
+
+ var classified = DatabaseHostClassifier.Classify(host);
+
+ return classified == DatabaseHostClassifier.Unknown ? DatabaseHostClassifier.SelfHosted : classified;
+ }
+
+ static string MajorVersion(DbDataReader reader) =>
+ reader.IsDBNull(0) ? DatabaseHostClassifier.Unknown : (reader.GetInt32(0) / 10000).ToString(CultureInfo.InvariantCulture);
+
+ DatabaseHosting HostingFromConnectionString()
+ {
+ var host = ConfiguredHost;
+
+ return host is null
+ ? DatabaseHosting.Unclassified
+ : new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown, DatabaseHostingSource.ConnectionString);
+ }
+
+ string? ConfiguredHost
+ {
+ get
+ {
+ try
+ {
+ return new NpgsqlConnectionStringBuilder(settings.ConnectionString).Host;
+ }
+ catch (Exception e)
+ {
+ logger.LogDebug(e, "Could not read the configured PostgreSQL host");
+
+ return null;
+ }
+ }
+ }
+
+ const string ProbeSql = """
+ SELECT current_setting('server_version_num')::int,
+ EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'azure_pg_admin'),
+ EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rds_superuser'),
+ EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cloudsqlsuperuser')
+ """;
+
+ const int ProbeTimeoutSeconds = 5;
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs
index f5d8c2ac6f..803151fce8 100644
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs
@@ -17,6 +17,7 @@ public void AddPersistence(IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
}
public void AddInstaller(IServiceCollection services)
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs
new file mode 100644
index 0000000000..7b686d72c7
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs
@@ -0,0 +1,133 @@
+namespace ServiceControl.Persistence.EFCore.SqlServer;
+
+using System.Data.Common;
+using System.Globalization;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Persistence;
+using ServiceControl.Persistence.EFCore.DbContexts;
+using ServiceControl.Persistence.EFCore.Infrastructure;
+
+class SqlServerDatabaseHostingProbe(SqlServerPersisterSettings settings, IServiceScopeFactory scopeFactory, ILogger logger) : IDatabaseHostingProbe
+{
+ public string StorageName => "SQLServer";
+
+ public async Task Probe(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ await using var command = dbContext.Database.GetDbConnection().CreateCommand();
+ // ProductVersion rather than ProductMajorVersion: the latter is documented as SQL Server
+ // only and comes back null on Azure SQL Database, Managed Instance and Synapse.
+ command.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion')";
+ command.CommandTimeout = ProbeTimeoutSeconds;
+
+ await dbContext.Database.OpenConnectionAsync(cancellationToken);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+
+ if (!await reader.ReadAsync(cancellationToken) || reader.IsDBNull(0))
+ {
+ return HostingFromConnectionString();
+ }
+
+ var engineEdition = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture);
+
+ return new DatabaseHosting(HostingFor(engineEdition, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ logger.LogDebug(e, "Could not ask SQL Server how it is hosted, falling back to the connection string");
+
+ return HostingFromConnectionString();
+ }
+ }
+
+ ///
+ /// Maps an EngineEdition to a hosting classification. The Azure editions identify their service
+ /// outright. The ordinary editions say only that this is a regular SQL Server, which still
+ /// leaves RDS and Cloud SQL in play, so the host name decides before self-hosted is concluded.
+ /// Synapse and Fabric are not mapped: ServiceControl does not run on them, and an edition we do
+ /// not recognise is not evidence of an ordinary SQL Server, so it falls to the host name.
+ ///
+ internal static string HostingFor(int engineEdition, string? host) => engineEdition switch
+ {
+ AzureSqlDatabase => "AzureSql",
+ AzureSqlManagedInstance => "AzureSqlManagedInstance",
+ AzureSqlEdge => "AzureSqlEdge",
+ PersonalOrDesktop or Standard or Enterprise or Express => ManagedOrSelfHosted(host),
+ _ => DatabaseHostClassifier.Classify(host)
+ };
+
+ static string ManagedOrSelfHosted(string? host)
+ {
+ var classified = DatabaseHostClassifier.Classify(host);
+
+ return classified == DatabaseHostClassifier.Unknown ? DatabaseHostClassifier.SelfHosted : classified;
+ }
+
+ static string MajorVersion(DbDataReader reader)
+ {
+ if (reader.IsDBNull(1))
+ {
+ return DatabaseHostClassifier.Unknown;
+ }
+
+ var productVersion = Convert.ToString(reader.GetValue(1), CultureInfo.InvariantCulture);
+
+ if (string.IsNullOrWhiteSpace(productVersion))
+ {
+ return DatabaseHostClassifier.Unknown;
+ }
+
+ var major = productVersion.Split('.')[0];
+
+ return string.IsNullOrWhiteSpace(major) ? DatabaseHostClassifier.Unknown : major;
+ }
+
+ DatabaseHosting HostingFromConnectionString()
+ {
+ var host = ConfiguredHost;
+
+ return host is null
+ ? DatabaseHosting.Unclassified
+ : new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown, DatabaseHostingSource.ConnectionString);
+ }
+
+ string? ConfiguredHost
+ {
+ get
+ {
+ try
+ {
+ return new SqlConnectionStringBuilder(settings.ConnectionString).DataSource;
+ }
+ catch (Exception e)
+ {
+ logger.LogDebug(e, "Could not read the configured SQL Server host");
+
+ return null;
+ }
+ }
+ }
+
+ const int ProbeTimeoutSeconds = 5;
+
+ // https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql
+ const int PersonalOrDesktop = 1;
+ const int Standard = 2;
+ const int Enterprise = 3;
+ const int Express = 4;
+ const int AzureSqlDatabase = 5;
+ const int AzureSqlManagedInstance = 8;
+ const int AzureSqlEdge = 9;
+}
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs
index 2a1e1d6dbf..6c67df2065 100644
--- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs
@@ -17,6 +17,7 @@ public void AddPersistence(IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
}
public void AddInstaller(IServiceCollection services)
diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs
index 632bb0cbbb..a1ced0ed8e 100644
--- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs
+++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs
@@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.EFCore.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions;
+using Particular.LicensingComponent.Contracts;
using Particular.LicensingComponent.Persistence;
using ServiceControl.CustomChecks;
using ServiceControl.Operations.BodyStorage;
@@ -28,6 +29,7 @@ protected static void RegisterDataStores(IServiceCollection services, EFPersiste
services.AddUnitOfWorkFactory();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton(p => p.GetRequiredService());
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs
new file mode 100644
index 0000000000..1ddb54ae6c
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs
@@ -0,0 +1,50 @@
+namespace ServiceControl.Persistence.EFCore.Implementation;
+
+using System.Globalization;
+using Abstractions;
+using Infrastructure;
+using Particular.LicensingComponent.Contracts;
+using static Particular.LicensingComponent.Contracts.EnvironmentDatum;
+
+class EFEnvironmentDataProvider(EFPersisterSettings settings, IDatabaseHostingProbe hostingProbe) : IEnvironmentDataProvider
+{
+ public IEnumerable GetData()
+ {
+ // The three hosting keys share one probe, so the database is asked once per report and they
+ // stand or fall together, which is right because they have a single cause.
+ Task? probe = null;
+ Task Hosting(CancellationToken cancellationToken) => probe ??= hostingProbe.Probe(cancellationToken);
+
+ return
+ [
+ Value("Persistence.Type", () => hostingProbe.StorageName),
+ Deferred("Persistence.Hosting", async cancellationToken => (await Hosting(cancellationToken)).Hosting),
+ Deferred("Persistence.ServerVersion", async cancellationToken => (await Hosting(cancellationToken)).ServerVersion),
+ Deferred("Persistence.HostingSource", async cancellationToken => (await Hosting(cancellationToken)).Source),
+ Value("Persistence.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"),
+ Value("Persistence.BodyStorage.Type", () => BodyStorageType(settings.BodyStorage)),
+ Value("Persistence.BodyStorage.Auth", () => BodyStorageAuth(settings.BodyStorage)),
+ Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture))
+ ];
+ }
+
+ static string BodyStorageType(BodyStorageSettings bodyStorage) => bodyStorage switch
+ {
+ FileSystemBodyStorageSettings => nameof(Abstractions.BodyStorageType.FileSystem),
+ AzureBlobBodyStorageSettings => nameof(Abstractions.BodyStorageType.AzureBlob),
+ S3BodyStorageSettings => nameof(Abstractions.BodyStorageType.S3),
+ _ => "Unknown"
+ };
+
+ static string BodyStorageAuth(BodyStorageSettings bodyStorage) => bodyStorage switch
+ {
+ AzureBlobBodyStorageSettings azureBlob => azureBlob.Authentication switch
+ {
+ AzureBlobManagedIdentityAuthentication => "ManagedIdentity",
+ AzureBlobSharedKeyAuthentication => "SharedKeyOrSas",
+ _ => "Unknown"
+ },
+ S3BodyStorageSettings s3 => s3.Credentials is null ? "IamRole" : "StaticCredentials",
+ _ => "NotApplicable"
+ };
+}
diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs
new file mode 100644
index 0000000000..73c3b12051
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs
@@ -0,0 +1,30 @@
+namespace ServiceControl.Persistence.EFCore.Infrastructure;
+
+using ServiceControl.Persistence;
+
+///
+/// How the database this instance stores its data in is hosted. Reported in usage telemetry, so
+/// every value is a fixed classification and never carries a host name, database name or credential.
+///
+public interface IDatabaseHostingProbe
+{
+ ///
+ /// The persistence name as it appears in the persistence manifest, for example SQLServer.
+ ///
+ string StorageName { get; }
+
+ ///
+ /// Classifies the database host, asking the server itself where it can. Never throws; a server
+ /// that cannot be reached or does not answer is reported as unknown.
+ ///
+ Task Probe(CancellationToken cancellationToken = default);
+}
+
+/// One of AzureSql, AzureSqlManagedInstance, AzureSqlEdge, AzurePostgres, AzureMySql, AwsRds, GoogleCloudSql, RavenCloud, SelfHosted or Unknown.
+/// The engine major version, or Unknown.
+/// A value.
+public record DatabaseHosting(string Hosting, string ServerVersion, string Source)
+{
+ /// Nothing was available to classify the host with. A determination, not a failure.
+ public static readonly DatabaseHosting Unclassified = new(DatabaseHostClassifier.Unknown, DatabaseHostClassifier.Unknown, DatabaseHostingSource.None);
+}
diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs
new file mode 100644
index 0000000000..5070d0bebb
--- /dev/null
+++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs
@@ -0,0 +1,45 @@
+namespace ServiceControl.Persistence.RavenDB;
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Particular.LicensingComponent.Contracts;
+using Raven.Client.ServerWide.Operations;
+using static Particular.LicensingComponent.Contracts.EnvironmentDatum;
+
+class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider) : IEnvironmentDataProvider
+{
+ public IEnumerable GetData() =>
+ [
+ Value("Persistence.Type", () => "RavenDB"),
+ Value("Persistence.RavenServer", () => settings.UseEmbeddedServer ? "Embedded" : "External"),
+ Value("Persistence.Hosting", () => Hosting().Hosting),
+ Deferred("Persistence.ServerVersion", ServerVersion),
+ Value("Persistence.HostingSource", () => Hosting().Source),
+ Value("Persistence.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"),
+ Value("Persistence.BodyStorage.Type", () => "RavenAttachments"),
+ Value("Persistence.BodyStorage.Auth", () => "NotApplicable")
+ ];
+
+ (string Hosting, string Source) Hosting()
+ {
+ // An embedded server runs in this process, so there is nothing to infer.
+ if (settings.UseEmbeddedServer)
+ {
+ return (DatabaseHostClassifier.SelfHosted, DatabaseHostingSource.Configuration);
+ }
+
+ return Uri.TryCreate(settings.ConnectionString, UriKind.Absolute, out var url)
+ ? (DatabaseHostClassifier.Classify(url.Host), DatabaseHostingSource.ConnectionString)
+ : (DatabaseHostClassifier.Unknown, DatabaseHostingSource.None);
+ }
+
+ async ValueTask ServerVersion(CancellationToken cancellationToken)
+ {
+ var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken);
+ var buildNumber = await documentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken);
+
+ return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown;
+ }
+}
diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs
index 8e82edd6a9..09863a002a 100644
--- a/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs
+++ b/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs
@@ -7,6 +7,7 @@ namespace ServiceControl.Persistence.RavenDB;
using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions;
using Operations.BodyStorage;
using Operations.BodyStorage.RavenAttachments;
+using Particular.LicensingComponent.Contracts;
using Persistence.MessageRedirects;
using Persistence.Recoverability;
using Recoverability;
@@ -36,6 +37,7 @@ public void AddPersistence(IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton(p => p.GetRequiredService());
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs
new file mode 100644
index 0000000000..3c424ac471
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs
@@ -0,0 +1,32 @@
+namespace ServiceControl.Persistence.Tests.PostgreSql;
+
+using NUnit.Framework;
+using ServiceControl.Persistence.EFCore.PostgreSql;
+
+[TestFixture]
+class PostgreSqlDatabaseHostingProbeTests
+{
+ [Test]
+ public void Should_report_azure_when_the_azure_admin_role_exists() =>
+ Assert.That(Hosting(azure: true), Is.EqualTo("AzurePostgres"));
+
+ [Test]
+ public void Should_report_rds_when_the_rds_superuser_role_exists() =>
+ Assert.That(Hosting(rds: true), Is.EqualTo("AwsRds"));
+
+ [Test]
+ public void Should_report_cloud_sql_when_the_cloud_sql_superuser_role_exists() =>
+ Assert.That(Hosting(cloudSql: true), Is.EqualTo("GoogleCloudSql"));
+
+ [Test]
+ public void Should_report_self_hosted_when_no_managed_role_exists() =>
+ Assert.That(PostgreSqlDatabaseHostingProbe.HostingFor(false, false, false, "db01.corp.example"), Is.EqualTo("SelfHosted"),
+ "The server answered and has no managed fingerprint, which is evidence rather than absence of it");
+
+ [Test]
+ public void Should_prefer_the_host_when_a_managed_service_creates_no_role() =>
+ Assert.That(PostgreSqlDatabaseHostingProbe.HostingFor(false, false, false, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds"));
+
+ static string Hosting(bool azure = false, bool rds = false, bool cloudSql = false) =>
+ PostgreSqlDatabaseHostingProbe.HostingFor(azure, rds, cloudSql, "anything.example.com");
+}
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs
new file mode 100644
index 0000000000..cd2c5830b1
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.Persistence.Tests.SqlServer;
+
+using NUnit.Framework;
+using ServiceControl.Persistence.EFCore.SqlServer;
+
+// EngineEdition values per
+// https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql
+[TestFixture]
+class SqlServerDatabaseHostingProbeTests
+{
+ [TestCase(5, "AzureSql")]
+ [TestCase(8, "AzureSqlManagedInstance")]
+ [TestCase(9, "AzureSqlEdge")]
+ public void Should_take_the_azure_service_from_the_engine_edition(int engineEdition, string expected) =>
+ Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "anything.example.com"), Is.EqualTo(expected),
+ "The engine names its own service, so the host name must not get a say");
+
+ [TestCase(1)]
+ [TestCase(2)]
+ [TestCase(3)]
+ [TestCase(4)]
+ public void Should_report_an_ordinary_edition_on_an_unrecognised_host_as_self_hosted(int engineEdition) =>
+ Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "db01.corp.example"), Is.EqualTo("SelfHosted"),
+ "The server answered and is not an Azure service, which is evidence rather than absence of it");
+
+ [TestCase(2)]
+ [TestCase(3)]
+ public void Should_still_recognise_rds_running_an_ordinary_edition(int engineEdition) =>
+ Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds"),
+ "RDS for SQL Server reports Standard or Enterprise, so the host name is the only thing that gives it away");
+
+ // Synapse (6, 11) and Fabric (12) are deliberately unmapped, so they take this path.
+ [TestCase(6)]
+ [TestCase(11)]
+ [TestCase(12)]
+ [TestCase(99)]
+ public void Should_fall_back_to_the_host_for_an_unmapped_edition(int engineEdition) =>
+ Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "sc.database.windows.net"), Is.EqualTo("AzureSql"));
+
+ [TestCase(6)]
+ [TestCase(11)]
+ [TestCase(12)]
+ [TestCase(99)]
+ public void Should_report_unknown_for_an_unmapped_edition_on_an_unrecognised_host(int engineEdition) =>
+ Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "db01.corp.example"), Is.EqualTo("Unknown"),
+ "An edition we do not map is not evidence of an ordinary SQL Server");
+}
diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs
new file mode 100644
index 0000000000..bf603ff58d
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs
@@ -0,0 +1,168 @@
+namespace ServiceControl.Persistence.Tests;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using ServiceControl.Persistence.EFCore.Abstractions;
+using ServiceControl.Persistence.EFCore.Implementation;
+using ServiceControl.Persistence.EFCore.Infrastructure;
+
+[TestFixture]
+class DatabaseHostClassifierTests
+{
+ [TestCase("sc.database.windows.net", "AzureSql")]
+ [TestCase("tcp:sc.database.windows.net,1433", "AzureSql")]
+ [TestCase("SC.DATABASE.WINDOWS.NET", "AzureSql")]
+ [TestCase("sc.postgres.database.azure.com", "AzurePostgres")]
+ [TestCase("sc.abcdef.eu-west-1.rds.amazonaws.com", "AwsRds")]
+ [TestCase("/cloudsql/my-project:europe-west1:sc", "GoogleCloudSql")]
+ [TestCase("a.b.c.ravendb.cloud", "RavenCloud")]
+ [TestCase("localhost", "SelfHosted")]
+ [TestCase("127.0.0.1", "SelfHosted")]
+ [TestCase("(local)", "SelfHosted")]
+ [TestCase("(localdb)\\MSSQLLocalDB", "SelfHosted")]
+ public void Should_classify_host(string host, string expected) =>
+ Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo(expected));
+
+ [TestCase("sqlserver.internal.contoso.com")]
+ [TestCase("db01.corp.example")]
+ [TestCase("10.0.4.12")]
+ public void Should_not_call_a_private_name_self_hosted(string host) =>
+ Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo("Unknown"),
+ "A private DNS name in front of a managed database must not be counted as self-hosting");
+
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase(null)]
+ public void Should_report_unknown_when_there_is_no_host(string host) =>
+ Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo("Unknown"));
+}
+
+[TestFixture]
+class EFEnvironmentDataProviderTests
+{
+ [Test]
+ public async Task Should_report_managed_identity_for_azure_blob_service_uri()
+ {
+ var data = await GetData(new AzureBlobBodyStorageSettings
+ {
+ Authentication = new AzureBlobManagedIdentityAuthentication { ServiceUri = new Uri("https://account.blob.core.windows.net") }
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(data["Persistence.BodyStorage.Type"], Is.EqualTo("AzureBlob"));
+ Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("ManagedIdentity"));
+ });
+ }
+
+ [Test]
+ public async Task Should_report_shared_key_for_azure_blob_connection_string()
+ {
+ var data = await GetData(new AzureBlobBodyStorageSettings
+ {
+ Authentication = new AzureBlobSharedKeyAuthentication { ConnectionString = "UseDevelopmentStorage=true" }
+ });
+
+ Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("SharedKeyOrSas"));
+ }
+
+ [Test]
+ public async Task Should_report_iam_role_when_s3_has_no_static_credentials()
+ {
+ var data = await GetData(new S3BodyStorageSettings { BucketName = "bodies" });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(data["Persistence.BodyStorage.Type"], Is.EqualTo("S3"));
+ Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("IamRole"));
+ });
+ }
+
+ [Test]
+ public async Task Should_report_static_credentials_when_s3_has_an_access_key()
+ {
+ var data = await GetData(new S3BodyStorageSettings
+ {
+ BucketName = "bodies",
+ Credentials = new S3StaticCredentials { AccessKeyId = "key", SecretAccessKey = "secret" }
+ });
+
+ Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("StaticCredentials"));
+ }
+
+ [Test]
+ public async Task Should_report_file_system_body_storage_as_not_applicable_for_auth()
+ {
+ var data = await GetData(new FileSystemBodyStorageSettings { StoragePath = "/var/lib/servicecontrol" });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(data["Persistence.BodyStorage.Type"], Is.EqualTo("FileSystem"));
+ Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("NotApplicable"));
+ });
+ }
+
+ [Test]
+ public async Task Should_not_report_any_body_storage_secret_or_location()
+ {
+ var data = await GetData(new S3BodyStorageSettings
+ {
+ BucketName = "customer-bucket-name",
+ Credentials = new S3StaticCredentials { AccessKeyId = "AKIAEXAMPLE", SecretAccessKey = "topsecret" }
+ });
+
+ foreach (var value in data.Values)
+ {
+ Assert.That(value, Does.Not.Contain("customer-bucket-name").And.Not.Contain("AKIAEXAMPLE").And.Not.Contain("topsecret"));
+ }
+ }
+
+ [Test]
+ public async Task Should_fall_back_to_unknown_when_the_hosting_probe_fails()
+ {
+ var data = await GetData(new FileSystemBodyStorageSettings { StoragePath = "/var/lib/servicecontrol" }, new FailingHostingProbe());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(data["Persistence.Hosting"], Is.EqualTo("Unknown"));
+ Assert.That(data["Persistence.ServerVersion"], Is.EqualTo("Unknown"));
+ Assert.That(data["Persistence.HostingSource"], Is.EqualTo("None"));
+ });
+ }
+
+ static async Task> GetData(BodyStorageSettings bodyStorage, IDatabaseHostingProbe hostingProbe = null)
+ {
+ var settings = new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = bodyStorage };
+ var provider = new EFEnvironmentDataProvider(settings, hostingProbe ?? new TestHostingProbe());
+ var data = new Dictionary();
+
+ foreach (var datum in provider.GetData())
+ {
+ data[datum.Key] = await datum.ReadValue(CancellationToken.None);
+ }
+
+ return data;
+ }
+
+ class TestPersisterSettings : EFPersisterSettings;
+
+ class TestHostingProbe : IDatabaseHostingProbe
+ {
+ public string StorageName => "PostgreSQL";
+
+ public Task Probe(CancellationToken cancellationToken = default) =>
+ Task.FromResult(new DatabaseHosting("SelfHosted", "17", DatabaseHostingSource.Probe));
+ }
+
+ class FailingHostingProbe : IDatabaseHostingProbe
+ {
+ public string StorageName => "PostgreSQL";
+
+ public Task Probe(CancellationToken cancellationToken = default) =>
+ Task.FromResult(DatabaseHosting.Unclassified);
+ }
+}
diff --git a/src/ServiceControl.Persistence/DatabaseHostClassifier.cs b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs
new file mode 100644
index 0000000000..560bd972f8
--- /dev/null
+++ b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs
@@ -0,0 +1,73 @@
+namespace ServiceControl.Persistence;
+
+using System;
+using System.Linq;
+
+///
+/// Classifies a database host name into a managed-service category. Only the category is ever
+/// reported; the host name itself never leaves the process.
+///
+///
+/// This is the fallback for servers that cannot identify their own hosting. Where the engine can be
+/// asked directly, as SQL Server can through EngineEdition, that answer wins over this one.
+///
+public static class DatabaseHostClassifier
+{
+ public const string Unknown = "Unknown";
+ public const string SelfHosted = "SelfHosted";
+
+ ///
+ /// A host name is only ever evidence of a managed service or of a server on this machine.
+ /// Anything else, a private DNS name above all, is unknown rather than self-hosted: a customer
+ /// who fronts a managed database with their own DNS must not be counted as self-hosting it.
+ ///
+ public static string Classify(string? host)
+ {
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ return Unknown;
+ }
+
+ var normalized = host.Trim().ToLowerInvariant();
+
+ foreach (var (suffix, hosting) in ManagedHostSuffixes)
+ {
+ if (normalized.EndsWith(suffix, StringComparison.Ordinal) || normalized.Contains($"{suffix},", StringComparison.Ordinal))
+ {
+ return hosting;
+ }
+ }
+
+ // Cloud SQL is reached either through a host name or through a unix socket directory named
+ // after the instance, so neither end of the value is a reliable place to look.
+ if (normalized.Contains("cloudsql", StringComparison.Ordinal))
+ {
+ return "GoogleCloudSql";
+ }
+
+ return IsThisMachine(normalized) ? SelfHosted : Unknown;
+ }
+
+ static bool IsThisMachine(string normalized) =>
+ LocalHosts.Contains(normalized) ||
+ normalized.StartsWith("(localdb)", StringComparison.Ordinal) ||
+ normalized.StartsWith("np:", StringComparison.Ordinal) ||
+ normalized.StartsWith("lpc:", StringComparison.Ordinal) ||
+ normalized.StartsWith('/'); // a unix socket directory, which only a server on this machine listens on
+
+ static readonly string[] LocalHosts = ["localhost", "127.0.0.1", "::1", "[::1]", ".", "(local)"];
+
+ // Ordered longest-suffix-first so that the more specific Azure services win over the shared
+ // .database.azure.com suffix.
+ static readonly (string Suffix, string Hosting)[] ManagedHostSuffixes =
+ [
+ (".postgres.database.azure.com", "AzurePostgres"),
+ (".mysql.database.azure.com", "AzureMySql"),
+ (".database.windows.net", "AzureSql"),
+ (".database.azure.com", "AzureSql"),
+ (".rds.amazonaws.com", "AwsRds"),
+ (".ravendb.cloud", "RavenCloud"),
+ (".development.run", "RavenCloud"),
+ (".gcp.cloud", "GoogleCloudSql")
+ ];
+}
diff --git a/src/ServiceControl.Persistence/DatabaseHostingSource.cs b/src/ServiceControl.Persistence/DatabaseHostingSource.cs
new file mode 100644
index 0000000000..7696c1eac5
--- /dev/null
+++ b/src/ServiceControl.Persistence/DatabaseHostingSource.cs
@@ -0,0 +1,20 @@
+namespace ServiceControl.Persistence;
+
+///
+/// Where a reported database hosting classification came from, so that a guess can be told apart
+/// from an answer when the reports are analysed.
+///
+public static class DatabaseHostingSource
+{
+ /// The server itself said so, and is therefore authoritative.
+ public const string Probe = "Probe";
+
+ /// The configuration says so outright, with nothing left to infer.
+ public const string Configuration = "Configuration";
+
+ /// Inferred from the configured host name because the server could not be asked.
+ public const string ConnectionString = "ConnectionString";
+
+ /// Nothing was available to classify.
+ public const string None = "None";
+}
diff --git a/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs
new file mode 100644
index 0000000000..42cecbfa0c
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs
@@ -0,0 +1,71 @@
+namespace ServiceControl.UnitTests.Licensing;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Particular.ServiceControl;
+
+[TestFixture]
+class HostEnvironmentDataProviderTests
+{
+ [SetUp]
+ public async Task SetUp()
+ {
+ data = [];
+
+ foreach (var datum in new HostEnvironmentDataProvider().GetData())
+ {
+ data[datum.Key] = await datum.ReadValue(CancellationToken.None);
+ }
+ }
+
+ [Test]
+ public void Should_report_a_known_hosting_model() =>
+ Assert.That(data["Host.Model"], Is.AnyOf("Container", "WindowsService", "Console"));
+
+ [Test]
+ public void Should_report_whether_kubernetes_is_orchestrating() =>
+ Assert.That(data["Host.Orchestrator"], Is.AnyOf("Kubernetes", "None"));
+
+ [Test]
+ public void Should_report_a_known_os_platform() =>
+ Assert.That(data["Host.OSPlatform"], Is.AnyOf("Windows", "Linux", "macOS", "Unknown"));
+
+ [Test]
+ public void Should_report_os_version_as_major_and_minor_only() =>
+ Assert.That(data["Host.OSVersion"], Does.Match(@"^\d+\.\d+$"));
+
+ [Test]
+ public void Should_report_runtime_version_without_build_metadata() =>
+ Assert.That(data["Host.RuntimeVersion"], Does.Match(@"^\d+\.\d+\.\d+$"));
+
+ [Test]
+ public void Should_report_a_positive_processor_count() =>
+ Assert.That(int.Parse(data["Host.ProcessorCount"]), Is.GreaterThan(0));
+
+ [Test]
+ public void Should_report_available_memory_in_whole_gigabytes() =>
+ Assert.That(data["Host.AvailableMemoryGB"], Does.Match(@"^\d+$").Or.EqualTo("Unknown"));
+
+ [Test]
+ public void Should_not_report_any_value_that_could_identify_the_machine()
+ {
+ var machineIdentifiers = new[]
+ {
+ Environment.MachineName,
+ Environment.UserName,
+ RuntimeInformation.OSDescription
+ };
+
+ foreach (var value in data.Values)
+ {
+ Assert.That(machineIdentifiers, Has.None.EqualTo(value), $"Value '{value}' identifies the machine");
+ }
+ }
+
+ Dictionary data;
+}
diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs
index 6f132742bc..109e37b8a7 100644
--- a/src/ServiceControl/HostApplicationBuilderExtensions.cs
+++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs
@@ -78,6 +78,7 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S
services.AddSingleton(settings);
services.AddEnvironmentDataProvider();
+ services.AddEnvironmentDataProvider();
services.AddHttpLogging(options =>
{
diff --git a/src/ServiceControl/HostEnvironmentDataProvider.cs b/src/ServiceControl/HostEnvironmentDataProvider.cs
new file mode 100644
index 0000000000..344cfff0b8
--- /dev/null
+++ b/src/ServiceControl/HostEnvironmentDataProvider.cs
@@ -0,0 +1,66 @@
+namespace Particular.ServiceControl;
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.Hosting.WindowsServices;
+using Particular.LicensingComponent.Contracts;
+using global::ServiceControl.Configuration;
+using static Particular.LicensingComponent.Contracts.EnvironmentDatum;
+
+class HostEnvironmentDataProvider : IEnvironmentDataProvider
+{
+ public IEnumerable GetData() =>
+ [
+ Value("Host.Model", HostModel),
+ Value("Host.Orchestrator", Orchestrator),
+ Value("Host.OSPlatform", OSPlatformName),
+ Value("Host.OSVersion", () => $"{Environment.OSVersion.Version.Major}.{Environment.OSVersion.Version.Minor}"),
+ Value("Host.Architecture", () => RuntimeInformation.ProcessArchitecture.ToString()),
+ Value("Host.RuntimeVersion", () => Environment.Version.ToString(3)),
+ Value("Host.ProcessorCount", () => Environment.ProcessorCount.ToString(CultureInfo.InvariantCulture)),
+ Value("Host.AvailableMemoryGB", AvailableMemoryGB)
+ ];
+
+ static string Orchestrator() => Environment.GetEnvironmentVariable(KubernetesServiceHostVariable) is not null ? "Kubernetes" : "None";
+
+ static string HostModel()
+ {
+ if (AppEnvironment.RunningInContainer)
+ {
+ return "Container";
+ }
+
+ return WindowsServiceHelpers.IsWindowsService() ? "WindowsService" : "Console";
+ }
+
+ static string OSPlatformName()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return "Windows";
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return "Linux";
+ }
+
+ return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macOS" : "Unknown";
+ }
+
+ // TotalAvailableMemoryBytes honours the cgroup limit, so a container reports the memory it is
+ // limited to rather than the physical memory of the machine hosting it.
+ static string AvailableMemoryGB()
+ {
+ var totalAvailableMemoryBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
+
+ return totalAvailableMemoryBytes <= 0
+ ? "Unknown"
+ : Math.Round(totalAvailableMemoryBytes / (double)BytesPerGigabyte, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture);
+ }
+
+ const string KubernetesServiceHostVariable = "KUBERNETES_SERVICE_HOST";
+ const long BytesPerGigabyte = 1024L * 1024 * 1024;
+}
diff --git a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs
index b3828e8b43..4e73a2a1f0 100644
--- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs
+++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs
@@ -1,13 +1,45 @@
namespace Particular.ServiceControl;
+using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using global::ServiceControl.Persistence;
using Particular.LicensingComponent.Contracts;
using ServiceBus.Management.Infrastructure.Settings;
+using static Particular.LicensingComponent.Contracts.EnvironmentDatum;
-class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings) : IEnvironmentDataProvider
+class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore) : IEnvironmentDataProvider
{
- public IEnumerable<(string key, string value)> GetData()
+ public IEnumerable GetData() =>
+ [
+ Value("Security.Authentication", () => Toggle(settings.OpenIdConnectSettings.Enabled)),
+ Value("Security.RoleBasedAuthorization", () => Toggle(settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled)),
+ Value("Security.Https", () => Toggle(settings.HttpsSettings.Enabled)),
+ Value("Features.IntegratedServicePulse", () => Toggle(settings.EnableIntegratedServicePulse)),
+ Value("Features.MessageEditing", () => Toggle(settings.AllowMessageEditing)),
+ Value("Features.ExternalIntegrationsPublishing", () => Toggle(!settings.DisableExternalIntegrationsPublishing)),
+ Value("Features.ForwardErrorMessages", () => Toggle(settings.ForwardErrorMessages)),
+ Deferred("Features.EmailNotifications", EmailNotifications),
+ Value("Retention.ErrorHours", () => Hours(settings.ErrorRetentionPeriod)),
+ Value("Retention.EventsHours", () => Hours(settings.EventsRetentionPeriod))
+ ];
+
+ static string Toggle(bool enabled) => enabled ? "Enabled" : "Disabled";
+
+ static string Hours(TimeSpan retentionPeriod) =>
+ Math.Round(retentionPeriod.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture);
+
+ async ValueTask EmailNotifications(CancellationToken cancellationToken)
{
- yield return ("Features.IntegratedServicePulse", settings.EnableIntegratedServicePulse ? "Enabled" : "Disabled");
+ var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken);
+
+ if (notificationsSettings.Email.Enabled)
+ {
+ return "Enabled";
+ }
+
+ return string.IsNullOrWhiteSpace(notificationsSettings.Email.SmtpServer) ? "NotConfigured" : "Disabled";
}
-}
\ No newline at end of file
+}