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
diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props
index 386cba5cb..d5d412634 100644
--- a/application/Directory.Packages.props
+++ b/application/Directory.Packages.props
@@ -10,9 +10,11 @@
+
+
@@ -68,6 +70,7 @@
+
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/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/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;
}
}
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/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..16cd5e530 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]
@@ -118,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]
@@ -136,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]
@@ -150,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]
@@ -171,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]
@@ -189,6 +204,8 @@ await EnsureErrorStatusCode(
HttpStatusCode.NotFound,
$"Tenant with id '{unknownTenantId}' not found."
);
+
+ AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse();
}
[Fact]
@@ -206,6 +223,8 @@ await EnsureErrorStatusCode(
HttpStatusCode.NotFound,
$"Tenant with id '{unknownTenantId}' not found."
);
+
+ AnalyticEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse();
}
[Fact]
@@ -214,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[]
@@ -221,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]
@@ -230,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}");
@@ -237,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
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/ApiCore/ApiCore.csproj b/application/shared-kernel/ApiCore/ApiCore.csproj
index bbadf6526..122208a0f 100644
--- a/application/shared-kernel/ApiCore/ApiCore.csproj
+++ b/application/shared-kernel/ApiCore/ApiCore.csproj
@@ -20,6 +20,8 @@
+
+
diff --git a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs
index 6e889ae1d..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;
@@ -29,6 +30,15 @@ public static IServiceCollection AddApiCoreServices(this IServiceCollection serv
.AddProblemDetails()
.AddEndpointsApiExplorer();
+ var applicationInsightsServiceOptions = new ApplicationInsightsServiceOptions
+ {
+ EnableRequestTrackingTelemetryModule = false,
+ EnableDependencyTrackingTelemetryModule = false,
+ RequestCollectionOptions = { TrackExceptions = false }
+ };
+
+ services.AddApplicationInsightsTelemetry(applicationInsightsServiceOptions);
+
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "PlatformPlatform API", Version = "v1" });
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;
}
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 @@
+
-
+
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/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();
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
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