Skip to content

Commit

Permalink
Add telemetry with Application Insights and OpenTelemetry (#247)
Browse files Browse the repository at this point in the history
### Summary & Motivation

Configure Application Insights to track requests, exceptions,
dependencies, traces, and custom events. To align with Application
Insights' shift towards OpenTelemetry, the OpenTelemetry SDK is utilized
for collecting all supported telemetry types (requests, exceptions,
dependencies, and traces). However, for custom events, the traditional
Microsoft.ApplicationInsights SDK is employed.

Custom events, termed "analytics events" to distinguish them from DDD
Domain and Integration Events, are crucial for tracking specific feature
usages for business insights. These events are not to be tracked until
after an operation is successfully completed. For instance,
`TenantCreated` and `UserCreated` events should only be logged in
Application Insights once these entities are securely saved to the
Database.

To coordinate this, a new `AnalyticsEventCollector` class is introduced
to aggregate all events. Additionally, a MediatR pipeline is implemented
to send these events to Application Insights *after* the UnitOfWork
completes. If the UnitOfWork fails, no events are tracked. While there's
a theoretical possibility of tracking failure, this risk is considered
acceptable.

All CQRS commands have been extended to track analytics events. Events
are named in past tense like `TenantCreated`, `TenantUpdated`,
`TenantDeleted`, `UserCreated`, `UserUpdated`, and `UserDeleted`.

For testing, an `AnalyticEventsCollectorSpy` test double class has been
created, and assertions have been added to all tests to ensure that the
correct events are tracked. For the few Application layer tests, the
assertions are also verifying that the correct event properties are
tracked (e.g., `Tenant_Id` and `Event_TenantState`).

Change Version generator to yyyy.m.d.HMM format using hour and minutes
instead of GitHub run number. This fixes a problem with the application
workflow failing when day and month was below 10 and had invalid 0
prefix. E.g `2024.4.5.830` instead of `2024.04.05.0830`.

Change the Version generator to use the `yyyy.m.d.HMM` format (e.g.
`2023.12.5.932`), incorporating hours and minutes instead of the GitHub
run number. This change addresses an issue where the application
workflow would fail due to an invalid prefix in dates with days and
months below 10.

### Checklist

- [x] I have added a Label to the pull-request
- [x] I have added tests, and done manual regression tests
- [x] I have updated the documentation, if necessary
tjementum authored Dec 5, 2023
2 parents acc0f01 + c653354 commit f057193
Showing 22 changed files with 277 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/application.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions application/Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -10,9 +10,11 @@
</PropertyGroup>
<ItemGroup>
<!-- PlatformPlatform dependencies - Api -->
<PackageVersion Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.21.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.ApiExplorer" Version="2.2.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(EfCoreVersion)" />
<!-- PlatformPlatform dependencies - Application -->
<PackageVersion Include="Microsoft.ApplicationInsights" Version="2.21.0" />
<PackageVersion Include="Mapster" Version="7.4.0" />
<PackageVersion Include="MediatR" Version="12.2.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.8.1" />
@@ -68,6 +70,7 @@
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(RuntimeVersion)" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(RuntimeVersion)" />
<!-- Open Telemetry -->
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.0.0-beta.8" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.7.0-alpha.1" />
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.7.0-alpha.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.7.0-alpha.1" />
Original file line number Diff line number Diff line change
@@ -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<Result<TenantId>>;

[UsedImplicitly]
public sealed class CreateTenantHandler(ITenantRepository tenantRepository, ISender mediator)
public sealed class CreateTenantHandler(
ITenantRepository tenantRepository,
IAnalyticEventsCollector analyticEventsCollector,
ISender mediator
)
: IRequestHandler<CreateTenantCommand, Result<TenantId>>
{
public async Task<Result<TenantId>> 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<string, string>
{
{ "Tenant_Id", tenant.Id.ToString() },
{ "Event_TenantState", tenant.State.ToString() }
}
);

await CreateTenantOwnerAsync(tenant.Id, command.Email, cancellationToken);

return tenant.Id;
}

Original file line number Diff line number Diff line change
@@ -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<Result>;

