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]
Loading

0 comments on commit f057193

Please sign in to comment.