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
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();
Task<IEnumerable<(string key, string value)>> GetData(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,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 Task<IEnumerable<(string key, string value)>> GetData(CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<(string, string)>>([("TestKey", "TestValue")]);
}
}
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
21 changes: 19 additions & 2 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,7 +189,23 @@ public async Task<SignedReport> GenerateThroughputReport(string spVersion, DateT

foreach (var environmentDataProvider in environmentDataProviders)
{
foreach (var (key, value) in environmentDataProvider.GetData())
IEnumerable<(string key, string value)> environmentData;

try
{
environmentData = await environmentDataProvider.GetData(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception e)
{
logger.LogWarning(e, "Environment data provider {EnvironmentDataProvider} failed, its data is omitted from the report", environmentDataProvider.GetType().Name);
continue;
}

foreach (var (key, value) in environmentData)
{
report.EnvironmentInformation.EnvironmentData[key] = value;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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["Security.Authentication"], Is.AnyOf("Enabled", "Disabled"));
Assert.That(data["Features.EmailNotifications"], Is.AnyOf("Enabled", "Disabled", "NotConfigured"));
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.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.AuditHours",
"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)));
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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<PostgreSqlDatabaseHostingProbe> logger) : IDatabaseHostingProbe
{
public string StorageName => "PostgreSQL";

public async Task<DatabaseHosting> Probe(CancellationToken cancellationToken = default)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();

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();
}

return new DatabaseHosting(HostingFromRoles(reader), MajorVersion(reader));
}
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.
string HostingFromRoles(DbDataReader reader)
{
if (reader.GetBoolean(1))
{
return "AzurePostgres";
}

if (reader.GetBoolean(2))
{
return "AwsRds";
}

return reader.GetBoolean(3) ? "GoogleCloudSql" : HostingFromConnectionString().Hosting;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the probe query succeeded, and we get to here, and its not azure, aws, or google, is this not evidence that its self hosted?

}

static string MajorVersion(DbDataReader reader) =>
reader.IsDBNull(0) ? DatabaseHostClassifier.Unknown : (reader.GetInt32(0) / 10000).ToString(CultureInfo.InvariantCulture);

DatabaseHosting HostingFromConnectionString()
{
try
{
var host = new NpgsqlConnectionStringBuilder(settings.ConnectionString).Host;

return new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown);
}
catch (Exception e)
{
logger.LogDebug(e, "Could not classify the configured PostgreSQL host");

return DatabaseHosting.Unavailable;
}
}

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public void AddPersistence(IServiceCollection services)
services.AddSingleton<IFailedMessageIngestionSqlDialect, PostgreSqlFailedMessageIngestionSqlDialect>();
services.AddSingleton<IRetryBatchSqlDialect, PostgreSqlRetryBatchSqlDialect>();
services.AddSingleton<IFullTextSearchDialect, PostgreSqlFullTextSearchDialect>();
services.AddSingleton<IDatabaseHostingProbe, PostgreSqlDatabaseHostingProbe>();
}

public void AddInstaller(IServiceCollection services)
Expand Down
Loading
Loading