Skip to content

Commit 323b654

Browse files
Report audit instance environment, storage footprint and ingestion health in the usage report (#5965)
* Report audit instance environment data in the usage report The audit instance serves host and storage facts on a new anonymous GET /api/environment endpoint, gathered from audit-local environment data providers. The primary polls every remote alongside the daily audit verification pass through the new IConfigurationApi.GetRemoteEnvironments, stores the per-instance dictionaries as AuditEnvironmentMetadata in the licensing stores, and the usage report emits them under the Audit.* prefix aggregated across instances: agreement passes through, processor count and memory take the maximum, and disagreement reports Mixed. Remotes that predate the endpoint are omitted rather than guessed. * Detect whether audit instances share the primary's machine, database or server Each instance hashes what identifies the machine it runs on and the storage it writes to: the OS machine id on Linux, the machine name elsewhere, NotApplicable in containers, and per engine the RavenDB server URL and database, SQL Server's ServerName and database, or PostgreSQL's cluster system identifier and database, plus the schema. The audit instance serves the hashes on the environment endpoint, the primary compares them with its own during collection, and only the classification reaches the report: Audit.SameMachine as True, False, Mixed, NotApplicable or Unknown, and Audit.DatabaseSharing as SameSchema, SameDatabase, SameServer, SeparateServer, NotApplicable or Unknown, aggregated to the most shared class across instances. A side that cannot be identified reports Unknown rather than guessing from configuration the two sides may spell differently. * Report storage footprint, edition and service tier for the primary and audit instances Storage.SizeGB, Storage.MessageCount and Storage.UnresolvedFailedMessages come from catalog statistics scoped to the configured schema on SQL Server and PostgreSQL and from database and collection statistics on RavenDB, never from counting rows. Storage.ServerEdition carries SQL Server's own edition designation and Storage.ServiceObjective the Azure SQL service objective, both NotApplicable elsewhere. The audit instance serves the same keys over the environment endpoint, and the report sums sizes and counts across audit instances with instances sharing a database counted once, keyed by the identity hashes from the sharing detection. A catalog that has no answer, such as a row estimate on a never analysed PostgreSQL table, reports Unknown rather than a guess. * Collect hourly ingestion rates and busy time for the error and audit sides Both ingestion pipelines keep running totals in memory: messages stored per completed batch, time spent processing batches, storage write time on the error side, and how far behind the endpoint's own timestamp each message arrived, bucketed at one, ten and sixty minutes. The audit instance serves its counters on the environment endpoint. A new hourly collector in the licensing component polls the primary's counters in process and every audit instance's over the API, folds the deltas into daily records in the licensing store with the audit side summed across instances, and keeps 400 days. A restarted process contributes its whole counters as the delta, a counter set seen for the first time only establishes a baseline, and a collector restart builds on what today's record already holds. The report emits per side the average daily messages, the busiest hour's messages and its busy share, plus storage milliseconds per message for the error side, from the days inside the report window. Known gap, accepted in the plan: --error-ingestion-only workers keep their own counters and nothing polls them. * Report health signals for the error and audit sides Health.Error.FailedImports and Health.Error.RetentionBehindHours come from the primary's persister: failed import rows counted from the store, and how many hours the oldest failed message sits past the retention window on the EF persisters, with RavenDB omitting the retention key because expiry is engine driven. The audit instance serves its failed audit import count on the environment endpoint, summed across instances with shared databases counted once. Restarts are the process start time changes the hourly collector observes between polls, and the lag buckets it accumulates become Health.*.LagOver1MinPercent, LagOver10MinPercent and LagOver60MinPercent over the report window, emitted only when any message carried a usable timestamp. * Replace hourly ingestion history collector with live counters served on the environment endpoint Instead of polling ingestion counters hourly, accumulating deltas into daily records in the licensing store, and reading those records back into the report, each instance now computes ingestion and health summaries directly from its running totals at report time. Both the audit and error sides expose their counters through IEnvironmentDataProvider implementations that call IngestionSummary.Describe, which yields keys like Ingestion.AvgDailyMessages, Ingestion.BusyPercent, Health.UptimeHours, and Health.LagOver*Percent relative to the process uptime. The audit instance serves these on the environment endpoint alongside its existing data; the primary aggregates them under the Audit.* prefix when building the report, summing daily message rates across instances and taking the worst saturation and lag values. This removes IngestionHistoryCollectorHostedService, IngestionHistory and IngestionDay contracts, IErrorIngestionSnapshotProvider, GetRemoteIngestionCounters from IConfigurationApi, and GetIngestionHistory/SaveIngestionHistory from ILicensingDataStore and both persistence backends. * Report database hosting, footprint and identity for EF Core audit persisters SQL Server and PostgreSQL audit persistence now probe how their database is hosted, how much it stores, and what identifies it, the same way the primary instance's EF persisters already did, so the report gets real Storage.* data for audit instances on these backends instead of nothing. SQL Server's edition and service objective are normalised to a small fixed set of categories instead of raw server strings, and both EF persisters resolve the configured schema the same way when none is set. AuditEnvironmentDataAggregator now passes through only an explicit whitelist of known keys, so a key served by an audit instance of another version can never reach the report unreviewed. ThroughputCollector copies broker metadata into a new dictionary before it reaches the report, so the stored metadata is never mutated. The environment endpoint's machine hash field is renamed from machine_id_hash to machine_name_hash to match what it actually hashes, and RavenDB's storage identity now treats a loopback address as this machine rather than a shared one. * Fix flaky full-text index size assertion in SQL Server audit footprint test WaitForFullTextIndex is now exposed as a public method with a default cancellation token so the environment data test can reuse the same wait the search tests rely on, instead of racing the index population. The test now waits for the full-text index to catch up before probing storage footprint, and asserts the footprint is only greater than the tables size rather than an exact sum, since the full-text index size can still drift slightly after the wait. The duplicated inline SQL for computing table and full-text sizes is consolidated into a single SizeGB helper. * Retry SQL Server storage footprint probe on deadlock or lost scan position Catalog reads under READ COMMITTED can deadlock with concurrent DDL, so the probe now runs under READ UNCOMMITTED instead. That isolation level can let a scan lose its place when pages move, so both error codes are now retried up to three times with a short backoff before the probe gives up and returns null. Applied to both the primary and audit EF Core SQL Server persisters, and the audit environment data test's inline SQL is updated to match the new isolation level. * Add tests for mixed and summed database values in throughput report * Add logging to ConfigurationApi for environment data retrieval errors --------- Co-authored-by: Warwick Schroeder <warwick.schroeder@particular.net>
1 parent ab53db9 commit 323b654

95 files changed

Lines changed: 3972 additions & 32 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Particular.LicensingComponent.Contracts;
2+
3+
/// <summary>
4+
/// The environment data each audit remote served when last polled, one dictionary per remote that
5+
/// responded. Never saved by versions that predate it, so a null read means unknown and the report
6+
/// omits the keys.
7+
/// </summary>
8+
public record AuditEnvironmentMetadata(List<Dictionary<string, string>> Instances)
9+
{
10+
/// <summary>
11+
/// The key under which the primary stores which database an instance's data came from, so that
12+
/// instances sharing a database are counted once when sizes and counts are summed. Underscore
13+
/// prefixed keys are collection bookkeeping and never reach the report.
14+
/// </summary>
15+
public const string DatabaseKey = "_DatabaseKey";
16+
}

‎src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class InMemoryLicensingDataStore : ILicensingDataStore
1414
readonly Dictionary<EndpointIdentifier, ThroughputData> allThroughput = [];
1515
BrokerMetadata brokerMetadata = new(null, []);
1616
AuditServiceMetadata auditServiceMetadata = new([], []);
17+
AuditEnvironmentMetadata? auditEnvironmentMetadata;
1718
List<string> reportMasks = [];
1819
LicensedEndpointDetails? endpointDetails = null;
1920
LicensedEndpointDetailsAmendment? endpointDetailsAmendment = null;
@@ -168,6 +169,14 @@ public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata,
168169
return Task.CompletedTask;
169170
}
170171

