Skip to content

Commit

Permalink
So/review sentry exception logging in the api (#1498)
Browse files Browse the repository at this point in the history
* Initial config setup

* WIP - added http request correlation id  in support of Serilog

* WIP - logging correlation id - tidy-up

* WIP - logging

* WIP

* WIP - logging correlation ID

* Corrected syntax error

* Removed async from correlation ID middleware

* WIP - building out test coverage

* WIP - added code comments as tests are built out

* WIP - completed tests for LogEventExtensions

* Tests completed

* Pushed last tests changes

* Removed cofigured file logging

* Fixed upsert candidate tests

* Removed whitespace

* Removed unused correlation Id on PerformContextAdapter

* Removed code-smells based on SonarQube recommendations

* Corrected attribute usage restrictions on CorrelationIdFilterAttribute

* Removed commented out code from Program.cs to keep SonarQube happy

* Made changes recommended by MW in associated PR

* Corrected a couple of issues flagged by SonarQube

---------

Co-authored-by: Spencer O'HEGARTY <[email protected]>
  • Loading branch information
spanersoraferty and spencerohegartyDfE authored Feb 5, 2025
1 parent fb482ad commit 061590d
Show file tree
Hide file tree
Showing 28 changed files with 1,007 additions and 40 deletions.
31 changes: 20 additions & 11 deletions GetIntoTeachingApi/AppStart/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System;
using AspNetCoreRateLimit;
using AspNetCoreRateLimit;
using AspNetCoreRateLimit.Redis;
using GetIntoTeachingApi.Adapters;
using GetIntoTeachingApi.Auth;
using GetIntoTeachingApi.CrossCuttingConcerns.Logging;
using GetIntoTeachingApi.CrossCuttingConcerns.Logging.Serilog.CustomEnrichers;
using GetIntoTeachingApi.CrossCuttingConcerns.Logging.Serilog.Middleware;
using GetIntoTeachingApi.Database;
using GetIntoTeachingApi.Jobs;
using GetIntoTeachingApi.Jobs.FilterAttributes;
using GetIntoTeachingApi.Middleware;
using GetIntoTeachingApi.Models;
using GetIntoTeachingApi.OperationFilters;
Expand All @@ -22,6 +25,7 @@
using Microsoft.OpenApi.Models;
using Microsoft.Xrm.Sdk;
using StackExchange.Redis;
using System;

namespace GetIntoTeachingApi.AppStart
{
Expand Down Expand Up @@ -56,6 +60,9 @@ public static void RegisterServices(this IServiceCollection services, IConfigura
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
services.AddSingleton(env);
services.AddSingleton<IRequestResponseLoggingConfiguration, RequestResponseLoggingConfiguration>();
services.AddScoped<IHttpContextCorrelationIdProvider, HttpContextCorrelationIdProvider>();
services.AddScoped<SerilogCorrelationIdMiddleware>();
services.AddSingleton<CorrelationIdLogEnricher>();
}

public static void AddDatabase(this IServiceCollection services, IEnv env)
Expand All @@ -81,12 +88,12 @@ public static void AddSwagger(this IServiceCollection services)
Title = "Get into Teaching API - V1",
Version = "v1",
Description = @"
Provides a RESTful API for integrating with the Get into Teaching CRM.
The Get into Teaching (GIT) API sits in front of the GIT CRM, which uses the [Microsoft Dynamics365](https://docs.microsoft.com/en-us/dynamics365/) platform (the [Customer Engagement](https://docs.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/overview) module is used for storing Candidate information and the [Marketing](https://docs.microsoft.com/en-us/dynamics365/marketing/developer/using-events-api) module for managing Events).
The GIT API aims to provide:
* Simple, task-based RESTful APIs.
* Message queueing (while the GIT CRM is offline for updates).
* Validation to ensure consistency across services writing to the GIT CRM.
Provides a RESTful API for integrating with the Get into Teaching CRM.
The Get into Teaching (GIT) API sits in front of the GIT CRM, which uses the [Microsoft Dynamics365](https://docs.microsoft.com/en-us/dynamics365/) platform (the [Customer Engagement](https://docs.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/overview) module is used for storing Candidate information and the [Marketing](https://docs.microsoft.com/en-us/dynamics365/marketing/developer/using-events-api) module for managing Events).
The GIT API aims to provide:
* Simple, task-based RESTful APIs.
* Message queueing (while the GIT CRM is offline for updates).
* Validation to ensure consistency across services writing to the GIT CRM.
",
License = new OpenApiLicense
{
Expand All @@ -111,7 +118,7 @@ public static void AddSwagger(this IServiceCollection services)

public static void AddHangfire(this IServiceCollection services, IEnv env, bool useMemoryStorage)
{
services.AddHangfire((_, config) =>
services.AddHangfire((serviceProvider, config) =>
{
var automaticRetry = new AutomaticRetryAttribute
{
Expand All @@ -123,8 +130,10 @@ public static void AddHangfire(this IServiceCollection services, IEnv env, bool
config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseFilter(automaticRetry);
.UseRecommendedSerializerSettings()
.UseFilter(automaticRetry)
.UseFilter(new CorrelationIdFilter(
serviceProvider.GetService<IHttpContextCorrelationIdProvider>()));

if (useMemoryStorage)
{
Expand Down
13 changes: 7 additions & 6 deletions GetIntoTeachingApi/AppStart/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using System.IO;
using System.Text.Json.Serialization;
using AspNetCoreRateLimit;
using AspNetCoreRateLimit;
using dotenv.net;
using FluentValidation;
using FluentValidation.AspNetCore;
using GetIntoTeachingApi.CrossCuttingConcerns.Logging.Serilog.Middleware;
using GetIntoTeachingApi.JsonConverters;
using GetIntoTeachingApi.ModelBinders;
using GetIntoTeachingApi.Utils;
Expand All @@ -12,7 +11,9 @@
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Prometheus;
using Prometheus;
using System.IO;
using System.Text.Json.Serialization;

namespace GetIntoTeachingApi.AppStart
{
Expand Down Expand Up @@ -42,7 +43,7 @@ public void ConfigureServices(IServiceCollection services)
}

services.RegisterServices(_configuration, _env);

services.AddDatabase(_env);

services.AddApiClientAuthentication();
Expand Down Expand Up @@ -98,7 +99,7 @@ public void Configure(IApplicationBuilder app)
{
app.UseDeveloperExceptionPage();
}

app.UseMiddleware<SerilogCorrelationIdMiddleware>();
app.UseAuthentication();

app.UseHttpsRedirection();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using GetIntoTeachingApi.Jobs;
using GetIntoTeachingApi.Jobs;
using GetIntoTeachingApi.Models;
using GetIntoTeachingApi.Models.Crm;
using GetIntoTeachingApi.Models.TeacherTrainingAdviser;
Expand All @@ -12,6 +10,8 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Annotations;
using System;
using System.Collections.Generic;

namespace GetIntoTeachingApi.Controllers.TeacherTrainingAdviser
{
Expand Down Expand Up @@ -57,13 +57,13 @@ public IActionResult SignUp(
if (!ModelState.IsValid)
{
return BadRequest(this.ModelState);
}

// This is the only way we can mock/freeze the current date/time
// in contract tests (there's no other way to inject it into this class).
}

// This is the only way we can mock/freeze the current date/time
// in contract tests (there's no other way to inject it into this class).
request.DateTimeProvider = _dateTime;
string json = request.Candidate.SerializeChangeTracked();
_jobClient.Enqueue<UpsertCandidateJob>((x) => x.Run(json, null));
_jobClient.Enqueue<UpsertCandidateJob>((upsertCandidateJob) => upsertCandidateJob.Run(json, null));

_logger.LogInformation("TeacherTrainingAdviser - CandidatesController - Sign Up - {Client}", User.Identity.Name);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace GetIntoTeachingApi.CrossCuttingConcerns.Logging.Common
{
/// <summary>
/// Shared property keys used to define request correlation specific properties.
/// </summary>
public readonly struct CorrelationPropertyKeys
{
/// <summary>
/// The correlation Id (GUID) property name key defined for each http request.
/// </summary>
public static readonly string PerRequestCorrelationIdPropertyNameKey = "PerRequestCorrelationId";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http;
using System.Diagnostics;
using System;
using GetIntoTeachingApi.CrossCuttingConcerns.Logging.Common;

namespace GetIntoTeachingApi.CrossCuttingConcerns.Logging
{
/// <summary>
/// Provides the ability to extract a given correlation Id (if available) from
/// the current HTTP context using the provisioned <see cref="IHttpContextAccessor"/> instance.
/// </summary>
public sealed class HttpContextCorrelationIdProvider : IHttpContextCorrelationIdProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;

/// <summary>
/// Provisions the <see cref="IHttpContextAccessor"/> which provides
/// access to the current <see cref="HttpContext"/>, if one is available.
/// </summary>
/// <param name="httpContextAccessor">
/// The <see cref="IHttpContextAccessor"/> instance used to access the current <see cref="HttpContext"/>.
/// </param>
public HttpContextCorrelationIdProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}

/// <summary>
/// Attempts to extract the correlation Id from the current <see cref="HttpContext"/>.
/// </summary>
/// <returns>
/// The Correlation Id (GUID), defaults to Empty if no correlation Id is provisioned.
/// </returns>
public Guid GetCorrelationId()
{
Guid correlationId = Guid.Empty;
HttpContext httpContext = _httpContextAccessor.HttpContext;

if (httpContext is not null)
{
IHttpActivityFeature httpActivityFeature =
httpContext.Features.GetRequiredFeature<IHttpActivityFeature>();

Activity activity = httpActivityFeature.Activity;

object httpRequestCorrelationId =
activity.GetTagItem(CorrelationPropertyKeys.PerRequestCorrelationIdPropertyNameKey);

if (httpRequestCorrelationId is not null)
{
correlationId = (Guid)httpRequestCorrelationId;
}
}

return correlationId;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace GetIntoTeachingApi.CrossCuttingConcerns.Logging
{
/// <summary>
/// Provides the ability to extract a given correlation Id (if available) from the current HTTP context.
/// </summary>
public interface IHttpContextCorrelationIdProvider
{
/// <summary>
/// Contract for extracting the correlation Id from the current <see cref="HttpContext"/>.
/// </summary>
/// <returns>
/// The Correlation Id (GUID), defaults to Empty if no correlation Id is provisioned.
/// </returns>
Guid GetCorrelationId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Http;
using Serilog.Core;
using Serilog.Events;
using System;

namespace GetIntoTeachingApi.CrossCuttingConcerns.Logging.Serilog.CustomEnrichers
{
/// <summary>
/// Log event enricher allows associated events to have a
/// correlation Id (GUID) to be assigned to a named property allowing
/// various log events to be aggregated across a single request.
/// </summary>
public class CorrelationIdLogEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHttpContextCorrelationIdProvider _httpContextCorrelationIdProvider;

/// <summary>
/// Initialisation requires a <see cref="IHttpContextAccessor"/> which provides
/// access to the current <see cref="HttpContext"/>, if one is available; and a
/// <see cref="IHttpContextCorrelationIdProvider"/> which provides a correlation
/// Id (GUID) used to aggregates log events across a single request.
/// </summary>
/// <param name="httpContextAccessor">
/// Instance of <see cref="IHttpContextAccessor"/> which provides
/// access to the current <see cref="HttpContext"/>, if available.
/// </param>
/// /// <param name="httpContextCorrelationIdProvider">
/// Instance of <see cref="IHttpContextCorrelationIdProvider"/> which provides
/// a correlation Id (GUID) used to aggregates log events across a single request.
/// </param>
public CorrelationIdLogEnricher(
IHttpContextAccessor httpContextAccessor,
IHttpContextCorrelationIdProvider httpContextCorrelationIdProvider)
{
_httpContextAccessor = httpContextAccessor;
_httpContextCorrelationIdProvider = httpContextCorrelationIdProvider;
}

/// <summary>
/// Call to enrich decorates each 'enriched' log event with additional
/// properties, including the correlation Id derived from the current request.
/// </summary>
/// <param name="logEvent">
/// The contextual <see cref="LogEvent"/> on which additional
/// properties will be applied/enriched.
/// </param>
/// <param name="propertyFactory">
/// The <see cref="ILogEventPropertyFactory"/> factory object used to create
/// log event properties from regular .NET objects, applying policies as required.
/// </param>
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
HttpContext httpContext = _httpContextAccessor.HttpContext;

if (httpContext is not null)
{
logEvent
.LogProperty(propertyFactory,
LogPropertyKeys.RequestMethodPropertyNameKey, httpContext.Request.Method)
.LogProperty(propertyFactory,
LogPropertyKeys.RequestPathPropertyNameKey, httpContext.Request.Path)
.LogProperty(propertyFactory,
LogPropertyKeys.UserAgentPropertyNameKey,
httpContext.Request.Headers[LogPropertyKeys.UserAgentHeaderNameKey]);

Guid correlationId = _httpContextCorrelationIdProvider.GetCorrelationId();

if (correlationId != Guid.Empty)
{
logEvent.LogProperty(propertyFactory,
LogPropertyKeys.CorrelationIdNameKey, $"CID-{correlationId}");
}
}
}

/// <summary>
/// Aggregation of related log property keys used to define a given log property name.
/// </summary>
internal readonly struct LogPropertyKeys
{
/// <summary>
/// httpContext.Request.Method property name key.
/// </summary>
public static readonly string RequestMethodPropertyNameKey = "RequestMethod";
/// <summary>
/// httpContext.Request.Path property name key.
/// </summary>
public static readonly string RequestPathPropertyNameKey = "RequestPath";
/// <summary>
/// httpContext.Request.Headers user agent property name key.
/// </summary>
public static readonly string UserAgentPropertyNameKey = "UserAgent";
/// <summary>
/// httpContext.Request.Headers user agent header index key.
/// </summary>
public static readonly string UserAgentHeaderNameKey = "User-Agent";
/// <summary>
/// The specific request correlation Id (GUID).
/// </summary>
public static readonly string CorrelationIdNameKey = "CorrelationId";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Serilog.Core;
using Serilog.Events;
using System;

namespace GetIntoTeachingApi.CrossCuttingConcerns.Logging.Serilog.CustomEnrichers
{
/// <summary>
/// Extension method used to aggregate the behaviour required to
/// create a new log property, and add to the current <see cref="LogEvent"/> property collection.
/// </summary>
public static class LogEventExtensions
{
/// <summary>
/// Allows additional properties to be added to a given
/// <see cref="LogEvent"/> property collection.
/// </summary>
/// <param name="logEvent">
/// The <see cref="LogEvent"/> instance whose properties will be extended.
/// </param>
/// <param name="propertyFactory">
/// The <see cref="ILogEventPropertyFactory"/> instance used to create log event
/// properties from regular .NET objects,as required.
/// </param>
/// <param name="propertyKey">
/// The string values used to assign as the property name.
/// </param>
/// <param name="propertyValue">
/// The object value used to assign as the property value.
/// </param>
/// <returns></returns>
public static LogEvent LogProperty(
this LogEvent logEvent,
ILogEventPropertyFactory propertyFactory,
string propertyKey,
object propertyValue)
{
ArgumentNullException.ThrowIfNull(logEvent);
ArgumentNullException.ThrowIfNull(propertyFactory);
ArgumentNullException.ThrowIfNull(propertyKey);
ArgumentNullException.ThrowIfNull(propertyValue);

logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty(propertyKey, propertyValue));

return logEvent;
}
}
}
Loading

0 comments on commit 061590d

Please sign in to comment.