From 5e997c1943f64c558396ddb60afcd904ee2adcb7 Mon Sep 17 00:00:00 2001 From: Quinten Van Assche Date: Thu, 14 Dec 2023 10:52:06 +0100 Subject: [PATCH] feat: or-1834-metrics --- .gitignore | 2 - AssociationRegistry.sln | 2 +- .../Extensions/StoreOptionsExtensions.cs | 2 +- ...strumentation.cs => AcmInstrumentation.cs} | 16 +- .../Metrics/HighWatermarkListener.cs | 43 ----- .../Metrics/ProjectionStateListener.cs | 76 +++++++++ src/AssociationRegistry.Acm.Api/Program.cs | 2 +- .../Metrics/HighWatermarkListener.cs | 31 +--- .../Infrastructure/Metrics/Instrumentation.cs | 6 +- .../Metrics/AdminInstrumentation.cs | 45 +++++ .../Metrics/ProjectionStateListener.cs | 82 +++++++++ .../ConfigureMartenExtensions.cs | 4 + .../Program.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 158 ++++++++++++------ .../Metrics/HighWatermarkListener.cs | 51 ------ .../Infrastructure/Metrics/Instrumentation.cs | 40 ----- .../Metrics/ProjectionStateListener.cs | 78 +++++++++ .../Metrics/PubliekInstrumentation.cs | 45 +++++ .../ConfigureMartenExtensions.cs | 2 +- .../Program.cs | 4 +- 20 files changed, 465 insertions(+), 227 deletions(-) rename src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/{Instrumentation.cs => AcmInstrumentation.cs} (56%) delete mode 100644 src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/HighWatermarkListener.cs create mode 100644 src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/ProjectionStateListener.cs create mode 100644 src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/AdminInstrumentation.cs create mode 100644 src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs delete mode 100644 src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/HighWatermarkListener.cs delete mode 100644 src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/Instrumentation.cs create mode 100644 src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs create mode 100644 src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/PubliekInstrumentation.cs diff --git a/.gitignore b/.gitignore index ca0c9af4d..2eea6097e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ wwwroot/ otel-collector-config.yaml src/*/Internal -otel-collector-config.yaml - .vs/ .vscode/ .fake/ diff --git a/AssociationRegistry.sln b/AssociationRegistry.sln index f89642f29..0ea4a85fe 100644 --- a/AssociationRegistry.sln +++ b/AssociationRegistry.sln @@ -20,8 +20,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".Solution Items", ".Solutio definition_of_done.md = definition_of_done.md readme.md = readme.md CONTRIBUTING.md = CONTRIBUTING.md - otel-collector-config.yaml = otel-collector-config.yaml regen-marten.sh = regen-marten.sh + otel-collector-config.yaml = otel-collector-config.yaml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C812C887-6495-405A-8B99-4F686D243126}" diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs index c4c3691f9..ac3b4c796 100644 --- a/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs @@ -12,6 +12,6 @@ public static class StoreOptionsExtensions public static void AddPostgresProjections(this StoreOptions source, IServiceProvider serviceProvider) { source.Projections.Add(new VerenigingenPerInszProjection(), ProjectionLifecycle.Async); - source.Projections.AsyncListeners.Add(new HighWatermarkListener(serviceProvider.GetRequiredService())); + source.Projections.AsyncListeners.Add(new ProjectionStateListener(serviceProvider.GetRequiredService())); } } diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/Instrumentation.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/AcmInstrumentation.cs similarity index 56% rename from src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/Instrumentation.cs rename to src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/AcmInstrumentation.cs index 14b28ac48..efdf47807 100644 --- a/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/Instrumentation.cs +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/AcmInstrumentation.cs @@ -10,24 +10,28 @@ namespace AssociationRegistry.Acm.Api.Infrastructure.Metrics; /// ActivitySource and Instruments. This avoids possible type collisions /// with other components in the DI container. /// -public class Instrumentation : IInstrumentation, IDisposable +public class AcmInstrumentation : IInstrumentation, IDisposable { public string ActivitySourceName => "AssociationRegistry"; public string MeterName => "AcmProjections"; private readonly Meter meter; - public Instrumentation() + public AcmInstrumentation() { - var version = typeof(Instrumentation).Assembly.GetName().Version?.ToString(); + var version = typeof(AcmInstrumentation).Assembly.GetName().Version?.ToString(); ActivitySource = new ActivitySource(ActivitySourceName, version); meter = new Meter(MeterName, version); - VerenigingPerInszHistogram = - meter.CreateHistogram(name: "ar.p.verenigingPerInsz.h", unit: "events", description: "vereniging per insz projection"); + _verenigingPerInszGauge = + meter.CreateObservableGauge(name: "ar.acm.p.verenigingPerInsz.g", unit: "events", + description: "vereniging per insz projection", + observeValue: () => VerenigingPerInszEventValue); } public ActivitySource ActivitySource { get; } - public Histogram VerenigingPerInszHistogram { get; } + private ObservableGauge _verenigingPerInszGauge; + + public long VerenigingPerInszEventValue = 0; public void Dispose() { diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/HighWatermarkListener.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/HighWatermarkListener.cs deleted file mode 100644 index 5fee10028..000000000 --- a/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/HighWatermarkListener.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace AssociationRegistry.Acm.Api.Infrastructure.Metrics; - -using Marten; -using Marten.Services; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -public class HighWatermarkListener : IDocumentSessionListener -{ - private readonly Instrumentation _instrumentation; - - public HighWatermarkListener(Instrumentation instrumentation) - { - _instrumentation = instrumentation; - } - - public void BeforeSaveChanges(IDocumentSession session) - { - } - - public async Task BeforeSaveChangesAsync(IDocumentSession session, CancellationToken token) - { - } - - public void AfterCommit(IDocumentSession session, IChangeSet commit) - { - } - - public void DocumentLoaded(object id, object document) - { - } - - public void DocumentAddedForStorage(object id, object document) - { - } - - public async Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) - { - var highWatermark = commit.GetEvents().Max(x => x.Sequence); - _instrumentation.VerenigingPerInszHistogram.Record(highWatermark); - } -} diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/ProjectionStateListener.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/ProjectionStateListener.cs new file mode 100644 index 000000000..6c80a59c5 --- /dev/null +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/ProjectionStateListener.cs @@ -0,0 +1,76 @@ +namespace AssociationRegistry.Acm.Api.Infrastructure.Metrics; + +using Marten; +using Marten.Events.Daemon; +using Marten.Internal.Operations; +using Marten.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Projections; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +public class ProjectionStateListener : DocumentSessionListenerBase +{ + private readonly AcmInstrumentation _acmInstrumentation; + + public ProjectionStateListener(AcmInstrumentation acmInstrumentation) + { + _acmInstrumentation = acmInstrumentation; + } + + public override Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) + { + if (commit is not IUnitOfWork uow) return Task.CompletedTask; + var operations = uow.OperationsFor(); + + foreach (var operation in operations) + { + var range = GetEventRange(operation); + + if (range is null) continue; + + if (range.ShardName.ProjectionName == + typeof(VerenigingenPerInszProjection).FullName) + _acmInstrumentation.VerenigingPerInszEventValue =range.SequenceCeiling; + } + + return Task.CompletedTask; + } + + private static EventRange? GetEventRange(IStorageOperation opperation) + { + return opperation.GetType().Name switch + { + "InsertProjectionProgress" => opperation.GetType().GetField("_progress", BindingFlags.NonPublic|BindingFlags.Instance).GetValue(opperation) as EventRange, + "UpdateProjectionProgress" => opperation.GetType().GetProperty("Range").GetValue(opperation) as EventRange, + _ => null + }; + } +} + +public class AllFieldsContractResolver : DefaultContractResolver +{ + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + var props = type + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(p => base.CreateProperty(p, memberSerialization)) + .Union(type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(f => base.CreateProperty(f, memberSerialization))) + .ToList(); + + props.ForEach(p => + { + p.Writable = true; + p.Readable = true; + }); + + return props; + } +} diff --git a/src/AssociationRegistry.Acm.Api/Program.cs b/src/AssociationRegistry.Acm.Api/Program.cs index ddabf22ce..674b0bd42 100755 --- a/src/AssociationRegistry.Acm.Api/Program.cs +++ b/src/AssociationRegistry.Acm.Api/Program.cs @@ -251,7 +251,7 @@ private static void ConfigureServices(WebApplicationBuilder builder) builder.Services .AddSingleton(appSettings) .AddMarten(postgreSqlOptionsSection, builder.Configuration) - .AddOpenTelemetry(new Instrumentation()) + .AddOpenTelemetry(new AcmInstrumentation()) .AddHttpContextAccessor() .AddControllers(); diff --git a/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs index b664bcbae..b5277fe9d 100644 --- a/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs +++ b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs @@ -6,7 +6,7 @@ namespace AssociationRegistry.Admin.Api.Infrastructure.Metrics; using System.Threading; using System.Threading.Tasks; -public class HighWatermarkListener : IDocumentSessionListener +public class HighWatermarkListener : DocumentSessionListenerBase { private readonly Instrumentation _instrumentation; @@ -15,29 +15,14 @@ public HighWatermarkListener(Instrumentation instrumentation) _instrumentation = instrumentation; } - public void BeforeSaveChanges(IDocumentSession session) + public override async Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) { - } - - public async Task BeforeSaveChangesAsync(IDocumentSession session, CancellationToken token) - { - } - - public void AfterCommit(IDocumentSession session, IChangeSet commit) - { - } + var events = commit.GetEvents().ToArray(); - public void DocumentLoaded(object id, object document) - { - } - - public void DocumentAddedForStorage(object id, object document) - { - } - - public async Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) - { - var highWatermark = commit.GetEvents().Max(x => x.Sequence); - _instrumentation.HighWaterMarkHistogram.Record(highWatermark); + if (events.Any()) + { + var highWatermark = events.Max(x => x.Sequence); + _instrumentation.HighWatermarkEventValue = highWatermark; + } } } diff --git a/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/Instrumentation.cs b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/Instrumentation.cs index 4400a7e7e..874c8e46a 100644 --- a/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/Instrumentation.cs +++ b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/Instrumentation.cs @@ -22,11 +22,13 @@ public Instrumentation() ActivitySource = new ActivitySource(ActivitySourceName, version); meter = new Meter(MeterName, version); - HighWaterMarkHistogram = meter.CreateHistogram(name: "ar.highWatermark.h", unit: "events", description: "high watermark"); + _highWatermarkGauge = meter.CreateObservableGauge(name: "ar.beheer.p.highWatermark.g", unit: "events", + description: "high watermark", observeValue: () => HighWatermarkEventValue); } public ActivitySource ActivitySource { get; } - public Histogram HighWaterMarkHistogram { get; } + private ObservableGauge _highWatermarkGauge; + public long HighWatermarkEventValue = 0; public void Dispose() { diff --git a/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/AdminInstrumentation.cs b/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/AdminInstrumentation.cs new file mode 100644 index 000000000..28a06582d --- /dev/null +++ b/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/AdminInstrumentation.cs @@ -0,0 +1,45 @@ +namespace AssociationRegistry.Admin.ProjectionHost.Infrastructure.Metrics; + +using AssociationRegistry.OpenTelemetry; +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +/// +/// It is recommended to use a custom type to hold references for +/// ActivitySource and Instruments. This avoids possible type collisions +/// with other components in the DI container. +/// +public class AdminInstrumentation : IInstrumentation, IDisposable +{ + public string ActivitySourceName => "AssociationRegistry"; + public string MeterName => "AdminProjections"; + private readonly Meter meter; + + public AdminInstrumentation() + { + var version = typeof(AdminInstrumentation).Assembly.GetName().Version?.ToString(); + ActivitySource = new ActivitySource(ActivitySourceName, version); + meter = new Meter(MeterName, version); + + _verenigingZoeken = meter.CreateObservableGauge(name: "ar.beheer.p.zoeken.g", unit: "events", description: "Beheer zoeken projection", observeValue:() => VerenigingZoekenEventValue); + _verenigingDetail = meter.CreateObservableGauge(name: "ar.beheer.p.detail.g", unit: "events", description: "Beheer detail projection", observeValue:() => VerenigingDetailEventValue); + _historiek = meter.CreateObservableGauge(name: "ar.beheer.p.historiek.g", unit: "events", description: "Beheer detail projection", observeValue:() => VerenigingHistoriekEventValue); + } + public ActivitySource ActivitySource { get; } + + private ObservableGauge _verenigingZoeken; + public long VerenigingZoekenEventValue { get; set; } + private ObservableGauge _verenigingDetail; + public long VerenigingDetailEventValue { get; set; } + + private ObservableGauge _historiek; + public long VerenigingHistoriekEventValue { get; set; } + + + public void Dispose() + { + ActivitySource.Dispose(); + meter.Dispose(); + } +} diff --git a/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs b/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs new file mode 100644 index 000000000..9c3360358 --- /dev/null +++ b/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs @@ -0,0 +1,82 @@ +namespace AssociationRegistry.Admin.ProjectionHost.Infrastructure.Metrics; + +using Marten; +using Marten.Events.Daemon; +using Marten.Internal.Operations; +using Marten.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Projections.Detail; +using Projections.Historiek; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +public class ProjectionStateListener : DocumentSessionListenerBase +{ + private readonly AdminInstrumentation _adminInstrumentation; + + public ProjectionStateListener(AdminInstrumentation adminInstrumentation) + { + _adminInstrumentation = adminInstrumentation; + } + + public override Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) + { + if (commit is not IUnitOfWork uow) return Task.CompletedTask; + var operations = uow.OperationsFor(); + + foreach (var operation in operations) + { + var range = GetEventRange(operation); + + if (range is null) continue; + + if (range.ShardName.ProjectionName == "BeheerVerenigingZoekenDocument") + _adminInstrumentation.VerenigingZoekenEventValue = range.SequenceCeiling; + + if (range.ShardName.ProjectionName == typeof(BeheerVerenigingDetailProjection).FullName) + _adminInstrumentation.VerenigingDetailEventValue = range.SequenceCeiling; + + if (range.ShardName.ProjectionName == typeof(BeheerVerenigingHistoriekProjection).FullName) + _adminInstrumentation.VerenigingHistoriekEventValue = range.SequenceCeiling; + } + + return Task.CompletedTask; + } + + private static EventRange? GetEventRange(IStorageOperation opperation) + { + return opperation.GetType().Name switch + { + "InsertProjectionProgress" => opperation.GetType().GetField("_progress", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(opperation) as EventRange, + "UpdateProjectionProgress" => opperation.GetType().GetProperty("Range").GetValue(opperation) as EventRange, + _ => null + }; + } +} + +public class AllFieldsContractResolver : DefaultContractResolver +{ + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + var props = type + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(p => base.CreateProperty(p, memberSerialization)) + .Union(type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(f => base.CreateProperty(f, memberSerialization))) + .ToList(); + + props.ForEach(p => + { + p.Writable = true; + p.Readable = true; + }); + + return props; + } +} diff --git a/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs b/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs index d11d0a957..90a050f31 100644 --- a/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs +++ b/src/AssociationRegistry.Admin.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs @@ -9,6 +9,7 @@ namespace AssociationRegistry.Admin.ProjectionHost.Infrastructure.Program.WebApp using Marten.Events.Daemon.Resiliency; using Marten.Events.Projections; using Marten.Services; +using Metrics; using Newtonsoft.Json; using Projections; using Projections.Detail; @@ -81,6 +82,9 @@ static JsonNetSerializer CreateCustomMartenSerializer() opts.Projections.OnException(_ => true).Stop(); + opts.Projections.AsyncListeners.Add( + new ProjectionStateListener(serviceProvider.GetRequiredService())); + opts.Projections.Add(ProjectionLifecycle.Async); opts.Projections.Add(ProjectionLifecycle.Async); diff --git a/src/AssociationRegistry.Admin.ProjectionHost/Program.cs b/src/AssociationRegistry.Admin.ProjectionHost/Program.cs index ec088c08c..6c09c648a 100644 --- a/src/AssociationRegistry.Admin.ProjectionHost/Program.cs +++ b/src/AssociationRegistry.Admin.ProjectionHost/Program.cs @@ -4,6 +4,7 @@ namespace AssociationRegistry.Admin.ProjectionHost; using Infrastructure.ConfigurationBindings; using Infrastructure.Extensions; using Infrastructure.Json; +using Infrastructure.Metrics; using Infrastructure.Program; using Infrastructure.Program.WebApplication; using Infrastructure.Program.WebApplicationBuilder; @@ -68,7 +69,7 @@ public static async Task Main(string[] args) builder.Services .ConfigureRequestLocalization() - .AddOpenTelemetry() + .AddOpenTelemetry(new AdminInstrumentation()) .ConfigureProjectionsWithMarten(builder.Configuration) .ConfigureSwagger() .ConfigureElasticSearch(elasticSearchOptions) diff --git a/src/AssociationRegistry.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs b/src/AssociationRegistry.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs index fed196480..5c90c51ac 100644 --- a/src/AssociationRegistry.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs +++ b/src/AssociationRegistry.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs @@ -13,53 +13,73 @@ public static class ServiceCollectionExtensions { - public static IServiceCollection AddOpenTelemetry(this IServiceCollection services, IInstrumentation? instrumentation = null) + public static IServiceCollection AddOpenTelemetry(this IServiceCollection services) { - var executingAssembly = Assembly.GetEntryAssembly()!; - var serviceName = executingAssembly.GetName().Name!; - var assemblyVersion = executingAssembly.GetName().Version?.ToString() ?? "unknown"; - var collectorUrl = Environment.GetEnvironmentVariable("COLLECTOR_URL") ?? "http://localhost:4317"; + var (serviceName, collectorUrl, configureResource) = GetResources(); - if (instrumentation is not null) - services.AddSingleton(instrumentation); + return services.AddTracking(configureResource, collectorUrl, serviceName) + .AddLogging(configureResource, collectorUrl) + .AddMetrics(configureResource, collectorUrl); + } - Action configureResource = r => r - .AddService( - serviceName, - serviceVersion: assemblyVersion, - serviceInstanceId: Environment.MachineName) - .AddAttributes( - new Dictionary - { - ["deployment.environment"] = - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") - ?.ToLowerInvariant() - ?? "unknown", - }); + public static IServiceCollection AddOpenTelemetry(this IServiceCollection services, params T[] instrumentations) + where T : class, IInstrumentation + { + var (serviceName, collectorUrl, configureResource) = GetResources(); - services.AddOpenTelemetryTracing( - builder => - builder - .AddSource(serviceName) - .ConfigureResource(configureResource).AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation( - options => + foreach (var instrumentation in instrumentations) + { + services.AddSingleton(instrumentation); + } + + return services.AddTracking(configureResource, collectorUrl, serviceName) + .AddLogging(configureResource, collectorUrl) + .AddMetrics(configureResource, collectorUrl, builder => { - options.EnrichWithHttpRequest = - (activity, request) => activity.SetParentId(request.Headers["traceparent"]); + foreach (var instrumentation in instrumentations) + { + builder.AddMeter(instrumentation.MeterName); + } + }); + } - options.Filter = context => context.Request.Method != HttpMethods.Options; - }) - .AddNpgsql() + private static IServiceCollection AddMetrics( + this IServiceCollection services, + Action configureResource, + string collectorUrl, + Action? extraConfig = null) + { + return services.AddOpenTelemetryMetrics( + options => + { + options + .ConfigureResource(configureResource) + .AddRuntimeInstrumentation() + .AddHttpClientInstrumentation() + .AddAspNetCoreInstrumentation() .AddOtlpExporter( - options => + exporter => { - options.Protocol = OtlpExportProtocol.Grpc; - options.Endpoint = new Uri(collectorUrl); + exporter.Protocol = OtlpExportProtocol.Grpc; + exporter.Endpoint = new Uri(collectorUrl); }) - .AddSource("Wolverine")); + .InvokeIfNotNull(extraConfig); + }); + } - services.AddLogging( + private static T InvokeIfNotNull(this T obj, Action? method) + { + method?.Invoke(obj); + + return obj; + } + + private static IServiceCollection AddLogging( + this IServiceCollection services, + Action configureResource, + string collectorUrl) + { + return services.AddLogging( builder => builder .AddOpenTelemetry( @@ -78,26 +98,58 @@ public static IServiceCollection AddOpenTelemetry(this IServiceCollection servic exporter.Endpoint = new Uri(collectorUrl); }); })); + } - services.AddOpenTelemetryMetrics( - options => - { - options - .ConfigureResource(configureResource) - .AddRuntimeInstrumentation() - .AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation() + private static IServiceCollection AddTracking( + this IServiceCollection services, + Action configureResource, + string collectorUrl, + string serviceName) + { + return services.AddOpenTelemetryTracing( + builder => + builder + .AddSource(serviceName) + .ConfigureResource(configureResource).AddHttpClientInstrumentation() + .AddAspNetCoreInstrumentation( + options => + { + options.EnrichWithHttpRequest = + (activity, request) => activity.SetParentId(request.Headers["traceparent"]); + + options.Filter = context => context.Request.Method != HttpMethods.Options; + }) + .AddNpgsql() .AddOtlpExporter( - exporter => + options => { - exporter.Protocol = OtlpExportProtocol.Grpc; - exporter.Endpoint = new Uri(collectorUrl); - }); + options.Protocol = OtlpExportProtocol.Grpc; + options.Endpoint = new Uri(collectorUrl); + }) + .AddSource("Wolverine")); + } - if (instrumentation is not null) - options.AddMeter(instrumentation.MeterName); - }); + private static (string serviceName, string collectorUrl, Action configureResource) GetResources() + { + var executingAssembly = Assembly.GetEntryAssembly()!; + var serviceName = executingAssembly.GetName().Name!; + var assemblyVersion = executingAssembly.GetName().Version?.ToString() ?? "unknown"; + var collectorUrl = Environment.GetEnvironmentVariable("COLLECTOR_URL") ?? "http://localhost:4317"; + + Action configureResource = r => r + .AddService( + serviceName, + serviceVersion: assemblyVersion, + serviceInstanceId: Environment.MachineName) + .AddAttributes( + new Dictionary + { + ["deployment.environment"] = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?.ToLowerInvariant() + ?? "unknown", + }); - return services; + return (serviceName, collectorUrl, configureResource); } } diff --git a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/HighWatermarkListener.cs b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/HighWatermarkListener.cs deleted file mode 100644 index 1c84c1390..000000000 --- a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/HighWatermarkListener.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace AssociationRegistry.Public.ProjectionHost.Infrastructure.Metrics; - -using Marten; -using Marten.Events.Daemon; -using Marten.Services; -using Projections.Detail; - -public class HighWatermarkListener : IDocumentSessionListener -{ - private readonly Instrumentation _instrumentation; - - public HighWatermarkListener(Instrumentation instrumentation) - { - _instrumentation = instrumentation; - } - - public void BeforeSaveChanges(IDocumentSession session) - { - } - - public async Task BeforeSaveChangesAsync(IDocumentSession session, CancellationToken token) - { - } - - public void AfterCommit(IDocumentSession session, IChangeSet commit) - { - } - - public void DocumentLoaded(object id, object document) - { - } - - public void DocumentAddedForStorage(object id, object document) - { - } - - public async Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) - { - var publiekDetailProgress = await session.DocumentStore.Advanced.ProjectionProgressFor( - new ShardName(typeof(PubliekVerenigingDetailProjection).Namespace), - token: token); - - _instrumentation.PubliekVerenigingDetailHistogram.Record(publiekDetailProgress); - - var publiekZoekenProgress = await session.DocumentStore.Advanced.ProjectionProgressFor( - new ShardName("PubliekVerenigingZoekenProjection"), - token: token); - - _instrumentation.PubliekVerenigingZoekenHistogram.Record(publiekZoekenProgress); - } -} diff --git a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/Instrumentation.cs b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/Instrumentation.cs deleted file mode 100644 index a5719dfc5..000000000 --- a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/Instrumentation.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace AssociationRegistry.Public.ProjectionHost.Infrastructure.Metrics; - -using OpenTelemetry; -using System.Diagnostics; -using System.Diagnostics.Metrics; - -/// -/// It is recommended to use a custom type to hold references for -/// ActivitySource and Instruments. This avoids possible type collisions -/// with other components in the DI container. -/// -public class Instrumentation : IInstrumentation, IDisposable -{ - public string ActivitySourceName => "AssociationRegistry"; - public string MeterName => "PublicApi"; - private readonly Meter meter; - - public Instrumentation() - { - var version = typeof(Instrumentation).Assembly.GetName().Version?.ToString(); - ActivitySource = new ActivitySource(ActivitySourceName, version); - meter = new Meter(MeterName, version); - - PubliekVerenigingDetailHistogram = meter.CreateHistogram(name: "ar.p.publiekVerenigingDetail.h", unit: "events", - description: "publiek vereniging detail histogram"); - - PubliekVerenigingZoekenHistogram = - meter.CreateHistogram(name: "ar.p.publiekVerenigingZoeken.h", unit: "events", description: "publiek vereniging zoeken histogram"); - } - - public ActivitySource ActivitySource { get; } - public Histogram PubliekVerenigingDetailHistogram { get; } - public Histogram PubliekVerenigingZoekenHistogram { get; } - - public void Dispose() - { - ActivitySource.Dispose(); - meter.Dispose(); - } -} diff --git a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs new file mode 100644 index 000000000..8774683fe --- /dev/null +++ b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/ProjectionStateListener.cs @@ -0,0 +1,78 @@ +namespace AssociationRegistry.Public.ProjectionHost.Metrics; + +using Marten; +using Marten.Events.Daemon; +using Marten.Internal.Operations; +using Marten.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Projections.Detail; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +public class ProjectionStateListener : DocumentSessionListenerBase +{ + private readonly PubliekInstrumentation _publiekInstrumentation; + + public ProjectionStateListener(PubliekInstrumentation publiekInstrumentation) + { + _publiekInstrumentation = publiekInstrumentation; + } + + public override Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) + { + if (commit is not IUnitOfWork uow) return Task.CompletedTask; + var operations = uow.OperationsFor(); + + foreach (var operation in operations) + { + var range = GetEventRange(operation); + + if (range is null) continue; + + if (range.ShardName.ProjectionName == "PubliekVerenigingZoekenDocument") + _publiekInstrumentation.VerenigingZoekenEventValue = range.SequenceCeiling; + + if (range.ShardName.ProjectionName == typeof(PubliekVerenigingDetailProjection).FullName) + _publiekInstrumentation.VerenigingDetailEventValue = range.SequenceCeiling; + } + + return Task.CompletedTask; + } + + private static EventRange? GetEventRange(IStorageOperation opperation) + { + return opperation.GetType().Name switch + { + "InsertProjectionProgress" => opperation.GetType().GetField("_progress", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(opperation) as EventRange, + "UpdateProjectionProgress" => opperation.GetType().GetProperty("Range").GetValue(opperation) as EventRange, + _ => null, + }; + } +} + +public class AllFieldsContractResolver : DefaultContractResolver +{ + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + var props = type + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(p => base.CreateProperty(p, memberSerialization)) + .Union(type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(f => base.CreateProperty(f, memberSerialization))) + .ToList(); + + props.ForEach(p => + { + p.Writable = true; + p.Readable = true; + }); + + return props; + } +} diff --git a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/PubliekInstrumentation.cs b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/PubliekInstrumentation.cs new file mode 100644 index 000000000..e81c2b222 --- /dev/null +++ b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Metrics/PubliekInstrumentation.cs @@ -0,0 +1,45 @@ +namespace AssociationRegistry.Public.ProjectionHost.Metrics; + +using AssociationRegistry.OpenTelemetry; +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +/// +/// It is recommended to use a custom type to hold references for +/// ActivitySource and Instruments. This avoids possible type collisions +/// with other components in the DI container. +/// +public class PubliekInstrumentation : IInstrumentation, IDisposable +{ + public string ActivitySourceName => "AssociationRegistry"; + public string MeterName => "PubliekProjections"; + private readonly Meter meter; + + public PubliekInstrumentation() + { + var version = typeof(PubliekInstrumentation).Assembly.GetName().Version?.ToString(); + ActivitySource = new ActivitySource(ActivitySourceName, version); + meter = new Meter(MeterName, version); + + _verenigingZoeken = meter.CreateObservableGauge(name: "ar.publiek.p.zoeken.g", unit: "events", + description: "Beheer zoeken projection", + observeValue: () => VerenigingZoekenEventValue); + + _verenigingDetail = meter.CreateObservableGauge(name: "ar.publiek.p.detail.g", unit: "events", + description: "Beheer detail projection", + observeValue: () => VerenigingDetailEventValue); + } + + private ObservableGauge _verenigingDetail; + public long VerenigingDetailEventValue { get; set; } + private ObservableGauge _verenigingZoeken; + public long VerenigingZoekenEventValue { get; set; } + public ActivitySource ActivitySource { get; } + + public void Dispose() + { + ActivitySource.Dispose(); + meter.Dispose(); + } +} diff --git a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs index 59abfa9ec..c0cb5ff79 100644 --- a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs +++ b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs @@ -88,7 +88,7 @@ static JsonNetSerializer CreateCustomMartenSerializer() ProjectionLifecycle.Async, projectionName: ProjectionNames.VerenigingZoeken); - opts.Projections.AsyncListeners.Add(new HighWatermarkListener(serviceProvider.GetRequiredService())); + opts.Projections.AsyncListeners.Add(new ProjectionStateListener(serviceProvider.GetRequiredService())); opts.Serializer(CreateCustomMartenSerializer()); diff --git a/src/AssociationRegistry.Public.ProjectionHost/Program.cs b/src/AssociationRegistry.Public.ProjectionHost/Program.cs index 199fe5830..2610e9a9e 100644 --- a/src/AssociationRegistry.Public.ProjectionHost/Program.cs +++ b/src/AssociationRegistry.Public.ProjectionHost/Program.cs @@ -4,12 +4,12 @@ namespace AssociationRegistry.Public.ProjectionHost; using Infrastructure.ConfigurationBindings; using Infrastructure.Extensions; using Infrastructure.Json; -using Infrastructure.Metrics; using Infrastructure.Program; using Infrastructure.Program.WebApplication; using Infrastructure.Program.WebApplicationBuilder; using JasperFx.CodeGeneration; using Marten; +using Metrics; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.NewtonsoftJson; @@ -68,7 +68,7 @@ public static async Task Main(string[] args) builder.Services .ConfigureRequestLocalization() - .AddOpenTelemetry(new Instrumentation()) + .AddOpenTelemetry(new PubliekInstrumentation()) .ConfigureProjectionsWithMarten(builder.Configuration) .ConfigureSwagger() .ConfigureElasticSearch(elasticSearchOptions)