172+
public Task<AuditEnvironmentMetadata?> GetAuditEnvironmentMetadata(CancellationToken cancellationToken = default) => Task.FromResult(auditEnvironmentMetadata);
173+
174+
public Task SaveAuditEnvironmentMetadata(AuditEnvironmentMetadata auditEnvironmentMetadata, CancellationToken cancellationToken = default)
175+
{
176+
this.auditEnvironmentMetadata = auditEnvironmentMetadata;
177+
return Task.CompletedTask;
178+
}
179+
171180
public Task<List<string>> GetReportMasks(CancellationToken cancellationToken = default) => Task.FromResult(reportMasks);
172181

173182
public Task SaveReportMasks(List<string> reportMasks, CancellationToken cancellationToken = default)

‎src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSo
3636
Task<AuditServiceMetadata> GetAuditServiceMetadata(CancellationToken cancellationToken = default);
3737

3838
Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken = default);
39+
40+
Task<AuditEnvironmentMetadata?> GetAuditEnvironmentMetadata(CancellationToken cancellationToken = default);
41+
42+
Task SaveAuditEnvironmentMetadata(AuditEnvironmentMetadata auditEnvironmentMetadata, CancellationToken cancellationToken = default);
3943
Task<List<string>> GetReportMasks(CancellationToken cancellationToken = default);
4044
Task SaveReportMasks(List<string> reportMasks, CancellationToken cancellationToken = default);
4145

