From 31d5150be28ae100503a5ced540c757647806496 Mon Sep 17 00:00:00 2001 From: John Simons Date: Fri, 28 Aug 2026 15:49:41 +1000 Subject: [PATCH] Add retention sweep metrics for cycle duration, rows deleted, and consecutive failures Persisters publishing onto a host's already-registered meter cannot reference the host assembly, so meter names are extracted into a shared ServiceControlMeters constants class. Cycles interrupted by cancellation are excluded from measurements, as a clean shutdown is not a failure worth alerting on. --- .../Auditing/Metrics/IngestionMetrics.cs | 3 +- .../ServiceControlMeters.cs | 12 ++ .../Abstractions/BasePersistence.cs | 1 + .../Infrastructure/RetentionMetrics.cs | 120 +++++++++++ .../Infrastructure/RetentionSweeper.cs | 22 +- .../EFCore/RecordedRetentionMetrics.cs | 91 +++++++++ .../EFCore/RetentionMetricsTests.cs | 191 ++++++++++++++++++ .../EFCore/RetentionSweepTests.cs | 45 +++++ .../Operations/Metrics/IngestionMetrics.cs | 3 +- 9 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.Infrastructure/ServiceControlMeters.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionMetrics.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/RecordedRetentionMetrics.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/RetentionMetricsTests.cs diff --git a/src/ServiceControl.Audit/Auditing/Metrics/IngestionMetrics.cs b/src/ServiceControl.Audit/Auditing/Metrics/IngestionMetrics.cs index d79a7e76f0..038fefb7df 100644 --- a/src/ServiceControl.Audit/Auditing/Metrics/IngestionMetrics.cs +++ b/src/ServiceControl.Audit/Auditing/Metrics/IngestionMetrics.cs @@ -7,11 +7,12 @@ namespace ServiceControl.Audit.Auditing.Metrics; using EndpointPlugin.Messages.SagaState; using NServiceBus; using NServiceBus.Transport; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Ingestion.Metrics; public class IngestionMetrics { - public const string MeterName = "Particular.ServiceControl.Audit"; + public const string MeterName = ServiceControlMeters.Audit; public static readonly string BatchDurationInstrumentName = $"{InstrumentPrefix}.batch_duration_seconds"; public static readonly string MessageDurationInstrumentName = $"{InstrumentPrefix}.message_duration_seconds"; diff --git a/src/ServiceControl.Infrastructure/ServiceControlMeters.cs b/src/ServiceControl.Infrastructure/ServiceControlMeters.cs new file mode 100644 index 0000000000..aaac716f21 --- /dev/null +++ b/src/ServiceControl.Infrastructure/ServiceControlMeters.cs @@ -0,0 +1,12 @@ +namespace ServiceControl.Infrastructure; + +/// +/// The meters each instance publishes on. Shared because persisters publish onto the meter their +/// host has already registered with the exporter, and the two assemblies cannot reference each +/// other. +/// +public static class ServiceControlMeters +{ + public const string Error = "Particular.ServiceControl"; + public const string Audit = "Particular.ServiceControl.Audit"; +} diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs index 632bb0cbbb..a6a1e7da15 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs @@ -35,6 +35,7 @@ protected static void RegisterDataStores(IServiceCollection services, EFPersiste if (settings.RunRetentionSweep) { + services.AddSingleton(); services.AddHostedService(); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionMetrics.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionMetrics.cs new file mode 100644 index 0000000000..34e384de7b --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionMetrics.cs @@ -0,0 +1,120 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using ServiceControl.Infrastructure; + +public enum RetentionEntity +{ + FailedMessages, + EventLog, + GroupComments +} + +public class RetentionMetrics +{ + public const string MeterName = ServiceControlMeters.Error; + + public static readonly string CycleDurationInstrumentName = $"{InstrumentPrefix}.cycle_duration_seconds"; + public static readonly string RowsDeletedInstrumentName = $"{InstrumentPrefix}.rows_deleted_total"; + public static readonly string ConsecutiveFailuresInstrumentName = $"{InstrumentPrefix}.consecutive_failures_total"; + + public RetentionMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MeterName, MeterVersion); + + cycleDuration = meter.CreateHistogram( + CycleDurationInstrumentName, + unit: "seconds", + description: "Retention sweep pass duration in seconds", + tags: null, + // A sweep pass is sub-second when it is keeping up and minutes long when it is working + // through a backlog, so the default boundaries resolve neither end. + advice: new InstrumentAdvice { HistogramBucketBoundaries = [0.1, 0.5, 1, 5, 15, 60, 300, 900] }); + + rowsDeleted = meter.CreateCounter(RowsDeletedInstrumentName, description: "Rows deleted by the retention sweep"); + consecutiveFailureGauge = meter.CreateObservableGauge(ConsecutiveFailuresInstrumentName, ObserveConsecutiveFailures, description: "Consecutive retention sweep failures"); + } + + public RetentionCycleMetrics BeginCycle(RetentionEntity entity, CancellationToken cancellationToken = default) => new(this, entity, cancellationToken); + + public void RecordRowsDeleted(RetentionEntity entity, int rows) => rowsDeleted.Add(rows, EntityTags[(int)entity]); + + internal void RecordCycle(RetentionEntity entity, TimeSpan elapsed, bool success) + { + var tags = EntityTags[(int)entity]; + tags.Add("result", success ? "success" : "failed"); + + cycleDuration.Record(elapsed.TotalSeconds, tags); + + if (success) + { + Interlocked.Exchange(ref consecutiveFailures[(int)entity], 0); + } + else + { + Interlocked.Increment(ref consecutiveFailures[(int)entity]); + } + } + + IEnumerable> ObserveConsecutiveFailures() + { + for (var entity = 0; entity < consecutiveFailures.Length; entity++) + { + yield return new Measurement(Volatile.Read(ref consecutiveFailures[entity]), EntityTags[entity]); + } + } + + static TagList EntityTag(string entity) => new() { { "retention.entity", entity } }; + + readonly long[] consecutiveFailures = new long[EntityTags.Length]; + + readonly Histogram cycleDuration; + readonly Counter rowsDeleted; +#pragma warning disable IDE0052 + readonly ObservableGauge consecutiveFailureGauge; +#pragma warning restore IDE0052 + + static readonly TagList[] EntityTags = + [ + EntityTag("failed_messages"), + EntityTag("event_log"), + EntityTag("group_comments") + ]; + + const string MeterVersion = "0.1.0"; + const string InstrumentPrefix = "sc.retention"; +} + +/// +/// One pass of the retention sweep. A pass interrupted by shutdown is not a measurement of +/// anything, so a cancelled cycle records neither a duration nor a failure. +/// +public sealed class RetentionCycleMetrics : IDisposable +{ + internal RetentionCycleMetrics(RetentionMetrics metrics, RetentionEntity entity, CancellationToken cancellationToken) + { + this.metrics = metrics; + this.entity = entity; + this.cancellationToken = cancellationToken; + } + + public void Complete() => completed = true; + + public void Dispose() + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + metrics.RecordCycle(entity, stopwatch.Elapsed, completed); + } + + bool completed; + + readonly RetentionMetrics metrics; + readonly RetentionEntity entity; + readonly CancellationToken cancellationToken; + readonly Stopwatch stopwatch = Stopwatch.StartNew(); +} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs index 07811c4ad3..fd57ae2faa 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs @@ -17,6 +17,7 @@ public class RetentionSweeper( TimeProvider timeProvider, IServiceScopeFactory serviceScopeFactory, IBodyStoragePersistence bodyStorage, + RetentionMetrics metrics, EFPersisterSettings settings) : BackgroundService { const int BatchSize = 1000; @@ -71,18 +72,25 @@ async Task Sweep(bool pace, CancellationToken cancellationToken) // group ids are deterministic, reattach a stale comment if the same failure ever recurs. async Task SweepOrphanedGroupComments(CancellationToken cancellationToken) { + using var cycle = metrics.BeginCycle(RetentionEntity.GroupComments, cancellationToken); using var scope = serviceScopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.GroupComments + var deleted = await dbContext.GroupComments .Where(comment => !dbContext.FailedMessageGroups.Any(group => group.GroupId == comment.GroupId)) .ExecuteDeleteAsync(cancellationToken); + + metrics.RecordRowsDeleted(RetentionEntity.GroupComments, deleted); + + cycle.Complete(); } // Event log items are insert-only and carry no external bodies, so each batch is a single // ordered DELETE. async Task SweepEventLogItems(bool pace, CancellationToken cancellationToken) { + using var cycle = metrics.BeginCycle(RetentionEntity.EventLog, cancellationToken); + var cutoff = timeProvider.GetUtcNow().UtcDateTime - settings.EventsRetentionPeriod; while (!cancellationToken.IsCancellationRequested) @@ -96,6 +104,8 @@ async Task SweepEventLogItems(bool pace, CancellationToken cancellationToken) .Take(BatchSize) .ExecuteDeleteAsync(cancellationToken); + metrics.RecordRowsDeleted(RetentionEntity.EventLog, deleted); + if (deleted < BatchSize) { break; @@ -106,10 +116,14 @@ async Task SweepEventLogItems(bool pace, CancellationToken cancellationToken) await Task.Delay(BatchPause, timeProvider, cancellationToken); } } + + cycle.Complete(); } async Task SweepFailedMessages(bool pace, CancellationToken cancellationToken) { + using var cycle = metrics.BeginCycle(RetentionEntity.FailedMessages, cancellationToken); + var cutoff = timeProvider.GetUtcNow().UtcDateTime - settings.ErrorRetentionPeriod; while (!cancellationToken.IsCancellationRequested) @@ -142,11 +156,13 @@ async Task SweepFailedMessages(bool pace, CancellationToken cancellationToken) // The predicate is re-asserted so a message that was re-failed (back to Unresolved) // between the select and the delete is left alone. The cascade removes its group rows. - await dbContext.FailedMessages + var deleted = await dbContext.FailedMessages .Where(failedMessage => ids.Contains(failedMessage.UniqueMessageId)) .Where(IsExpired(cutoff)) .ExecuteDeleteAsync(cancellationToken); + metrics.RecordRowsDeleted(RetentionEntity.FailedMessages, deleted); + if (expired.Count < BatchSize) { break; @@ -157,6 +173,8 @@ await dbContext.FailedMessages await Task.Delay(BatchPause, timeProvider, cancellationToken); } } + + cycle.Complete(); } async Task DeleteExternalBody(Guid uniqueMessageId, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RecordedRetentionMetrics.cs b/src/ServiceControl.Persistence.Tests/EFCore/RecordedRetentionMetrics.cs new file mode 100644 index 0000000000..687164d117 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/RecordedRetentionMetrics.cs @@ -0,0 +1,91 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using ServiceControl.Persistence.EFCore.Infrastructure; + +/// +/// Collects everything the retention instruments record, for the meter belonging to one factory. +/// Every fixture in the run shares the meter name, so the factory is what tells these instruments +/// apart from the ones another test left behind. +/// +sealed class RecordedRetentionMetrics : IDisposable +{ + public RecordedRetentionMetrics(IMeterFactory meterFactory) + { + listener = new MeterListener + { + InstrumentPublished = (instrument, activeListener) => + { + if (instrument.Meter.Name == RetentionMetrics.MeterName && ReferenceEquals(instrument.Meter.Scope, meterFactory)) + { + activeListener.EnableMeasurementEvents(instrument); + } + } + }; + + listener.SetMeasurementEventCallback((instrument, measurement, tags, _) => Add(instrument, measurement, tags)); + listener.SetMeasurementEventCallback((instrument, measurement, tags, _) => Add(instrument, measurement, tags)); + listener.Start(); + } + + public IReadOnlyList Of(string instrumentName, RetentionEntity entity) + { + lock (measurements) + { + return + [ + .. measurements.Where(measurement => + measurement.InstrumentName == instrumentName && + Equals(measurement.Tags["retention.entity"], EntityTag(entity))) + ]; + } + } + + public IReadOnlyList Cycles(RetentionEntity entity) => Of(RetentionMetrics.CycleDurationInstrumentName, entity); + + public double RowsDeleted(RetentionEntity entity) => + Of(RetentionMetrics.RowsDeletedInstrumentName, entity).Sum(measurement => measurement.Value); + + public double ConsecutiveFailures(RetentionEntity entity) + { + listener.RecordObservableInstruments(); + + return Of(RetentionMetrics.ConsecutiveFailuresInstrumentName, entity)[^1].Value; + } + + public void Dispose() => listener.Dispose(); + + void Add(Instrument instrument, double value, ReadOnlySpan> tags) + { + var copied = new Dictionary(); + + foreach (var tag in tags) + { + copied[tag.Key] = tag.Value; + } + + lock (measurements) + { + measurements.Add(new Recorded(instrument.Name, value, copied)); + } + } + + static string EntityTag(RetentionEntity entity) => entity switch + { + RetentionEntity.FailedMessages => "failed_messages", + RetentionEntity.EventLog => "event_log", + RetentionEntity.GroupComments => "group_comments", + _ => throw new ArgumentOutOfRangeException(nameof(entity)) + }; + + readonly MeterListener listener; + readonly List measurements = []; + + public sealed record Recorded(string InstrumentName, double Value, Dictionary Tags) + { + public object Result => Tags["result"]; + } +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionMetricsTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionMetricsTests.cs new file mode 100644 index 0000000000..774f5a8a0a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionMetricsTests.cs @@ -0,0 +1,191 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Infrastructure; + +/// +/// Instrument names are what dashboards and alerts are built on, so they are a published contract +/// and not an implementation detail. +/// +[TestFixture] +class RetentionMetricsTests +{ + [SetUp] + public void CreateMeterFactory() => provider = new ServiceCollection().AddMetrics().BuildServiceProvider(); + + [TearDown] + public void DisposeMeterFactory() => provider.Dispose(); + + [Test] + public void The_meter_publishes_the_instruments_it_is_named_for() + { + var published = new List(); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, _) => + { + if (BelongsToThisTest(instrument)) + { + published.Add(instrument.Name); + } + } + }; + + listener.Start(); + + _ = new RetentionMetrics(MeterFactory); + + Assert.That(published.Order(), Is.EqualTo(new[] + { + "sc.retention.consecutive_failures_total", + "sc.retention.cycle_duration_seconds", + "sc.retention.rows_deleted_total" + })); + } + + [Test] + public void A_completed_cycle_is_recorded_as_a_success() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + + using (var cycle = metrics.BeginCycle(RetentionEntity.EventLog)) + { + cycle.Complete(); + } + + var cycles = recorded.Cycles(RetentionEntity.EventLog); + + using (Assert.EnterMultipleScope()) + { + Assert.That(cycles, Has.Count.EqualTo(1)); + Assert.That(cycles[0].Result, Is.EqualTo("success")); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.EventLog), Is.Zero); + } + } + + [Test] + public void An_abandoned_cycle_is_recorded_as_a_failure() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + + metrics.BeginCycle(RetentionEntity.EventLog).Dispose(); + + var cycles = recorded.Cycles(RetentionEntity.EventLog); + + using (Assert.EnterMultipleScope()) + { + Assert.That(cycles, Has.Count.EqualTo(1)); + Assert.That(cycles[0].Result, Is.EqualTo("failed")); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.EventLog), Is.EqualTo(1)); + } + } + + [Test] + public void Consecutive_failures_are_counted_per_entity() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + + metrics.BeginCycle(RetentionEntity.FailedMessages).Dispose(); + metrics.BeginCycle(RetentionEntity.FailedMessages).Dispose(); + metrics.BeginCycle(RetentionEntity.EventLog).Dispose(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.FailedMessages), Is.EqualTo(2)); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.EventLog), Is.EqualTo(1)); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.GroupComments), Is.Zero); + } + } + + [Test] + public void A_success_clears_the_failures_of_that_entity_alone() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + + metrics.BeginCycle(RetentionEntity.FailedMessages).Dispose(); + metrics.BeginCycle(RetentionEntity.EventLog).Dispose(); + + using (var cycle = metrics.BeginCycle(RetentionEntity.FailedMessages)) + { + cycle.Complete(); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.FailedMessages), Is.Zero); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.EventLog), Is.EqualTo(1)); + } + } + + [Test] + public void A_cycle_interrupted_by_shutdown_is_not_recorded() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + using var shutdown = new CancellationTokenSource(); + + using (metrics.BeginCycle(RetentionEntity.FailedMessages, shutdown.Token)) + { + shutdown.Cancel(); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(recorded.Cycles(RetentionEntity.FailedMessages), Is.Empty); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.FailedMessages), Is.Zero); + } + } + + [Test] + public void Deleted_rows_are_counted_per_entity() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + + metrics.RecordRowsDeleted(RetentionEntity.FailedMessages, 1000); + metrics.RecordRowsDeleted(RetentionEntity.FailedMessages, 7); + metrics.RecordRowsDeleted(RetentionEntity.GroupComments, 3); + + using (Assert.EnterMultipleScope()) + { + Assert.That(recorded.RowsDeleted(RetentionEntity.FailedMessages), Is.EqualTo(1007)); + Assert.That(recorded.RowsDeleted(RetentionEntity.GroupComments), Is.EqualTo(3)); + } + } + + [Test] + public void Concurrent_failures_are_all_counted() + { + var metrics = new RetentionMetrics(MeterFactory); + using var recorded = Listen(); + + const int failedCycles = 1000; + + Parallel.For(0, failedCycles, _ => metrics.BeginCycle(RetentionEntity.FailedMessages).Dispose()); + + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.FailedMessages), Is.EqualTo(failedCycles)); + } + + RecordedRetentionMetrics Listen() => new(MeterFactory); + + // Every fixture in the run shares the meter name, so the factory is what tells the instruments + // created here apart from the ones another test left behind. + bool BelongsToThisTest(Instrument instrument) => + instrument.Meter.Name == RetentionMetrics.MeterName && ReferenceEquals(instrument.Meter.Scope, MeterFactory); + + IMeterFactory MeterFactory => provider.GetRequiredService(); + + ServiceProvider provider; +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs index 122677d200..8948999fe6 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs @@ -2,12 +2,15 @@ namespace ServiceControl.Persistence.Tests; using System; using System.Collections.Generic; +using System.Diagnostics.Metrics; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using ServiceControl.EventLog; using ServiceControl.MessageFailures; using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.Infrastructure; class RetentionSweepTests : ErrorIngestionTestBase @@ -168,6 +171,48 @@ public async Task Archived_messages_are_swept_after_the_archiver_updates_the_tim Assert.That(await FindFailedMessage(messageId), Is.Null); } + [Test] + public async Task Counts_the_rows_it_deletes() + { + EFSettings.EventsRetentionPeriod = TimeSpan.FromDays(14); + + await SeedFailedMessage(FailedMessageStatus.Resolved, Now.AddDays(-31)); + await SeedFailedMessage(FailedMessageStatus.Resolved, Now.AddDays(-29)); + await Store(EventLogRow("expired", Now.AddDays(-15))); + + var expiredWithGroup = await SeedFailedMessage(FailedMessageStatus.Archived, Now.AddDays(-31)); + await GroupsStore.EditComment(await SeedGroup(expiredWithGroup), "Raised with the shipping team"); + + using var recorded = ListenToRetentionMetrics(); + + await RunRetentionSweep(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(recorded.RowsDeleted(RetentionEntity.FailedMessages), Is.EqualTo(2), "the 29 day old message is still within retention"); + Assert.That(recorded.RowsDeleted(RetentionEntity.EventLog), Is.EqualTo(1)); + Assert.That(recorded.RowsDeleted(RetentionEntity.GroupComments), Is.EqualTo(1)); + } + } + + [Test] + public async Task Records_a_successful_cycle_for_every_pass() + { + using var recorded = ListenToRetentionMetrics(); + + await RunRetentionSweep(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(recorded.Cycles(RetentionEntity.FailedMessages).Select(cycle => cycle.Result), Is.EqualTo(new[] { "success" })); + Assert.That(recorded.Cycles(RetentionEntity.EventLog).Select(cycle => cycle.Result), Is.EqualTo(new[] { "success" })); + Assert.That(recorded.Cycles(RetentionEntity.GroupComments).Select(cycle => cycle.Result), Is.EqualTo(new[] { "success" })); + Assert.That(recorded.ConsecutiveFailures(RetentionEntity.FailedMessages), Is.Zero); + } + } + + RecordedRetentionMetrics ListenToRetentionMetrics() => new(ServiceProvider.GetRequiredService()); + async Task SeedGroup(Guid uniqueMessageId) { var groupId = Guid.NewGuid().ToString(); diff --git a/src/ServiceControl/Operations/Metrics/IngestionMetrics.cs b/src/ServiceControl/Operations/Metrics/IngestionMetrics.cs index b877a21164..e395919797 100644 --- a/src/ServiceControl/Operations/Metrics/IngestionMetrics.cs +++ b/src/ServiceControl/Operations/Metrics/IngestionMetrics.cs @@ -5,11 +5,12 @@ namespace ServiceControl.Operations.Metrics; using System.Diagnostics.Metrics; using System.Threading; using NServiceBus.Transport; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Ingestion.Metrics; public class IngestionMetrics { - public const string MeterName = "Particular.ServiceControl"; + public const string MeterName = ServiceControlMeters.Error; public static readonly string BatchDurationInstrumentName = $"{InstrumentPrefix}.batch_duration_seconds"; public static readonly string MessageDurationInstrumentName = $"{InstrumentPrefix}.message_duration_seconds";