[UsedImplicitly]
public sealed class DeleteTenantHandler(ITenantRepository tenantRepository)
public sealed class DeleteTenantHandler(
ITenantRepository tenantRepository,
IAnalyticEventsCollector analyticEventsCollector
)
: IRequestHandler<DeleteTenantCommand, Result>
{
public async Task<Result> Handle(DeleteTenantCommand command, CancellationToken cancellationToken)
@@ -15,6 +19,16 @@ public async Task<Result> 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<string, string>
{
{ "Tenant_Id", tenant.Id.ToString() },
{ "Event_TenantState", tenant.State.ToString() }
}
);

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -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<UpdateTenantCommand, Result>
{
public async Task<Result> Handle(UpdateTenantCommand command, CancellationToken cancellationToken)
@@ -23,6 +27,12 @@ public async Task<Result> Handle(UpdateTenantCommand command, CancellationToken

tenant.Update(command.Name, command.Phone);
tenantRepository.Update(tenant);

analyticEventsCollector.CollectEvent(
"TenantUpdated",
new Dictionary<string, string> { { "Tenant_Id", command.Id.ToString() } }
);

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
using FluentValidation;
using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs;
using PlatformPlatform.SharedKernel.ApplicationCore.Tracking;

namespace PlatformPlatform.AccountManagement.Application.Users;

public sealed record CreateUserCommand(TenantId TenantId, string Email, UserRole UserRole)
: ICommand, IUserValidation, IRequest<Result<UserId>>;

[UsedImplicitly]
public sealed class CreateUserHandler(IUserRepository userRepository)
public sealed class CreateUserHandler(IUserRepository userRepository, IAnalyticEventsCollector analyticEventsCollector)
: IRequestHandler<CreateUserCommand, Result<UserId>>
{
public async Task<Result<UserId>> 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<string, string> { { "Tenant_Id", command.TenantId.ToString() } }
);

return user.Id;
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs;
using PlatformPlatform.SharedKernel.ApplicationCore.Tracking;

namespace PlatformPlatform.AccountManagement.Application.Users;

public sealed record DeleteUserCommand(UserId Id) : ICommand, IRequest<Result>;

[UsedImplicitly]
public sealed class DeleteUserHandler(IUserRepository userRepository) : IRequestHandler<DeleteUserCommand, Result>
public sealed class DeleteUserHandler(
IUserRepository userRepository,
IAnalyticEventsCollector analyticEventsCollector
) : IRequestHandler<DeleteUserCommand, Result>
{
public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(command.Id, cancellationToken);
if (user is null) return Result.NotFound($"User with id '{command.Id}' not found.");

userRepository.Remove(user);

analyticEventsCollector.CollectEvent("UserDeleted");
return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -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<Res
}

[UsedImplicitly]
public sealed class UpdateUserHandler(IUserRepository userRepository) : IRequestHandler<UpdateUserCommand, Result>
public sealed class UpdateUserHandler(
IUserRepository userRepository,
IAnalyticEventsCollector analyticEventsCollector
) : IRequestHandler<UpdateUserCommand, Result>
{
public async Task<Result> Handle(UpdateUserCommand command, CancellationToken cancellationToken)
{
@@ -22,6 +26,8 @@ public async Task<Result> Handle(UpdateUserCommand command, CancellationToken ca

user.Update(command.Email, command.UserRole);
userRepository.Update(user);

analyticEventsCollector.CollectEvent("UserUpdated");
return Result.Success();
}
}
5 changes: 5 additions & 0 deletions application/account-management/Tests/Api/BaseApiTest.cs
Original file line number Diff line number Diff line change
@@ -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<TContext>)));
services.AddDbContext<TContext>(options => { options.UseSqlite(Connection); });

AnalyticEventsCollectorSpy = new AnalyticEventsCollectorSpy(new AnalyticEventsCollector());
services.AddScoped<IAnalyticEventsCollector>(_ => AnalyticEventsCollectorSpy);
});
});

Original file line number Diff line number Diff line change
@@ -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,13 +233,16 @@ 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[]
{
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,12 +252,17 @@ 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}");

