diff --git a/AssociationRegistry.sln b/AssociationRegistry.sln index e7bd5b638..39214a2c5 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/otel-collector-config.yaml b/otel-collector-config.example.yaml similarity index 100% rename from otel-collector-config.yaml rename to otel-collector-config.example.yaml diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/MartenExtensions.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/MartenExtensions.cs index 84e656ea3..ecad0e639 100644 --- a/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/MartenExtensions.cs +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/MartenExtensions.cs @@ -14,9 +14,12 @@ using Newtonsoft.Json; using Schema.VerenigingenPerInsz; -public static class MartenExtentions +public static class MartenExtensions { - public static IServiceCollection AddMarten(this IServiceCollection services, PostgreSqlOptionsSection postgreSqlOptions, IConfiguration configuration) + public static IServiceCollection AddMarten( + this IServiceCollection services, + PostgreSqlOptionsSection postgreSqlOptions, + IConfiguration configuration) { var martenConfiguration = services .AddSingleton(postgreSqlOptions) @@ -28,7 +31,7 @@ public static IServiceCollection AddMarten(this IServiceCollection services, Pos opts.Events.StreamIdentity = StreamIdentity.AsString; opts.Serializer(CreateCustomMartenSerializer()); opts.Events.MetadataConfig.EnableAll(); - opts.AddPostgresProjections(); + opts.AddPostgresProjections(serviceProvider); opts.RegisterDocumentType(); opts.RegisterDocumentType(); diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs index a819ae8e2..ac3b4c796 100644 --- a/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Extensions/StoreOptionsExtensions.cs @@ -2,12 +2,16 @@ namespace AssociationRegistry.Acm.Api.Infrastructure.Extensions; using Marten; using Marten.Events.Projections; +using Metrics; +using Microsoft.Extensions.DependencyInjection; using Projections; +using System; public static class StoreOptionsExtensions { - public static void AddPostgresProjections(this StoreOptions source) + public static void AddPostgresProjections(this StoreOptions source, IServiceProvider serviceProvider) { source.Projections.Add(new VerenigingenPerInszProjection(), ProjectionLifecycle.Async); + source.Projections.AsyncListeners.Add(new ProjectionStateListener(serviceProvider.GetRequiredService())); } } diff --git a/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/AcmInstrumentation.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/AcmInstrumentation.cs new file mode 100644 index 000000000..efdf47807 --- /dev/null +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Metrics/AcmInstrumentation.cs @@ -0,0 +1,41 @@ +namespace AssociationRegistry.Acm.Api.Infrastructure.Metrics; + +using 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 AcmInstrumentation : IInstrumentation, IDisposable +{ + public string ActivitySourceName => "AssociationRegistry"; + public string MeterName => "AcmProjections"; + private readonly Meter meter; + + public AcmInstrumentation() + { + var version = typeof(AcmInstrumentation).Assembly.GetName().Version?.ToString(); + ActivitySource = new ActivitySource(ActivitySourceName, version); + meter = new Meter(MeterName, version); + + _verenigingPerInszGauge = + meter.CreateObservableGauge(name: "ar.acm.p.verenigingPerInsz.g", unit: "events", + description: "vereniging per insz projection", + observeValue: () => VerenigingPerInszEventValue); + } + + public ActivitySource ActivitySource { get; } + private ObservableGauge _verenigingPerInszGauge; + + public long VerenigingPerInszEventValue = 0; + + public void Dispose() + { + ActivitySource.Dispose(); + meter.Dispose(); + } +} 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/Infrastructure/Monitoring/OpenTelemetryMetrics.cs b/src/AssociationRegistry.Acm.Api/Infrastructure/Monitoring/OpenTelemetryMetrics.cs new file mode 100644 index 000000000..d2050eb84 --- /dev/null +++ b/src/AssociationRegistry.Acm.Api/Infrastructure/Monitoring/OpenTelemetryMetrics.cs @@ -0,0 +1,83 @@ +namespace AssociationRegistry.Acm.Api.Infrastructure.Monitoring; + +using System; +using System.Diagnostics.Metrics; + +public class OpenTelemetryMetrics +{ + public class ElasticSearchProjections + { + public static Func MeterNameFunc = name => $"VR.ES.{name}"; + private readonly Meter _meter; + public Histogram NumberOfEnvelopesHandledHistogram { get; } + public int NumberOfEnvelopesHandledGauge { get; set; } + public int NumberOfEnvelopesHandledCounter { get; set; } + public Histogram NumberOfEnvelopesBehindHistogram { get; } + public int NumberOfEnvelopesBehindGauge { get; set; } + public int NumberOfEnvelopesBehindCounter { get; set; } + public int LastProcessedEventNumberGauge { get; set; } + public int LastProcessedEventNumberCounter { get; set; } + public int MaxEventNumberToProcessGauge { get; set; } + public int MaxEventNumberToProcessCounter { get; set; } + + private static class MeterNames + { + public static Func EnvelopesHandled = (meterName, meterType) + => $"{meterName}.{nameof(EnvelopesHandled)}.{meterType}"; + + public static Func EnvelopesBehind = (meterName, meterType) + => $"{meterName}.{nameof(EnvelopesBehind)}.{meterType}"; + + public static Func LastProcessedEvent = + (meterName, meterType) => $"{meterName}.{nameof(LastProcessedEvent)}.{meterType}"; + + public static Func MaxEventNumberToProcess = + (meterName, meterType) => $"{meterName}.{nameof(MaxEventNumberToProcess)}.{meterType}"; + + public static Func OrganisationsToRebuild = + (meterName, meterType) => $"{meterName}.{nameof(OrganisationsToRebuild)}.{meterType}"; + } + + public ElasticSearchProjections(string runnerName) + { + const string histogram = "Histogram"; + const string counter = "Counter"; + const string gauge = "Gauge"; + + var meterName = MeterNameFunc(runnerName); + _meter = new Meter(meterName); + + NumberOfEnvelopesHandledHistogram = _meter.CreateHistogram(MeterNames.EnvelopesHandled(meterName, histogram), + unit: "envelopes", description: "number of envelopes handled"); + + _meter.CreateObservableGauge(MeterNames.EnvelopesHandled(meterName, gauge), observeValue: () => NumberOfEnvelopesHandledGauge, + unit: "envelopes", description: "number of envelopes handled"); + + _meter.CreateObservableUpDownCounter(MeterNames.EnvelopesHandled(meterName, counter), + observeValue: () => NumberOfEnvelopesHandledCounter, unit: "envelopes", + description: "number of envelopes handled"); + + _meter.CreateObservableUpDownCounter(MeterNames.LastProcessedEvent(meterName, counter), + observeValue: () => LastProcessedEventNumberCounter); + + _meter.CreateObservableGauge(MeterNames.LastProcessedEvent(meterName, gauge), + observeValue: () => LastProcessedEventNumberGauge); + + _meter.CreateObservableUpDownCounter(MeterNames.MaxEventNumberToProcess(meterName, counter), + observeValue: () => MaxEventNumberToProcessCounter); + + _meter.CreateObservableGauge(MeterNames.MaxEventNumberToProcess(meterName, gauge), + observeValue: () => MaxEventNumberToProcessGauge); + + NumberOfEnvelopesBehindHistogram = _meter.CreateHistogram(MeterNames.EnvelopesBehind(meterName, histogram), + unit: "envelopes", description: "number of envelopes behind"); + + _meter.CreateObservableUpDownCounter(MeterNames.EnvelopesBehind(meterName, counter), + observeValue: () => NumberOfEnvelopesBehindCounter, unit: "envelopes", + description: "number of envelopes behind"); + + _meter.CreateObservableGauge(MeterNames.EnvelopesBehind(meterName, gauge), observeValue: () => NumberOfEnvelopesBehindGauge, + unit: "envelopes", description: "number of envelopes behind"); + } + } +} diff --git a/src/AssociationRegistry.Acm.Api/Program.cs b/src/AssociationRegistry.Acm.Api/Program.cs index b8b720a13..674b0bd42 100755 --- a/src/AssociationRegistry.Acm.Api/Program.cs +++ b/src/AssociationRegistry.Acm.Api/Program.cs @@ -16,6 +16,7 @@ namespace AssociationRegistry.Acm.Api; using Infrastructure.Configuration; using Infrastructure.ConfigurationBindings; using Infrastructure.Extensions; +using Infrastructure.Metrics; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; @@ -214,7 +215,8 @@ private static void LoadConfiguration(WebApplicationBuilder builder, params stri { builder.Configuration .AddJsonFile("appsettings.json") - .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName.ToLowerInvariant()}.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName.ToLowerInvariant()}.json", optional: true, + reloadOnChange: false) .AddJsonFile($"appsettings.{Environment.MachineName.ToLowerInvariant()}.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() .AddCommandLine(args) @@ -249,7 +251,7 @@ private static void ConfigureServices(WebApplicationBuilder builder) builder.Services .AddSingleton(appSettings) .AddMarten(postgreSqlOptionsSection, builder.Configuration) - .AddOpenTelemetry() + .AddOpenTelemetry(new AcmInstrumentation()) .AddHttpContextAccessor() .AddControllers(); @@ -303,7 +305,8 @@ private static void ConfigureServices(WebApplicationBuilder builder) cfg.AddPolicy( StartupConstants.AllowSpecificOrigin, configurePolicy: corsPolicy => corsPolicy - .WithOrigins(builder.Configuration.GetValue("Cors") ?? Array.Empty()) + .WithOrigins(builder.Configuration.GetValue("Cors") ?? + Array.Empty()) .WithMethods(StartupConstants.HttpMethodsAsString) .WithHeaders(StartupConstants.Headers) .WithExposedHeaders(StartupConstants.ExposedHeaders) @@ -337,7 +340,9 @@ private static void ConfigureServices(WebApplicationBuilder builder) JwtBearerDefaults.AuthenticationScheme, configureOptions: options => { - var configOptions = builder.Configuration.GetSection(nameof(OAuth2IntrospectionOptions)).Get(); + var configOptions = builder.Configuration.GetSection(nameof(OAuth2IntrospectionOptions)) + .Get(); + options.ClientId = configOptions.ClientId; options.ClientSecret = configOptions.ClientSecret; options.Authority = configOptions.Authority; diff --git a/src/AssociationRegistry.Admin.Api/Infrastructure/Extensions/MartenExtensions.cs b/src/AssociationRegistry.Admin.Api/Infrastructure/Extensions/MartenExtensions.cs index f99b77f98..11c581e5d 100644 --- a/src/AssociationRegistry.Admin.Api/Infrastructure/Extensions/MartenExtensions.cs +++ b/src/AssociationRegistry.Admin.Api/Infrastructure/Extensions/MartenExtensions.cs @@ -8,6 +8,7 @@ using Marten; using Marten.Events; using Marten.Services; +using Metrics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -35,6 +36,8 @@ public static IServiceCollection AddMarten( opts.Serializer(CreateCustomMartenSerializer()); opts.Events.MetadataConfig.EnableAll(); + opts.Listeners.Add(new HighWatermarkListener(serviceProvider.GetRequiredService())); + opts.RegisterDocumentType(); opts.RegisterDocumentType(); diff --git a/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs new file mode 100644 index 000000000..b5277fe9d --- /dev/null +++ b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/HighWatermarkListener.cs @@ -0,0 +1,28 @@ +namespace AssociationRegistry.Admin.Api.Infrastructure.Metrics; + +using Marten; +using Marten.Services; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +public class HighWatermarkListener : DocumentSessionListenerBase +{ + private readonly Instrumentation _instrumentation; + + public HighWatermarkListener(Instrumentation instrumentation) + { + _instrumentation = instrumentation; + } + + public override async Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token) + { + var events = commit.GetEvents().ToArray(); + + 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 new file mode 100644 index 000000000..874c8e46a --- /dev/null +++ b/src/AssociationRegistry.Admin.Api/Infrastructure/Metrics/Instrumentation.cs @@ -0,0 +1,38 @@ +namespace AssociationRegistry.Admin.Api.Infrastructure.Metrics; + +using 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 Instrumentation : IInstrumentation, IDisposable +{ + public string ActivitySourceName => "AssociationRegistry"; + public string MeterName => "AdminApi"; + private readonly Meter meter; + + public Instrumentation() + { + var version = typeof(Instrumentation).Assembly.GetName().Version?.ToString(); + ActivitySource = new ActivitySource(ActivitySourceName, version); + meter = new Meter(MeterName, version); + + _highWatermarkGauge = meter.CreateObservableGauge(name: "ar.beheer.p.highWatermark.g", unit: "events", + description: "high watermark", observeValue: () => HighWatermarkEventValue); + } + + public ActivitySource ActivitySource { get; } + private ObservableGauge _highWatermarkGauge; + public long HighWatermarkEventValue = 0; + + public void Dispose() + { + ActivitySource.Dispose(); + meter.Dispose(); + } +} diff --git a/src/AssociationRegistry.Admin.Api/Infrastructure/Middleware/UnexpectedAggregateVersionMiddleware.cs b/src/AssociationRegistry.Admin.Api/Infrastructure/Middleware/UnexpectedAggregateVersionMiddleware.cs index 1351dc64d..11bc9afb9 100644 --- a/src/AssociationRegistry.Admin.Api/Infrastructure/Middleware/UnexpectedAggregateVersionMiddleware.cs +++ b/src/AssociationRegistry.Admin.Api/Infrastructure/Middleware/UnexpectedAggregateVersionMiddleware.cs @@ -1,11 +1,10 @@ namespace AssociationRegistry.Admin.Api.Infrastructure.Middleware; -using Be.Vlaanderen.Basisregisters.AggregateSource; using Be.Vlaanderen.Basisregisters.Api.Exceptions; -using System.Threading.Tasks; using EventStore; using Extensions; using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; public class UnexpectedAggregateVersionMiddleware { diff --git a/src/AssociationRegistry.Admin.Api/Program.cs b/src/AssociationRegistry.Admin.Api/Program.cs index f9cbad68d..0a9949323 100755 --- a/src/AssociationRegistry.Admin.Api/Program.cs +++ b/src/AssociationRegistry.Admin.Api/Program.cs @@ -18,15 +18,14 @@ namespace AssociationRegistry.Admin.Api; using FluentValidation; using Framework; using IdentityModel.AspNetCore.OAuth2Introspection; -using Infrastructure; using Infrastructure.Configuration; using Infrastructure.ConfigurationBindings; using Infrastructure.ExceptionHandlers; using Infrastructure.Extensions; using Infrastructure.Json; +using Infrastructure.Metrics; using Infrastructure.Middleware; using JasperFx.CodeGeneration; -using JasperFx.Core; using Kbo; using Lamar.Microsoft.DependencyInjection; using Magda; @@ -46,7 +45,6 @@ namespace AssociationRegistry.Admin.Api; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -306,7 +304,7 @@ private static void ConfigureServices(WebApplicationBuilder builder) .AddTransient() .AddMarten(postgreSqlOptionsSection, builder.Configuration) .AddElasticSearch(elasticSearchOptionsSection) - .AddOpenTelemetry() + .AddOpenTelemetry(new Instrumentation()) .AddHttpContextAccessor() .AddControllers(options => options.Filters.Add()); 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 581287fa5..f76c8ff44 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.Detail; using Projections.Historiek; @@ -80,6 +81,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 465ec8981..aaf6da913 100644 --- a/src/AssociationRegistry.Admin.ProjectionHost/Program.cs +++ b/src/AssociationRegistry.Admin.ProjectionHost/Program.cs @@ -2,6 +2,7 @@ namespace AssociationRegistry.Admin.ProjectionHost; using Be.Vlaanderen.Basisregisters.Aws.DistributedMutex; using Infrastructure.Json; +using Infrastructure.Metrics; using Infrastructure.Program; using Infrastructure.Program.WebApplication; using Infrastructure.Program.WebApplicationBuilder; @@ -31,7 +32,8 @@ public static async Task Main(string[] args) builder.Configuration .AddJsonFile("appsettings.json") - .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName.ToLowerInvariant()}.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName.ToLowerInvariant()}.json", optional: true, + reloadOnChange: false) .AddJsonFile($"appsettings.{Environment.MachineName.ToLowerInvariant()}.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() .AddCommandLine(args); @@ -60,7 +62,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 4353946d7..5c90c51ac 100644 --- a/src/AssociationRegistry.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs +++ b/src/AssociationRegistry.OpenTelemetry/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ namespace AssociationRegistry.OpenTelemetry.Extensions; -using System.Reflection; using global::OpenTelemetry.Exporter; using global::OpenTelemetry.Logs; using global::OpenTelemetry.Metrics; @@ -10,54 +9,80 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; +using System.Reflection; public static class ServiceCollectionExtensions { 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(); - Action configureResource = r => r - .AddService( - serviceName, - serviceVersion: assemblyVersion, - serviceInstanceId: Environment.MachineName) - .AddAttributes( - new Dictionary - { - ["deployment.environment"] = - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLowerInvariant() - ?? "unknown", - }); - - services.AddOpenTelemetryTracing( - builder => - builder - .AddSource(serviceName) - .ConfigureResource(configureResource).AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation( - options => + return services.AddTracking(configureResource, collectorUrl, serviceName) + .AddLogging(configureResource, collectorUrl) + .AddMetrics(configureResource, collectorUrl); + } + + public static IServiceCollection AddOpenTelemetry(this IServiceCollection services, params T[] instrumentations) + where T : class, IInstrumentation + { + var (serviceName, collectorUrl, configureResource) = GetResources(); + + 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"]); - options.Filter = context => context.Request.Method != HttpMethods.Options; - }) - .AddNpgsql() - .AddOtlpExporter( - options => + foreach (var instrumentation in instrumentations) + { + builder.AddMeter(instrumentation.MeterName); + } + }); + } + + 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( + exporter => { - options.Protocol = OtlpExportProtocol.Grpc; - options.Endpoint = new Uri(collectorUrl); + exporter.Protocol = OtlpExportProtocol.Grpc; + exporter.Endpoint = new Uri(collectorUrl); }) - .AddSource("Wolverine")); + .InvokeIfNotNull(extraConfig); + }); + } + + private static T InvokeIfNotNull(this T obj, Action? method) + { + method?.Invoke(obj); - services.AddLogging( + return obj; + } + + private static IServiceCollection AddLogging( + this IServiceCollection services, + Action configureResource, + string collectorUrl) + { + return services.AddLogging( builder => builder - .AddOpenTelemetry( + .AddOpenTelemetry( options => { options.ConfigureResource(configureResource); @@ -73,21 +98,58 @@ public static IServiceCollection AddOpenTelemetry(this IServiceCollection servic exporter.Endpoint = new Uri(collectorUrl); }); })); + } - services.AddOpenTelemetryMetrics( - options => - options - .ConfigureResource(configureResource) - .AddRuntimeInstrumentation() - .AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation() - .AddOtlpExporter( - exporter => + 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 => { - exporter.Protocol = OtlpExportProtocol.Grpc; - exporter.Endpoint = new Uri(collectorUrl); - })); + options.EnrichWithHttpRequest = + (activity, request) => activity.SetParentId(request.Headers["traceparent"]); + + options.Filter = context => context.Request.Method != HttpMethods.Options; + }) + .AddNpgsql() + .AddOtlpExporter( + options => + { + options.Protocol = OtlpExportProtocol.Grpc; + options.Endpoint = new Uri(collectorUrl); + }) + .AddSource("Wolverine")); + } + + 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.OpenTelemetry/IInstrumentation.cs b/src/AssociationRegistry.OpenTelemetry/IInstrumentation.cs new file mode 100644 index 000000000..1d64aa4b2 --- /dev/null +++ b/src/AssociationRegistry.OpenTelemetry/IInstrumentation.cs @@ -0,0 +1,7 @@ +namespace AssociationRegistry.OpenTelemetry; + +public interface IInstrumentation +{ + public string ActivitySourceName { get; } + public string MeterName { get; } +} 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 b530d04d3..44fa3448e 100644 --- a/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs +++ b/src/AssociationRegistry.Public.ProjectionHost/Infrastructure/Program/WebApplicationBuilder/ConfigureMartenExtensions.cs @@ -9,6 +9,7 @@ namespace AssociationRegistry.Public.ProjectionHost.Infrastructure.Program.WebAp using Marten.Events.Daemon.Resiliency; using Marten.Events.Projections; using Marten.Services; +using Metrics; using Newtonsoft.Json; using Projections.Detail; using Projections.Search; @@ -86,6 +87,8 @@ static JsonNetSerializer CreateCustomMartenSerializer() ProjectionLifecycle.Async, projectionName: "PubliekVerenigingZoekenDocument"); + opts.Projections.AsyncListeners.Add(new ProjectionStateListener(serviceProvider.GetRequiredService())); + opts.Serializer(CreateCustomMartenSerializer()); opts.RegisterDocumentType(); diff --git a/src/AssociationRegistry.Public.ProjectionHost/Program.cs b/src/AssociationRegistry.Public.ProjectionHost/Program.cs index c7986fba7..d69f6f048 100644 --- a/src/AssociationRegistry.Public.ProjectionHost/Program.cs +++ b/src/AssociationRegistry.Public.ProjectionHost/Program.cs @@ -7,6 +7,7 @@ namespace AssociationRegistry.Public.ProjectionHost; 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; @@ -63,7 +64,7 @@ public static async Task Main(string[] args) builder.Services .ConfigureRequestLocalization() - .AddOpenTelemetry() + .AddOpenTelemetry(new PubliekInstrumentation()) .ConfigureProjectionsWithMarten(builder.Configuration) .ConfigureSwagger() .ConfigureElasticSearch(elasticSearchOptions)