From ceb614ad37add7d92b2dcbbf273f77a227947cb5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Nov 2023 13:38:14 +0100 Subject: [PATCH 1/9] Add Azure Monitor OpenTelemetry NuGet package, set up in Shared Kernel with dummy Application Insights connection string for development --- application/Directory.Packages.props | 1 + application/shared-kernel/ApiCore/ApiCore.csproj | 1 + .../ApiCore/Aspire/ServiceDefaultsExtensions.cs | 9 ++++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index 386cba5cb..f7de7fc65 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -68,6 +68,7 @@ + diff --git a/application/shared-kernel/ApiCore/ApiCore.csproj b/application/shared-kernel/ApiCore/ApiCore.csproj index bbadf6526..bcecbbe37 100644 --- a/application/shared-kernel/ApiCore/ApiCore.csproj +++ b/application/shared-kernel/ApiCore/ApiCore.csproj @@ -20,6 +20,7 @@ + diff --git a/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs b/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs index 4dd2adc5b..0fd69643c 100644 --- a/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs +++ b/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs @@ -1,3 +1,4 @@ +using Azure.Monitor.OpenTelemetry.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; @@ -77,9 +78,11 @@ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostAppli builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.Exporter package) - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); + builder.Services.AddOpenTelemetry().UseAzureMonitor(options => + { + options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] ?? + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost"; + }); return builder; } From 633b02c04d3a1f7a0d62f7e2f5452c8ccd62a4bd Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 29 Nov 2023 11:54:06 +0100 Subject: [PATCH 2/9] Add Application Insights client for API and Application layers --- application/Directory.Packages.props | 2 ++ application/shared-kernel/ApiCore/ApiCore.csproj | 1 + application/shared-kernel/ApiCore/ApiCoreConfiguration.cs | 3 ++- .../shared-kernel/ApplicationCore/ApplicationCore.csproj | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index f7de7fc65..d5d412634 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -10,9 +10,11 @@ + + diff --git a/application/shared-kernel/ApiCore/ApiCore.csproj b/application/shared-kernel/ApiCore/ApiCore.csproj index bcecbbe37..122208a0f 100644 --- a/application/shared-kernel/ApiCore/ApiCore.csproj +++ b/application/shared-kernel/ApiCore/ApiCore.csproj @@ -21,6 +21,7 @@ + diff --git a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs index 6e889ae1d..a5b77c74b 100644 --- a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs +++ b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs @@ -27,7 +27,8 @@ public static IServiceCollection AddApiCoreServices(this IServiceCollection serv .AddExceptionHandler() .AddTransient() .AddProblemDetails() - .AddEndpointsApiExplorer(); + .AddEndpointsApiExplorer() + .AddApplicationInsightsTelemetry(); services.AddSwaggerGen(c => { diff --git a/application/shared-kernel/ApplicationCore/ApplicationCore.csproj b/application/shared-kernel/ApplicationCore/ApplicationCore.csproj index 4fae21568..ebf0da208 100644 --- a/application/shared-kernel/ApplicationCore/ApplicationCore.csproj +++ b/application/shared-kernel/ApplicationCore/ApplicationCore.csproj @@ -13,9 +13,10 @@ + - + From d2c0380cd905aca494303ccc448380508ba20f88 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 30 Nov 2023 23:23:40 +0100 Subject: [PATCH 3/9] Create analytic events collector and introduce MediatR pipeline for publishing on success --- .../ApplicationCoreConfiguration.cs | 7 ++-- .../PublishAnalyticEventsPipelineBehavior.cs | 29 +++++++++++++++ .../Tracking/AnalyticEventCollector.cs | 35 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 application/shared-kernel/ApplicationCore/Behaviors/PublishAnalyticEventsPipelineBehavior.cs create mode 100644 application/shared-kernel/ApplicationCore/Tracking/AnalyticEventCollector.cs diff --git a/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs b/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs index 1c417322e..362657dcb 100644 --- a/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs +++ b/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.Extensions.DependencyInjection; using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; namespace PlatformPlatform.SharedKernel.ApplicationCore; @@ -12,11 +13,13 @@ public static IServiceCollection AddApplicationCoreServices( Assembly applicationAssembly ) { - // Order is important. First all Pre behaviors run (top to bottom), then the command is handled, then all Post - // behaviors run (bottom to top). So Validation -> Command -> PublishDomainEvents -> UnitOfWork. + // Order is important! First all Pre behaviors run, then the command is handled, then all Post behaviors run. + // So Validation -> Command -> PublishDomainEvents -> UnitOfWork -> PublishAnalyticEvents. services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)); // Pre + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishAnalyticEventsPipelineBehavior<,>)); // Post services.AddTransient(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkPipelineBehavior<,>)); // Post services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishDomainEventsPipelineBehavior<,>)); // Post + services.AddScoped(); services.AddScoped(); services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(applicationAssembly)); diff --git a/application/shared-kernel/ApplicationCore/Behaviors/PublishAnalyticEventsPipelineBehavior.cs b/application/shared-kernel/ApplicationCore/Behaviors/PublishAnalyticEventsPipelineBehavior.cs new file mode 100644 index 000000000..d49d54518 --- /dev/null +++ b/application/shared-kernel/ApplicationCore/Behaviors/PublishAnalyticEventsPipelineBehavior.cs @@ -0,0 +1,29 @@ +using Microsoft.ApplicationInsights; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; + +public sealed class PublishAnalyticEventsPipelineBehavior( + IAnalyticEventsCollector analyticEventsCollector, + TelemetryClient telemetryClient +) + : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + var result = await next(); + + while (analyticEventsCollector.HasEvents) + { + var analyticEvent = analyticEventsCollector.Dequeue(); + telemetryClient.TrackEvent(analyticEvent.Name, analyticEvent.Properties); + } + + return result; + } +} \ No newline at end of file diff --git a/application/shared-kernel/ApplicationCore/Tracking/AnalyticEventCollector.cs b/application/shared-kernel/ApplicationCore/Tracking/AnalyticEventCollector.cs new file mode 100644 index 000000000..777c56502 --- /dev/null +++ b/application/shared-kernel/ApplicationCore/Tracking/AnalyticEventCollector.cs @@ -0,0 +1,35 @@ +namespace PlatformPlatform.SharedKernel.ApplicationCore.Tracking; + +public interface IAnalyticEventsCollector +{ + bool HasEvents { get; } + + void CollectEvent(string name, Dictionary? properties = null); + + AnalyticEvent Dequeue(); +} + +public class AnalyticEventsCollector : IAnalyticEventsCollector +{ + private readonly Queue _events = new(); + + public bool HasEvents => _events.Count > 0; + + public void CollectEvent(string name, Dictionary? properties = null) + { + var analyticEvent = new AnalyticEvent(name, properties); + _events.Enqueue(analyticEvent); + } + + public AnalyticEvent Dequeue() + { + return _events.Dequeue(); + } +} + +public class AnalyticEvent(string name, Dictionary? properties = null) +{ + public string Name { get; } = name; + + public Dictionary? Properties { get; } = properties; +} \ No newline at end of file From 56e44659c8f73036c37e550af27d017f5c27f6c9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 1 Dec 2023 16:04:37 +0100 Subject: [PATCH 4/9] Track analytic events for TenantCreated and UserCreated --- .../Application/Tenants/CreateTenant.cs | 16 +++++++++++++++- .../Application/Users/CreateUser.cs | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/application/account-management/Application/Tenants/CreateTenant.cs b/application/account-management/Application/Tenants/CreateTenant.cs index c8c799074..9280ebc0f 100644 --- a/application/account-management/Application/Tenants/CreateTenant.cs +++ b/application/account-management/Application/Tenants/CreateTenant.cs @@ -1,6 +1,7 @@ using FluentValidation; using PlatformPlatform.AccountManagement.Application.Users; using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; using PlatformPlatform.SharedKernel.ApplicationCore.Validation; namespace PlatformPlatform.AccountManagement.Application.Tenants; @@ -9,15 +10,28 @@ public sealed record CreateTenantCommand(string Subdomain, string Name, string? : ICommand, ITenantValidation, IRequest>; [UsedImplicitly] -public sealed class CreateTenantHandler(ITenantRepository tenantRepository, ISender mediator) +public sealed class CreateTenantHandler( + ITenantRepository tenantRepository, + IAnalyticEventsCollector analyticEventsCollector, + ISender mediator +) : IRequestHandler> { public async Task> Handle(CreateTenantCommand command, CancellationToken cancellationToken) { var tenant = Tenant.Create(command.Subdomain, command.Name, command.Phone); await tenantRepository.AddAsync(tenant, cancellationToken); + analyticEventsCollector.CollectEvent( + "TenantCreated", + new Dictionary + { + { "Tenant_Id", tenant.Id.ToString() }, + { "Event_TenantState", tenant.State.ToString() } + } + ); await CreateTenantOwnerAsync(tenant.Id, command.Email, cancellationToken); + return tenant.Id; } diff --git a/application/account-management/Application/Users/CreateUser.cs b/application/account-management/Application/Users/CreateUser.cs index 0764a9dc6..ccd2678e7 100644 --- a/application/account-management/Application/Users/CreateUser.cs +++ b/application/account-management/Application/Users/CreateUser.cs @@ -1,5 +1,6 @@ using FluentValidation; using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Application.Users; @@ -7,13 +8,19 @@ public sealed record CreateUserCommand(TenantId TenantId, string Email, UserRole : ICommand, IUserValidation, IRequest>; [UsedImplicitly] -public sealed class CreateUserHandler(IUserRepository userRepository) +public sealed class CreateUserHandler(IUserRepository userRepository, IAnalyticEventsCollector analyticEventsCollector) : IRequestHandler> { public async Task> Handle(CreateUserCommand command, CancellationToken cancellationToken) { var user = User.Create(command.TenantId, command.Email, command.UserRole); await userRepository.AddAsync(user, cancellationToken); + + analyticEventsCollector.CollectEvent( + "UserCreated", + new Dictionary { { "Tenant_Id", command.TenantId.ToString() } } + ); + return user.Id; } } From c7736c97e12be9fff68d27ae59bff31f490651f2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 1 Dec 2023 16:35:42 +0100 Subject: [PATCH 5/9] Create tests for precise analytic event tracking, use a spy test double for observation --- .../Tests/Api/BaseApiTest.cs | 5 +++ .../Tests/Api/Tenants/TenantEndpointsTests.cs | 5 +++ .../Tenants/CreateTenantValidationTests.cs | 13 +++++++ .../account-management/Tests/BaseTest.cs | 18 +++++++++ .../account-management/Tests/Tests.csproj | 1 + .../Tracking/AnalyticEventsCollectorSpy.cs | 38 +++++++++++++++++++ 6 files changed, 80 insertions(+) create mode 100644 application/shared-kernel/Tests/ApplicationCore/Tracking/AnalyticEventsCollectorSpy.cs diff --git a/application/account-management/Tests/Api/BaseApiTest.cs b/application/account-management/Tests/Api/BaseApiTest.cs index 97c110bfe..2d45127ca 100644 --- a/application/account-management/Tests/Api/BaseApiTest.cs +++ b/application/account-management/Tests/Api/BaseApiTest.cs @@ -8,7 +8,9 @@ using Microsoft.Extensions.DependencyInjection; using PlatformPlatform.SharedKernel.ApiCore.ApiResults; using PlatformPlatform.SharedKernel.ApiCore.Middleware; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; using PlatformPlatform.SharedKernel.ApplicationCore.Validation; +using PlatformPlatform.SharedKernel.Tests.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Tests.Api; @@ -28,6 +30,9 @@ protected BaseApiTests() // Replace the default DbContext in the WebApplication to use an in-memory SQLite database services.Remove(services.Single(d => d.ServiceType == typeof(DbContextOptions))); services.AddDbContext(options => { options.UseSqlite(Connection); }); + + AnalyticEventsCollectorSpy = new AnalyticEventsCollectorSpy(new AnalyticEventsCollector()); + services.AddScoped(_ => AnalyticEventsCollectorSpy); }); }); diff --git a/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs b/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs index 4c4bc2abf..b4a5db217 100644 --- a/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs +++ b/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs @@ -93,6 +93,11 @@ public async Task CreateTenant_WhenValid_ShouldCreateTenantAndOwnerUser() await EnsureSuccessPostRequest(response, $"/api/tenants/{subdomain}"); Connection.RowExists("Tenants", subdomain); Connection.ExecuteScalar("SELECT COUNT(*) FROM Users WHERE Email = @email", new { email }).Should().Be(1); + + AnalyticEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + AnalyticEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantCreated").Should().Be(1); + AnalyticEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "UserCreated").Should().Be(1); + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] diff --git a/application/account-management/Tests/Application/Tenants/CreateTenantValidationTests.cs b/application/account-management/Tests/Application/Tenants/CreateTenantValidationTests.cs index d986d3458..b3d51b103 100644 --- a/application/account-management/Tests/Application/Tenants/CreateTenantValidationTests.cs +++ b/application/account-management/Tests/Application/Tenants/CreateTenantValidationTests.cs @@ -30,6 +30,19 @@ string email // Assert result.IsSuccess.Should().BeTrue(scenario); result.Errors.Should().BeNull(scenario); + + AnalyticEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + + AnalyticEventsCollectorSpy.CollectedEvents.Count(e => + e.Name == "TenantCreated" && + e.Properties!["Tenant_Id"] == subdomain && + e.Properties["Event_TenantState"] == "Trial" + ).Should().Be(1); + + AnalyticEventsCollectorSpy.CollectedEvents.Count(e => + e.Name == "UserCreated" && + e.Properties!["Tenant_Id"] == subdomain + ).Should().Be(1); } [Theory] diff --git a/application/account-management/Tests/BaseTest.cs b/application/account-management/Tests/BaseTest.cs index 44026eff0..4f12fa93b 100644 --- a/application/account-management/Tests/BaseTest.cs +++ b/application/account-management/Tests/BaseTest.cs @@ -1,10 +1,16 @@ using Bogus; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; using PlatformPlatform.AccountManagement.Application; using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; +using PlatformPlatform.SharedKernel.Tests.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Tests; @@ -13,9 +19,15 @@ public abstract class BaseTest : IDisposable where TContext : DbContex protected readonly Faker Faker = new(); protected readonly ServiceCollection Services; private ServiceProvider? _provider; + protected AnalyticEventsCollectorSpy AnalyticEventsCollectorSpy; protected BaseTest() { + Environment.SetEnvironmentVariable( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" + ); + Services = new ServiceCollection(); Services.AddLogging(); @@ -31,6 +43,12 @@ protected BaseTest() .AddApplicationServices() .AddInfrastructureServices(configuration); + AnalyticEventsCollectorSpy = new AnalyticEventsCollectorSpy(new AnalyticEventsCollector()); + Services.AddScoped(_ => AnalyticEventsCollectorSpy); + + var telemetryChannel = Substitute.For(); + Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); + // Make sure database is created using var serviceScope = Services.BuildServiceProvider().CreateScope(); serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); diff --git a/application/account-management/Tests/Tests.csproj b/application/account-management/Tests/Tests.csproj index 5becac161..474219054 100644 --- a/application/account-management/Tests/Tests.csproj +++ b/application/account-management/Tests/Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/application/shared-kernel/Tests/ApplicationCore/Tracking/AnalyticEventsCollectorSpy.cs b/application/shared-kernel/Tests/ApplicationCore/Tracking/AnalyticEventsCollectorSpy.cs new file mode 100644 index 000000000..44153acc2 --- /dev/null +++ b/application/shared-kernel/Tests/ApplicationCore/Tracking/AnalyticEventsCollectorSpy.cs @@ -0,0 +1,38 @@ +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; + +namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.Tracking; + +public class AnalyticEventsCollectorSpy(AnalyticEventsCollector realAnalyticEventsCollector) : IAnalyticEventsCollector +{ + private readonly List _collectedEvents = new(); + + public IReadOnlyList CollectedEvents => _collectedEvents; + + public bool AreAllEventsDispatched { get; private set; } + + public void CollectEvent(string name, Dictionary? properties = null) + { + realAnalyticEventsCollector.CollectEvent(name, properties); + _collectedEvents.Add(new AnalyticEvent(name, properties)); + } + + public bool HasEvents => realAnalyticEventsCollector.HasEvents; + + public AnalyticEvent Dequeue() + { + var analyticEvent = realAnalyticEventsCollector.Dequeue(); + AreAllEventsDispatched = !realAnalyticEventsCollector.HasEvents; + return analyticEvent; + } + + public void Reset() + { + while (realAnalyticEventsCollector.HasEvents) + { + realAnalyticEventsCollector.Dequeue(); + } + + _collectedEvents.Clear(); + AreAllEventsDispatched = false; + } +} \ No newline at end of file From 004ab3fd1bd215e927db764dedae89a1895819ba Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 3 Dec 2023 16:54:32 +0100 Subject: [PATCH 6/9] Invert If condition in ValidationPipelineBehavior to centralize next() call to one location --- .../Behaviors/ValidationPipelineBehavior.cs | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs b/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs index 583e0657f..715d4a0a3 100644 --- a/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs +++ b/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs @@ -19,28 +19,26 @@ public async Task Handle( CancellationToken cancellationToken ) { - if (!validators.Any()) + if (validators.Any()) { - return await next(); - } - - var context = new ValidationContext(request); - - // Run all validators in parallel and await the results - var validationResults = - await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); - - // Aggregate the results from all validators into a distinct list of errorDetails - var errorDetails = validationResults - .SelectMany(result => result.Errors) - .Where(failure => failure != null) - .Select(failure => new ErrorDetail(failure.PropertyName.Split('.').First(), failure.ErrorMessage)) - .Distinct() - .ToArray(); - - if (errorDetails.Any()) - { - return CreateValidationResult(errorDetails); + var context = new ValidationContext(request); + + // Run all validators in parallel and await the results + var validationResults = + await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + // Aggregate the results from all validators into a distinct list of errorDetails + var errorDetails = validationResults + .SelectMany(result => result.Errors) + .Where(failure => failure != null) + .Select(failure => new ErrorDetail(failure.PropertyName.Split('.')[0], failure.ErrorMessage)) + .Distinct() + .ToArray(); + + if (errorDetails.Any()) + { + return CreateValidationResult(errorDetails); + } } return await next(); From dc73d004f502955eaba854c8b00cfda52fa198bd Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 4 Dec 2023 23:35:55 +0100 Subject: [PATCH 7/9] Disable Requests, Dependencies, and Exception tracking in Microsoft.ApplicationInsights, already covered by OpenTelemetry --- .../shared-kernel/ApiCore/ApiCoreConfiguration.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs index a5b77c74b..2c886ac51 100644 --- a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs +++ b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.ApplicationInsights.AspNetCore.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Json; @@ -27,8 +28,16 @@ public static IServiceCollection AddApiCoreServices(this IServiceCollection serv .AddExceptionHandler() .AddTransient() .AddProblemDetails() - .AddEndpointsApiExplorer() - .AddApplicationInsightsTelemetry(); + .AddEndpointsApiExplorer(); + + var applicationInsightsServiceOptions = new ApplicationInsightsServiceOptions + { + EnableRequestTrackingTelemetryModule = false, + EnableDependencyTrackingTelemetryModule = false, + RequestCollectionOptions = { TrackExceptions = false } + }; + + services.AddApplicationInsightsTelemetry(applicationInsightsServiceOptions); services.AddSwaggerGen(c => { From 827e98e6b84d0e4070c978c34c782b838b47627d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 5 Dec 2023 22:20:28 +0100 Subject: [PATCH 8/9] Add analytics tracking for UpdateTenant, DeleteTenant, UpdateUser, and DeleteTenant --- .../Application/Tenants/DeleteTenant.cs | 16 +++++++++++++- .../Application/Tenants/UpdateTenant.cs | 12 +++++++++- .../Application/Users/DeleteUser.cs | 8 ++++++- .../Application/Users/UpdateUser.cs | 8 ++++++- .../Tests/Api/Tenants/TenantEndpointsTests.cs | 22 +++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/application/account-management/Application/Tenants/DeleteTenant.cs b/application/account-management/Application/Tenants/DeleteTenant.cs index 3bf05f095..5f139daa7 100644 --- a/application/account-management/Application/Tenants/DeleteTenant.cs +++ b/application/account-management/Application/Tenants/DeleteTenant.cs @@ -1,12 +1,16 @@ using FluentValidation; using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Application.Tenants; public sealed record DeleteTenantCommand(TenantId Id) : ICommand, IRequest; [UsedImplicitly] -public sealed class DeleteTenantHandler(ITenantRepository tenantRepository) +public sealed class DeleteTenantHandler( + ITenantRepository tenantRepository, + IAnalyticEventsCollector analyticEventsCollector +) : IRequestHandler { public async Task Handle(DeleteTenantCommand command, CancellationToken cancellationToken) @@ -15,6 +19,16 @@ public async Task Handle(DeleteTenantCommand command, CancellationToken if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found."); tenantRepository.Remove(tenant); + + analyticEventsCollector.CollectEvent( + "TenantDeleted", + new Dictionary + { + { "Tenant_Id", tenant.Id.ToString() }, + { "Event_TenantState", tenant.State.ToString() } + } + ); + return Result.Success(); } } diff --git a/application/account-management/Application/Tenants/UpdateTenant.cs b/application/account-management/Application/Tenants/UpdateTenant.cs index 27a211f60..e871c7f20 100644 --- a/application/account-management/Application/Tenants/UpdateTenant.cs +++ b/application/account-management/Application/Tenants/UpdateTenant.cs @@ -1,4 +1,5 @@ using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Application.Tenants; @@ -13,7 +14,10 @@ public sealed record UpdateTenantCommand : ICommand, ITenantValidation, IRequest } [UsedImplicitly] -public sealed class UpdateTenantHandler(ITenantRepository tenantRepository) +public sealed class UpdateTenantHandler( + ITenantRepository tenantRepository, + IAnalyticEventsCollector analyticEventsCollector +) : IRequestHandler { public async Task Handle(UpdateTenantCommand command, CancellationToken cancellationToken) @@ -23,6 +27,12 @@ public async Task Handle(UpdateTenantCommand command, CancellationToken tenant.Update(command.Name, command.Phone); tenantRepository.Update(tenant); + + analyticEventsCollector.CollectEvent( + "TenantUpdated", + new Dictionary { { "Tenant_Id", command.Id.ToString() } } + ); + return Result.Success(); } } diff --git a/application/account-management/Application/Users/DeleteUser.cs b/application/account-management/Application/Users/DeleteUser.cs index 39ca4999b..25cde448b 100644 --- a/application/account-management/Application/Users/DeleteUser.cs +++ b/application/account-management/Application/Users/DeleteUser.cs @@ -1,11 +1,15 @@ using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Application.Users; public sealed record DeleteUserCommand(UserId Id) : ICommand, IRequest; [UsedImplicitly] -public sealed class DeleteUserHandler(IUserRepository userRepository) : IRequestHandler +public sealed class DeleteUserHandler( + IUserRepository userRepository, + IAnalyticEventsCollector analyticEventsCollector +) : IRequestHandler { public async Task Handle(DeleteUserCommand command, CancellationToken cancellationToken) { @@ -13,6 +17,8 @@ public async Task Handle(DeleteUserCommand command, CancellationToken ca if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); userRepository.Remove(user); + + analyticEventsCollector.CollectEvent("UserDeleted"); return Result.Success(); } } \ No newline at end of file diff --git a/application/account-management/Application/Users/UpdateUser.cs b/application/account-management/Application/Users/UpdateUser.cs index bfb178ec5..fb4d16010 100644 --- a/application/account-management/Application/Users/UpdateUser.cs +++ b/application/account-management/Application/Users/UpdateUser.cs @@ -1,4 +1,5 @@ using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Tracking; namespace PlatformPlatform.AccountManagement.Application.Users; @@ -13,7 +14,10 @@ public sealed record UpdateUserCommand : ICommand, IUserValidation, IRequest +public sealed class UpdateUserHandler( + IUserRepository userRepository, + IAnalyticEventsCollector analyticEventsCollector +) : IRequestHandler { public async Task Handle(UpdateUserCommand command, CancellationToken cancellationToken) { @@ -22,6 +26,8 @@ public async Task Handle(UpdateUserCommand command, CancellationToken ca user.Update(command.Email, command.UserRole); userRepository.Update(user); + + analyticEventsCollector.CollectEvent("UserUpdated"); return Result.Success(); } } diff --git a/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs b/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs index b4a5db217..16cd5e530 100644 --- a/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs +++ b/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs @@ -123,6 +123,8 @@ public async Task CreateTenant_WhenInvalid_ShouldReturnBadRequest() new ErrorDetail("Email", "Email must be in a valid format and no longer than 100 characters.") }; await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } [Fact] @@ -141,6 +143,8 @@ public async Task CreateTenant_WhenTenantExists_ShouldReturnBadRequest() new ErrorDetail("Subdomain", "The subdomain is not available.") }; await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } [Fact] @@ -155,6 +159,10 @@ public async Task UpdateTenant_WhenValid_ShouldUpdateTenant() // Assert EnsureSuccessWithEmptyHeaderAndLocation(response); + + AnalyticEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + AnalyticEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantUpdated").Should().Be(1); + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] @@ -176,6 +184,8 @@ public async Task UpdateTenant_WhenInvalid_ShouldReturnBadRequest() new ErrorDetail("Phone", "Phone must be in a valid format and no longer than 20 characters.") }; await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } [Fact] @@ -194,6 +204,8 @@ await EnsureErrorStatusCode( HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found." ); + + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } [Fact] @@ -211,6 +223,8 @@ await EnsureErrorStatusCode( HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found." ); + + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } [Fact] @@ -219,6 +233,7 @@ public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest() // Act var existingTenantId = DatabaseSeeder.Tenant1.Id; var response = await TestHttpClient.DeleteAsync($"/api/tenants/{existingTenantId}"); + AnalyticEventsCollectorSpy.Reset(); // Assert var expectedErrors = new[] @@ -226,6 +241,8 @@ public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest() new ErrorDetail("Id", "All users must be deleted before the tenant can be deleted.") }; await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } [Fact] @@ -235,6 +252,7 @@ public async Task DeleteTenant_WhenTenantHasNoUsers_ShouldDeleteTenant() var existingTenantId = DatabaseSeeder.Tenant1.Id; var existingUserId = DatabaseSeeder.User1.Id; _ = await TestHttpClient.DeleteAsync($"/api/users/{existingUserId}"); + AnalyticEventsCollectorSpy.Reset(); // Act var response = await TestHttpClient.DeleteAsync($"/api/tenants/{existingTenantId}"); @@ -242,5 +260,9 @@ public async Task DeleteTenant_WhenTenantHasNoUsers_ShouldDeleteTenant() // Assert EnsureSuccessWithEmptyHeaderAndLocation(response); Connection.RowExists("Tenants", existingTenantId).Should().BeFalse(); + + AnalyticEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + AnalyticEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantDeleted").Should().Be(1); + AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } } \ No newline at end of file From c65335432054348f1a72f5c640e618293ce6cbdc Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 5 Dec 2023 23:26:27 +0100 Subject: [PATCH 9/9] Change Version generator to yyyy.m.d.HMM format, single-digit month, day, hour to prevent prefix 0 failure (e.g., 2024.4.5.830) --- .github/workflows/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/application.yml b/.github/workflows/application.yml index 0c62ec206..9cfdcbb48 100644 --- a/.github/workflows/application.yml +++ b/.github/workflows/application.yml @@ -32,7 +32,7 @@ jobs: - name: Generate version id: generate_version run: | - VERSION=$(date +"%Y.%m.%d").$GITHUB_RUN_NUMBER + VERSION=$(date +"%Y.%-m.%-d.%-H%M") echo "Generated version: $VERSION" echo "version=$VERSION" >> $GITHUB_OUTPUT