// 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();
}
}
Original file line number Diff line number Diff line change
@@ -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]
18 changes: 18 additions & 0 deletions application/account-management/Tests/BaseTest.cs
Original file line number Diff line number Diff line change
@@ -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<TContext> : 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<IAnalyticEventsCollector>(_ => AnalyticEventsCollectorSpy);

var telemetryChannel = Substitute.For<ITelemetryChannel>();
Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel }));

// Make sure database is created
using var serviceScope = Services.BuildServiceProvider().CreateScope();
serviceScope.ServiceProvider.GetRequiredService<TContext>().Database.EnsureCreated();
1 change: 1 addition & 0 deletions application/account-management/Tests/Tests.csproj
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@
<ProjectReference Include="..\Application\Application.csproj"/>
<ProjectReference Include="..\Domain\Domain.csproj"/>
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj"/>
<ProjectReference Include="..\..\shared-kernel\Tests\Tests.csproj" />
</ItemGroup>

<ItemGroup>
2 changes: 2 additions & 0 deletions application/shared-kernel/ApiCore/ApiCore.csproj
Original file line number Diff line number Diff line change
@@ -20,6 +20,8 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.Monitor.OpenTelemetry.AspNetCore" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.ApiExplorer" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
10 changes: 10 additions & 0 deletions application/shared-kernel/ApiCore/ApiCoreConfiguration.cs
Original file line number Diff line number Diff line change
@@ -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" });
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -13,9 +13,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Mapster"/>
<PackageReference Include="MediatR" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Microsoft.ApplicationInsights" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<IAnalyticEventsCollector, AnalyticEventsCollector>();
services.AddScoped<UnitOfWorkPipelineBehaviorConcurrentCounter>();

services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(applicationAssembly));
Original file line number Diff line number Diff line change
@@ -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<TRequest, TResponse>(
IAnalyticEventsCollector analyticEventsCollector,
TelemetryClient telemetryClient
)
: IPipelineBehavior<TRequest, TResponse> where TRequest : ICommand where TResponse : ResultBase
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var result = await next();

while (analyticEventsCollector.HasEvents)
{
var analyticEvent = analyticEventsCollector.Dequeue();
telemetryClient.TrackEvent(analyticEvent.Name, analyticEvent.Properties);
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -19,28 +19,26 @@ public async Task<TResponse> Handle(
CancellationToken cancellationToken
)
{
if (!validators.Any())
if (validators.Any())
{
return await next();
}

var context = new ValidationContext<TRequest>(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<TResponse>(errorDetails);
var context = new ValidationContext<TRequest>(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<TResponse>(errorDetails);
}
}

return await next();
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace PlatformPlatform.SharedKernel.ApplicationCore.Tracking;

public interface IAnalyticEventsCollector
{
bool HasEvents { get; }

void CollectEvent(string name, Dictionary<string, string>? properties = null);

AnalyticEvent Dequeue();
}

public class AnalyticEventsCollector : IAnalyticEventsCollector
{
private readonly Queue<AnalyticEvent> _events = new();

public bool HasEvents => _events.Count > 0;

public void CollectEvent(string name, Dictionary<string, string>? properties = null)
{
var analyticEvent = new AnalyticEvent(name, properties);
_events.Enqueue(analyticEvent);
}

public AnalyticEvent Dequeue()
{
return _events.Dequeue();
}
}

public class AnalyticEvent(string name, Dictionary<string, string>? properties = null)
{
public string Name { get; } = name;

public Dictionary<string, string>? Properties { get; } = properties;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using PlatformPlatform.SharedKernel.ApplicationCore.Tracking;

namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.Tracking;

public class AnalyticEventsCollectorSpy(AnalyticEventsCollector realAnalyticEventsCollector) : IAnalyticEventsCollector
{
private readonly List<AnalyticEvent> _collectedEvents = new();

public IReadOnlyList<AnalyticEvent> CollectedEvents => _collectedEvents;

public bool AreAllEventsDispatched { get; private set; }

public void CollectEvent(string name, Dictionary<string, string>? 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;
}
}

0 comments on commit f057193

Please sign in to comment.