‎src/Particular.LicensingComponent.UnitTests/AuditQuery_Tests.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ public Task<RemoteConfiguration[]> GetRemoteConfigs(CancellationToken cancellati
228228
}
229229

230230
public Task<RootUrls> GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
231+
232+
public Task<RemoteEnvironment[]> GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
231233
}
232234

233235
class ConfigurationApi_ReturningRemotes(params RemoteConfiguration[] remotes) : IConfigurationApi
@@ -237,6 +239,8 @@ class ConfigurationApi_ReturningRemotes(params RemoteConfiguration[] remotes) :
237239
public Task<RemoteConfiguration[]> GetRemoteConfigs(CancellationToken cancellationToken = default) => Task.FromResult(remotes);
238240

239241
public Task<RootUrls> GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
242+
243+
public Task<RemoteEnvironment[]> GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
240244
}
241245

242246
class ConfigurationApi_Configurable : IConfigurationApi
@@ -257,6 +261,8 @@ public Task<RemoteConfiguration[]> GetRemoteConfigs(CancellationToken cancellati
257261

258262
public Task<RootUrls> GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
259263

264+
public Task<RemoteEnvironment[]> GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
265+
260266
public bool ReturnAuditConfig { get; set; }
261267
public string RemoteStatus { get; set; }
262268
public string RemoteVersion { get; set; }

‎src/Particular.LicensingComponent.UnitTests/AuditThroughputCollectorHostedService_Tests.cs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,40 @@ public async Task Should_store_how_many_audit_instances_are_configured_and_how_m
272272
}
273273
}
274274

275+
[Test]
276+
public async Task Should_store_the_environment_data_of_every_responding_audit_instance()
277+
{
278+
var auditQuery = new AuditQuery_WithRemotes([new RemoteInstanceInformation { Status = "online" }])
279+
{
280+
Environments =
281+
[
282+
new Dictionary<string, string> { ["Storage.Type"] = "RavenDB", ["Host.ProcessorCount"] = "4" },
283+
new Dictionary<string, string> { ["Storage.Type"] = "RavenDB", ["Host.ProcessorCount"] = "8" }
284+
]
285+
};
286+
287+
using var auditThroughputCollectorHostedService = new AuditThroughputCollectorHostedService(
288+
NullLogger<AuditThroughputCollectorHostedService>.Instance, configuration.ThroughputSettings, DataStore,
289+
auditQuery, new FakeTimeProvider())
290+
{ DelayStart = TimeSpan.Zero };
291+
292+
await auditThroughputCollectorHostedService.StartAsync(CancellationToken.None);
293+
await auditQuery.KnownEndpointsRequested.WaitAsync(TimeSpan.FromSeconds(30));
294+
await auditThroughputCollectorHostedService.StopAsync(CancellationToken.None);
295+
296+
var auditEnvironmentMetadata = await DataStore.GetAuditEnvironmentMetadata();
297+
298+
Assert.That(auditEnvironmentMetadata, Is.Not.Null);
299+
Assert.That(auditEnvironmentMetadata.Instances, Is.EqualTo(auditQuery.Environments));
300+
}
301+
275302
class AuditQuery_NoAuditRemotes : IAuditQuery
276303
{
277304
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
278305

306+
public Task<List<Dictionary<string, string>>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
307+
Task.FromResult<List<Dictionary<string, string>>>([]);
308+
279309
public Func<RemoteInstanceInformation, bool> ValidRemoteInstances => r => true;
280310

281311
public Task<IEnumerable<AuditCount>> GetAuditCountForEndpoint(string endpointUrlName,
@@ -309,6 +339,9 @@ public AuditQuery_WithOneEndpoint(string endpointName, long throughputCount, Dat
309339

310340
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
311341

342+
public Task<List<Dictionary<string, string>>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
343+
Task.FromResult<List<Dictionary<string, string>>>([]);
344+
312345
public Func<RemoteInstanceInformation, bool> ValidRemoteInstances => r => true;
313346

314347
public Task<IEnumerable<AuditCount>> GetAuditCountForEndpoint(string endpointUrlName,
@@ -341,6 +374,9 @@ class AuditQuery_ThrowingAnExceptionOnKnownEndpointsCall : IAuditQuery
341374
{
342375
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
343376

377+
public Task<List<Dictionary<string, string>>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
378+
Task.FromResult<List<Dictionary<string, string>>>([]);
379+
344380
public Func<RemoteInstanceInformation, bool> ValidRemoteInstances => r => true;
345381

346382
public Task<IEnumerable<AuditCount>> GetAuditCountForEndpoint(string endpointUrlName,
@@ -420,6 +456,10 @@ public AuditQuery_WithTwoEndpointsAndSelectiveCounts(
420456
}
421457

422458
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
459+
460+
public Task<List<Dictionary<string, string>>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
461+
Task.FromResult<List<Dictionary<string, string>>>([]);
462+
423463
public Func<RemoteInstanceInformation, bool> ValidRemoteInstances => _ => true;
424464

425465
public Task<IEnumerable<ServiceControlEndpoint>> GetKnownEndpoints(CancellationToken cancellationToken = default) =>
@@ -456,6 +496,12 @@ class AuditQuery_WithRemotes(List<RemoteInstanceInformation> remotes) : IAuditQu
456496
public Task KnownEndpointsRequested => knownEndpointsRequested.Task;
457497

458498
public SemanticVersion MinAuditCountsVersion => new(4, 29, 0);
499+
500+
public List<Dictionary<string, string>> Environments { get; init; } = [];
501+
502+
public Task<List<Dictionary<string, string>>> GetAuditEnvironments(CancellationToken cancellationToken = default) =>
503+
Task.FromResult(Environments);
504+
459505
public Func<RemoteInstanceInformation, bool> ValidRemoteInstances => _ => true;
460506

461507
public Task<List<RemoteInstanceInformation>> GetAuditRemotes(CancellationToken cancellationToken = default) =>

‎src/Particular.LicensingComponent.UnitTests/Infrastructure/FakeConfigurationApi.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class FakeConfigurationApi : IConfigurationApi
1010
{
1111
public Task<object> GetConfig(CancellationToken cancellationToken = default) => throw new NotImplementedException();
1212
public Task<RemoteConfiguration[]> GetRemoteConfigs(CancellationToken cancellationToken = default) => throw new NotImplementedException();
13+
public Task<RemoteEnvironment[]> GetRemoteEnvironments(CancellationToken cancellationToken = default) => throw new NotImplementedException();
1314
public Task<RootUrls> GetUrls(string baseUrl, CancellationToken cancellationToken = default) => throw new NotImplementedException();
1415
}
1516
}

‎src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ public Task<AuditServiceMetadata> GetAuditServiceMetadata(CancellationToken canc
252252
public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken = default) =>
253253
throw new NotSupportedException();
254254

255+
public Task<AuditEnvironmentMetadata> GetAuditEnvironmentMetadata(CancellationToken cancellationToken = default) =>
256+
throw new NotSupportedException();
257+
258+
public Task SaveAuditEnvironmentMetadata(AuditEnvironmentMetadata auditEnvironmentMetadata, CancellationToken cancellationToken = default) =>
259+
throw new NotSupportedException();
260+
255261
public Task<List<string>> GetReportMasks(CancellationToken cancellationToken = default) =>
256262
throw new NotSupportedException();
257263

0 commit comments

Comments
 (0)