diff --git a/CrowdParlay.Social.sln b/CrowdParlay.Social.sln index f2bd8ae..28e73ef 100644 --- a/CrowdParlay.Social.sln +++ b/CrowdParlay.Social.sln @@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrowdParlay.Social.Infrastr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrowdParlay.Social.UnitTests", "tests\CrowdParlay.Social.UnitTests\CrowdParlay.Social.UnitTests.csproj", "{8F95AF80-A278-49DB-97EB-F8DB96F083D5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrowdParlay.Social.Aspects", "src\CrowdParlay.Social.Aspects\CrowdParlay.Social.Aspects.csproj", "{4DBC1701-9141-4AC2-80C1-CA9050FF7F0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {8F95AF80-A278-49DB-97EB-F8DB96F083D5}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F95AF80-A278-49DB-97EB-F8DB96F083D5}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F95AF80-A278-49DB-97EB-F8DB96F083D5}.Release|Any CPU.Build.0 = Release|Any CPU + {4DBC1701-9141-4AC2-80C1-CA9050FF7F0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DBC1701-9141-4AC2-80C1-CA9050FF7F0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DBC1701-9141-4AC2-80C1-CA9050FF7F0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DBC1701-9141-4AC2-80C1-CA9050FF7F0B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1C0DE505-4898-4FB4-A5AA-C01630770F79} = {340CE817-E3FB-4753-94AA-E5BFB13E6F6A} @@ -61,5 +67,6 @@ Global {640B084C-59F5-448D-845A-7D6A8E1A7F99} = {340CE817-E3FB-4753-94AA-E5BFB13E6F6A} {47776236-58AD-449A-B3BF-6ED960C3D12C} = {340CE817-E3FB-4753-94AA-E5BFB13E6F6A} {8F95AF80-A278-49DB-97EB-F8DB96F083D5} = {00C5EC71-2662-4897-8C51-8C1355111519} + {4DBC1701-9141-4AC2-80C1-CA9050FF7F0B} = {340CE817-E3FB-4753-94AA-E5BFB13E6F6A} EndGlobalSection EndGlobal diff --git a/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj b/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj index 5d39314..6a447cd 100644 --- a/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj +++ b/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj @@ -10,17 +10,13 @@ - - - - - + diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs index 85233b8..4817a47 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs @@ -7,9 +7,9 @@ namespace CrowdParlay.Social.Api.Extensions; -public static class ConfigureAuthenticationExtensions +partial class ServiceCollectionExtensions { - public static IServiceCollection ConfigureAuthentication(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection ConfigureAuthentication(this IServiceCollection services, IConfiguration configuration) { var dataProtectionRedisConnectionString = configuration["DATA_PROTECTION_REDIS_CONNECTION_STRING"]!; var dataProtectionRedisMultiplexer = ConnectionMultiplexer.Connect(dataProtectionRedisConnectionString); diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureCorsExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureCorsExtensions.cs index cc52142..b5a7f19 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureCorsExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureCorsExtensions.cs @@ -1,8 +1,8 @@ namespace CrowdParlay.Social.Api.Extensions; -public static class ConfigureCorsExtensions +partial class ServiceCollectionExtensions { - public static IServiceCollection ConfigureCors(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection ConfigureCors(this IServiceCollection services, IConfiguration configuration) { var corsOrigins = configuration["CORS_ORIGINS"]?.Split(';') diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureEndpointsExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureEndpointsExtensions.cs index 1b39143..a1f3566 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureEndpointsExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureEndpointsExtensions.cs @@ -3,9 +3,9 @@ namespace CrowdParlay.Social.Api.Extensions; -public static class ConfigureEndpointsExtensions +partial class ServiceCollectionExtensions { - public static IServiceCollection ConfigureEndpoints(this IServiceCollection services) + internal static IServiceCollection ConfigureEndpoints(this IServiceCollection services) { var mvcBuilder = services.AddControllers(options => { diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs index b7898fc..faf33d4 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs @@ -4,7 +4,7 @@ namespace CrowdParlay.Social.Api.Extensions; -public static class ConfigureApiExtensions +public static partial class ServiceCollectionExtensions { public static IServiceCollection AddApi(this IServiceCollection services, IConfiguration configuration) { @@ -13,7 +13,6 @@ public static IServiceCollection AddApi(this IServiceCollection services, IConfi .ConfigureAuthentication(configuration) .ConfigureCors(configuration) .ConfigureSignalR(configuration) - .ConfigureTelemetry(configuration) .AddExceptionHandler() .AddProblemDetails() .AddAuthorization(); diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureSignalRExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureSignalRExtensions.cs index 80fadc6..de07674 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureSignalRExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureSignalRExtensions.cs @@ -1,8 +1,8 @@ namespace CrowdParlay.Social.Api.Extensions; -public static class ConfigureSignalRExtensions +partial class ServiceCollectionExtensions { - public static IServiceCollection ConfigureSignalR(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection ConfigureSignalR(this IServiceCollection services, IConfiguration configuration) { var redisConnectionString = configuration["REDIS_CONNECTION_STRING"] diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureTelemetryExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureTelemetryExtensions.cs deleted file mode 100644 index c1fb9d6..0000000 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureTelemetryExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using OpenTelemetry.Trace; - -namespace CrowdParlay.Social.Api.Extensions; - -public static class ConfigureTelemetryExtensions -{ - public static IServiceCollection ConfigureTelemetry(this IServiceCollection services, IConfiguration configuration) - { - var otlpExporterEndpoint = - configuration["TELEMETRY_OTLP_EXPORTER_ENDPOINT"] - ?? throw new InvalidOperationException("Missing required configuration 'CORS_ORIGINS'."); - - var telemetrySourceName = - configuration["TELEMETRY_SOURCE_NAME"] - ?? throw new InvalidOperationException("Missing required configuration 'TELEMETRY_SOURCE_NAME'."); - - var openTelemetryBuilder = services.AddOpenTelemetry(); - - openTelemetryBuilder.WithMetrics(metrics => metrics - .AddMeter(telemetrySourceName) - .AddMeter("Microsoft.AspNetCore.Hosting") - .AddMeter("Microsoft.AspNetCore.Server.Kestrel")); - - openTelemetryBuilder.WithTracing(builder => builder - .AddSource(telemetrySourceName) - .AddOtlpExporter(options => options.Endpoint = new Uri(otlpExporterEndpoint)) - .AddAspNetCoreInstrumentation() - .AddGrpcClientInstrumentation()); - - return services; - } -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/Program.cs b/src/CrowdParlay.Social.Api/Program.cs index f4fb2d4..3b06ac8 100644 --- a/src/CrowdParlay.Social.Api/Program.cs +++ b/src/CrowdParlay.Social.Api/Program.cs @@ -1,4 +1,7 @@ +using CrowdParlay.Social.Infrastructure.Communication; using Serilog; +using Serilog.Enrichers.OpenTelemetry; +using Serilog.Sinks.OpenTelemetry; namespace CrowdParlay.Social.Api; @@ -8,5 +11,13 @@ public class Program private static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(builder => builder.UseStartup()) - .UseSerilog(); + .UseSerilog((context, logger) => logger + .ReadFrom.Configuration(context.Configuration) + .Enrich.WithOpenTelemetrySpanId() + .Enrich.WithOpenTelemetryTraceId() + .WriteTo.OpenTelemetry(sink => + { + sink.Endpoint = context.Configuration.GetRequiredSection("OpenTelemetry").Get()?.OtlpEndpoint; + sink.Protocol = OtlpProtocol.HttpProtobuf; + })); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/Startup.cs b/src/CrowdParlay.Social.Api/Startup.cs index 0efc47a..d462371 100644 --- a/src/CrowdParlay.Social.Api/Startup.cs +++ b/src/CrowdParlay.Social.Api/Startup.cs @@ -1,12 +1,10 @@ using CrowdParlay.Social.Api.Extensions; using CrowdParlay.Social.Api.Hubs; -using CrowdParlay.Social.Application; -using CrowdParlay.Social.Infrastructure.Communication; -using CrowdParlay.Social.Infrastructure.Persistence; -using Elastic.Apm.NetCoreAll; +using CrowdParlay.Social.Application.Extensions; +using CrowdParlay.Social.Infrastructure.Communication.Extensions; +using CrowdParlay.Social.Infrastructure.Persistence.Extensions; using Microsoft.AspNetCore.Http.Connections; using Serilog; -using Serilog.Enrichers.OpenTelemetry; namespace CrowdParlay.Social.Api; @@ -14,13 +12,6 @@ public class Startup(IConfiguration configuration) { public void Configure(IApplicationBuilder app, IWebHostEnvironment environment) { - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .Enrich.WithOpenTelemetrySpanId() - .Enrich.WithOpenTelemetryTraceId() - .CreateLogger(); - - app.UseAllElasticApm(configuration); app.UseExceptionHandler(); app.UseSerilogRequestLogging(); app.UseHealthChecks("/healthz"); @@ -39,6 +30,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment environment) public void ConfigureServices(IServiceCollection services) => services .AddApi(configuration) .AddApplication() - .AddPersistence(configuration) + .AddPersistence() .AddCommunication(configuration); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj index b08ee04..92b82d0 100644 --- a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj +++ b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj @@ -22,6 +22,7 @@ + diff --git a/src/CrowdParlay.Social.Application/DTOs/UserDto.cs b/src/CrowdParlay.Social.Application/DTOs/UserDto.cs index 80f97fe..ca9e636 100644 --- a/src/CrowdParlay.Social.Application/DTOs/UserDto.cs +++ b/src/CrowdParlay.Social.Application/DTOs/UserDto.cs @@ -3,7 +3,7 @@ namespace CrowdParlay.Social.Application.DTOs; public class UserDto { public required Guid Id { get; set; } - public required string Username { get; set; } - public required string DisplayName { get; set; } + public required string? Username { get; set; } + public required string? DisplayName { get; set; } public required string? AvatarUrl { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/ConfigureServices.cs b/src/CrowdParlay.Social.Application/Extensions/ConfigureServices.cs similarity index 83% rename from src/CrowdParlay.Social.Application/ConfigureServices.cs rename to src/CrowdParlay.Social.Application/Extensions/ConfigureServices.cs index f7497e2..ddbda9a 100644 --- a/src/CrowdParlay.Social.Application/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Application/Extensions/ConfigureServices.cs @@ -4,9 +4,9 @@ using FluentValidation; using Microsoft.Extensions.DependencyInjection; -namespace CrowdParlay.Social.Application; +namespace CrowdParlay.Social.Application.Extensions; -public static class ConfigureServicesExtensions +public static class ServiceCollectionExtensions { public static IServiceCollection AddApplication(this IServiceCollection services) => services .AddScoped() diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs index 10f715a..c049b72 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -1,13 +1,18 @@ using System.Diagnostics; using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; using Mapster; +using Metalama.Framework.Code; + +// ReSharper disable ExplicitCallerInfoArgument namespace CrowdParlay.Social.Application.Services; +[TraceMethods(Accessibility.Public, Accessibility.Private)] public class CommentsService( IUnitOfWorkFactory unitOfWorkFactory, ICommentsRepository commentsRepository, @@ -16,6 +21,7 @@ public class CommentsService( : ICommentsService { private readonly ISubjectsService _subjectsService = new SubjectsService(commentsRepository); + private readonly ActivitySource _activitySource = new(typeof(CommentsService).FullName!); public async Task GetByIdAsync(string commentId, Guid? viewerId) { @@ -47,18 +53,18 @@ public async Task ReplyToCommentAsync(string commentId, Guid au private async Task CreateAsync(string subjectId, Guid authorId, string content) { - var source = new ActivitySource(nameof(CommentsService)); - using var activity = source.CreateActivity("Create comment", ActivityKind.Server); - Comment comment; - using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + using (_activitySource.StartActivity("Save and retrieve entity")) { - var commentId = await unitOfWork.CommentsRepository.CreateAsync(subjectId, authorId, content); - comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); - await unitOfWork.CommitAsync(); + using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + var commentId = await unitOfWork.CommentsRepository.CreateAsync(subjectId, authorId, content); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); + await unitOfWork.CommitAsync(); + } } - using (source.CreateActivity("Update dependant metadata", ActivityKind.Server)) + using (_activitySource.StartActivity("Update ancestors")) { var ancestors = await commentsRepository.GetAncestorsAsync(comment.Id, null); await commentsRepository.IncludeCommentInAncestorsMetadataAsync(ancestors, authorId); @@ -78,11 +84,8 @@ public async Task SetReactionsAsync(string commentId, Guid authorId, ISet EnrichAsync(Comment comment) => (await EnrichAsync([comment])).First(); + private async Task EnrichAsync([TraceIgnore] Comment comment) => (await EnrichAsync([comment])).First(); - private async Task> EnrichAsync(IReadOnlyList comments) + private async Task> EnrichAsync([TraceIgnore] IReadOnlyList comments) { var authorIds = comments .SelectMany(comment => comment.LastCommentsAuthorIds.Append(comment.AuthorId)) diff --git a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs index 54a5dd9..520f28c 100644 --- a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs +++ b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs @@ -1,13 +1,16 @@ using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; using Mapster; +using Metalama.Framework.Code; namespace CrowdParlay.Social.Application.Services; +[TraceMethods(Accessibility.Public, Accessibility.Private)] public class DiscussionsService( IUnitOfWorkFactory unitOfWorkFactory, IDiscussionsRepository discussionsRepository, diff --git a/src/CrowdParlay.Social.Application/Services/SubjectsService.cs b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs index 13b1eac..d9a8563 100644 --- a/src/CrowdParlay.Social.Application/Services/SubjectsService.cs +++ b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs @@ -1,10 +1,13 @@ using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.ValueObjects; +using Metalama.Framework.Code; namespace CrowdParlay.Social.Application.Services; +[TraceMethods(Accessibility.Public, Accessibility.Private)] public class SubjectsService(ISubjectsRepository subjectsRepository) : ISubjectsService { public async Task> GetReactionsAsync(string subjectId, Guid authorId) => diff --git a/src/CrowdParlay.Social.Aspects/CrowdParlay.Social.Aspects.csproj b/src/CrowdParlay.Social.Aspects/CrowdParlay.Social.Aspects.csproj new file mode 100644 index 0000000..e31a07d --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/CrowdParlay.Social.Aspects.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/CrowdParlay.Social.Aspects/MethodTracingOptions.cs b/src/CrowdParlay.Social.Aspects/MethodTracingOptions.cs new file mode 100644 index 0000000..a502af8 --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/MethodTracingOptions.cs @@ -0,0 +1,18 @@ +using Metalama.Framework.Aspects; + +namespace CrowdParlay.Social.Aspects; + +[CompileTime] +internal class MethodTracingOptions +{ + public string? ActivityName; + public TraceKind? TraceKind; + public readonly HashSet IgnoredParameters = new(); + + public void Fallback(MethodTracingOptions other) + { + ActivityName ??= other.ActivityName; + TraceKind ??= other.TraceKind; + IgnoredParameters.UnionWith(other.IgnoredParameters); + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Aspects/TraceAspectProjectFabric.cs b/src/CrowdParlay.Social.Aspects/TraceAspectProjectFabric.cs new file mode 100644 index 0000000..2947b10 --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/TraceAspectProjectFabric.cs @@ -0,0 +1,30 @@ +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Fabrics; + +namespace CrowdParlay.Social.Aspects; + +public class TraceAspectProjectFabric : TransitiveProjectFabric +{ + public override void AmendProject(IProjectAmender amender) + { + var implicitlyTracedMethods = amender + .SelectDeclarationsWithAttribute() + .OfType() + .SelectMany(type => + { + var typeAttribute = type.Attributes.GetConstructedAttributesOfType().Single(); + return type.Methods.Where(method => + method.HasImplementation + && typeAttribute.Accessibility.Contains(method.Accessibility) + && !method.Attributes.OfAttributeType(typeof(TraceAttribute)).Any() + && !method.Attributes.OfAttributeType(typeof(TraceIgnoreAttribute)).Any()); + }); + + implicitlyTracedMethods.AddAspect(method => + { + var declaringTypeAttribute = method.DeclaringType.Attributes.GetConstructedAttributesOfType().Single(); + return new TraceAttribute(declaringTypeAttribute.MethodTracingOptions); + }); + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Aspects/TraceAttribute.cs b/src/CrowdParlay.Social.Aspects/TraceAttribute.cs new file mode 100644 index 0000000..0f4e7e0 --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/TraceAttribute.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; + +namespace CrowdParlay.Social.Aspects; + +[SuppressMessage("ReSharper", "ConvertToPrimaryConstructor")] +[AttributeUsage(AttributeTargets.Method), CompileTime] +public sealed class TraceAttribute : OverrideMethodAspect +{ + internal MethodTracingOptions Options = new(); + + public TraceAttribute() { } + internal TraceAttribute(MethodTracingOptions options) => Options = options; + public TraceAttribute(string activityName) => Options.ActivityName = activityName; + public TraceAttribute(TraceKind traceKind) => Options.TraceKind = traceKind; + + public TraceAttribute(string activityName, TraceKind traceKind) + { + Options.ActivityName = activityName; + Options.TraceKind = traceKind; + } + + public override void BuildAspect(IAspectBuilder builder) + { + var typeAttribute = builder.Target.DeclaringType.Attributes.GetConstructedAttributesOfType().SingleOrDefault(); + if (typeAttribute is not null) + Options.Fallback(typeAttribute.MethodTracingOptions); + + var ignoredParameters = builder.Target.Parameters + .Where(parameter => parameter.Attributes.OfAttributeType(typeof(TraceIgnoreAttribute)).Any()) + .Select(parameter => parameter.Name); + + Options.IgnoredParameters.UnionWith(ignoredParameters); + base.BuildAspect(builder); + } + + public override dynamic? OverrideMethod() + { + var activitySource = new ActivitySource(meta.Target.Method.DeclaringType.FullName); + var activity = activitySource.CreateActivity( + Options.ActivityName ?? meta.Target.Method.Name, + (ActivityKind?)Options.TraceKind ?? ActivityKind.Internal)!; + + var parametersForSerialization = meta.Target.Parameters.Where(parameter => !Options.IgnoredParameters.Contains(parameter.Name)); + var options = new JsonSerializerOptions { MaxDepth = 3 }; + foreach (var parameter in parametersForSerialization) + { + activity.AddTag($"parameters.{parameter.Name}.presence","YES" ); + try + { + var serializedValue = JsonSerializer.Serialize(parameter.Value, options); + activity.AddTag($"parameters.{parameter.Name}", serializedValue); + } + catch + { + // Parameters of unserializable types (e.g. CancellationToken having nested IntPtr property) are ignored + } + } + + using (activity.Start()) + { + try + { + return meta.Proceed(); + } + catch (Exception exception) + { + activity.AddException(exception); + throw; + } + } + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Aspects/TraceIgnoreAttribute.cs b/src/CrowdParlay.Social.Aspects/TraceIgnoreAttribute.cs new file mode 100644 index 0000000..4a4c273 --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/TraceIgnoreAttribute.cs @@ -0,0 +1,4 @@ +namespace CrowdParlay.Social.Aspects; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter)] +public class TraceIgnoreAttribute : Attribute; \ No newline at end of file diff --git a/src/CrowdParlay.Social.Aspects/TraceKind.cs b/src/CrowdParlay.Social.Aspects/TraceKind.cs new file mode 100644 index 0000000..a4a4598 --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/TraceKind.cs @@ -0,0 +1,24 @@ +using Metalama.Framework.Aspects; +using System.Diagnostics; + +namespace CrowdParlay.Social.Aspects; + +/// Compile-time version of +[RunTimeOrCompileTime] +public enum TraceKind +{ + /// Internal operation within an application, as opposed to operations with remote parents or children. This is the default value. + Internal, + + /// Requests incoming from external component. + Server, + + /// Outgoing request to the external component. + Client, + + /// Output provided to external components. + Producer, + + /// Output received from an external component. + Consumer +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Aspects/TraceMethodsAttribute.cs b/src/CrowdParlay.Social.Aspects/TraceMethodsAttribute.cs new file mode 100644 index 0000000..72b3a76 --- /dev/null +++ b/src/CrowdParlay.Social.Aspects/TraceMethodsAttribute.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; + +namespace CrowdParlay.Social.Aspects; + +[SuppressMessage("ReSharper", "ConvertToPrimaryConstructor")] +[AttributeUsage(AttributeTargets.Class), CompileTime] +public class TraceMethodsAttribute : Attribute +{ + internal readonly Accessibility[] Accessibility = [Metalama.Framework.Code.Accessibility.Public]; + internal readonly MethodTracingOptions MethodTracingOptions = new(); + + public TraceMethodsAttribute() { } + public TraceMethodsAttribute(TraceKind traceKind) => MethodTracingOptions.TraceKind = traceKind; + public TraceMethodsAttribute(params Accessibility[] accessibility) => Accessibility = accessibility; + + public TraceMethodsAttribute(TraceKind traceKind, params Accessibility[] accessibility) + { + if (!accessibility.Any()) + throw new ArgumentException("At least one accessibility must be specified.", nameof(accessibility)); + + MethodTracingOptions.TraceKind = traceKind; + Accessibility = accessibility; + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj b/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj index 8c5a6f5..02d5128 100644 --- a/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj +++ b/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj @@ -13,6 +13,15 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/Extensions/ConfigureOpenTelemetry.cs b/src/CrowdParlay.Social.Infrastructure.Communication/Extensions/ConfigureOpenTelemetry.cs new file mode 100644 index 0000000..0863b70 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Communication/Extensions/ConfigureOpenTelemetry.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace CrowdParlay.Social.Infrastructure.Communication.Extensions; + +partial class ServiceCollectionExtensions +{ + private static IServiceCollection ConfigureOpenTelemetry(this IServiceCollection services, IConfiguration configuration) + { + var openTelemetryConfiguration = configuration.GetRequiredSection("OpenTelemetry"); + var openTelemetrySettings = openTelemetryConfiguration.Get()!; + + services + .AddOptions() + .ValidateDataAnnotations() + .ValidateOnStart() + .Bind(openTelemetryConfiguration); + + var openTelemetryBuilder = services.AddOpenTelemetry().UseOtlpExporter(); + + openTelemetryBuilder.ConfigureResource(resource => resource + .AddService(openTelemetrySettings.ServiceName)); + + openTelemetryBuilder.WithTracing(tracing => tracing + .AddSource(openTelemetrySettings.AdditionalTraceSources.Split(';')) + .AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddMassTransitInstrumentation() + .AddRedisInstrumentation()); + + openTelemetryBuilder.WithMetrics(metrics => metrics + .AddMeter(openTelemetrySettings.MeterName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()); + + return services; + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Communication/Extensions/ConfigureServices.cs similarity index 88% rename from src/CrowdParlay.Social.Infrastructure.Communication/ConfigureServices.cs rename to src/CrowdParlay.Social.Infrastructure.Communication/Extensions/ConfigureServices.cs index d449a69..0842a19 100644 --- a/src/CrowdParlay.Social.Infrastructure.Communication/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Communication/Extensions/ConfigureServices.cs @@ -5,9 +5,9 @@ using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; -namespace CrowdParlay.Social.Infrastructure.Communication; +namespace CrowdParlay.Social.Infrastructure.Communication.Extensions; -public static class ConfigureServices +public static partial class ServiceCollectionExtensions { public static IServiceCollection AddCommunication(this IServiceCollection services, IConfiguration configuration) { @@ -28,6 +28,7 @@ public static IServiceCollection AddCommunication(this IServiceCollection servic }); return services + .ConfigureOpenTelemetry(configuration) .AddScoped() .AddScoped() .Decorate() diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/OpenTelemetrySettings.cs b/src/CrowdParlay.Social.Infrastructure.Communication/OpenTelemetrySettings.cs new file mode 100644 index 0000000..b373b01 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Communication/OpenTelemetrySettings.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace CrowdParlay.Social.Infrastructure.Communication; + +public record OpenTelemetrySettings +{ + [Required] public required string ServiceName { get; init; } + [Required] public required string OtlpEndpoint { get; init; } + [Required] public required string MeterName { get; init; } + [Required] public required string AdditionalTraceSources { get; init; } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/Services/RedisUsersCache.cs b/src/CrowdParlay.Social.Infrastructure.Communication/Services/RedisUsersCache.cs index 049684a..fd7090e 100644 --- a/src/CrowdParlay.Social.Infrastructure.Communication/Services/RedisUsersCache.cs +++ b/src/CrowdParlay.Social.Infrastructure.Communication/Services/RedisUsersCache.cs @@ -1,10 +1,12 @@ using System.Text.Json; using CrowdParlay.Social.Application.DTOs; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Infrastructure.Communication.Abstractions; using StackExchange.Redis; namespace CrowdParlay.Social.Infrastructure.Communication.Services; +[TraceMethods] public class RedisUsersCache(IDatabase redis) : IUsersCache { public async Task GetUserByIdAsync(Guid userId) @@ -29,10 +31,10 @@ public class RedisUsersCache(IDatabase redis) : IUsersCache return userIds.Zip(users).ToDictionary(x => x.First, x => x.Second); } - public async Task SaveAsync(UserDto user) => + public async Task SaveAsync([TraceIgnore] UserDto user) => await redis.StringSetAsync(user.Id.ToString(), JsonSerializer.Serialize(user), TimeSpan.FromMinutes(1)); - public async Task SaveAsync(IEnumerable users) + public async Task SaveAsync([TraceIgnore] IEnumerable users) { var transaction = redis.CreateTransaction(); diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersService.cs b/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersService.cs index 0b93f9e..34197c6 100644 --- a/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersService.cs +++ b/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersService.cs @@ -1,11 +1,13 @@ using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; +using CrowdParlay.Social.Aspects; using CrowdParlay.Users.gRPC; using Grpc.Core; using Mapster; namespace CrowdParlay.Social.Infrastructure.Communication.Services; +[TraceMethods] public class UsersService(Users.gRPC.UsersService.UsersServiceClient usersClient) : IUsersService { public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersServiceResilienceDecorator.cs b/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersServiceResilienceDecorator.cs index f39c7c6..a2a1732 100644 --- a/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersServiceResilienceDecorator.cs +++ b/src/CrowdParlay.Social.Infrastructure.Communication/Services/UsersServiceResilienceDecorator.cs @@ -23,7 +23,13 @@ public class UsersServiceResilienceDecorator( catch (RpcException exception) { logger.LogError(exception, message: null); - return null; + return new UserDto + { + Id = id, + Username = null, + DisplayName = null, + AvatarUrl = null + }; } } @@ -39,7 +45,13 @@ public class UsersServiceResilienceDecorator( catch (RpcException exception) { logger.LogError(exception, message: null); - return ids.ToDictionary(id => id, _ => null); + return ids.ToDictionary(id => id, id => new UserDto + { + Id = id, + Username = null, + DisplayName = null, + AvatarUrl = null + }); } } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Extensions/ConfigureServices.cs similarity index 81% rename from src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs rename to src/CrowdParlay.Social.Infrastructure.Persistence/Extensions/ConfigureServices.cs index c6c1fcc..6794743 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Extensions/ConfigureServices.cs @@ -1,29 +1,28 @@ using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Infrastructure.Persistence.Services; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using MongoDB.Driver; -namespace CrowdParlay.Social.Infrastructure.Persistence; +namespace CrowdParlay.Social.Infrastructure.Persistence.Extensions; -public static class ConfigurePersistenceExtensions +public static class ServiceCollectionExtensions { - public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) => services - .AddMongoDb(configuration) + public static IServiceCollection AddPersistence(this IServiceCollection services) => services + .AddMongoDb() .AddScoped() .AddScoped() .AddScoped() .AddHostedService() .AddHostedService(); - private static IServiceCollection AddMongoDb(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection AddMongoDb(this IServiceCollection services) { services .AddOptions() .ValidateDataAnnotations() .ValidateOnStart() - .Bind(configuration.GetSection("MongoDb")); + .BindConfiguration("MongoDb"); services.AddSingleton(provider => { diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index 4055dd0..e203512 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; @@ -14,6 +15,7 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; +[TraceMethods] public class CommentsRepository(IClientSessionHandle session, IMongoDatabase database) : ICommentsRepository { private readonly IMongoCollection _comments = database.GetCollection(Collections.Comments); @@ -111,7 +113,7 @@ public async Task> GetAncestorsAsync(string commentId, Guid? view .Project(CreateCommentProjectionExpression(viewerId)) .ToListAsync(); - public async Task IncludeCommentInAncestorsMetadataAsync(IEnumerable ancestors, Guid authorId) + public async Task IncludeCommentInAncestorsMetadataAsync([TraceIgnore] IEnumerable ancestors, Guid authorId) { var updates = ancestors.Select(ancestor => { @@ -134,7 +136,7 @@ public async Task IncludeCommentInAncestorsMetadataAsync(IEnumerable an await _comments.BulkWriteAsync(session, updates); } - public async Task ExcludeCommentFromAncestorsMetadataAsync(IEnumerable ancestors) + public async Task ExcludeCommentFromAncestorsMetadataAsync([TraceIgnore] IEnumerable ancestors) { var filter = Filter.In(comment => comment.Id, ancestors.Select(comment => ObjectId.Parse(comment.Id))); var update = Update.Inc(comment => comment.CommentCount, -1); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs index eaaa098..124474e 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; @@ -14,6 +15,7 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; +[TraceMethods] public class DiscussionsRepository(IClientSessionHandle session, IMongoDatabase database) : IDiscussionsRepository { private readonly IMongoCollection _discussions = database.GetCollection(Collections.Discussions); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs index c797d3d..b1702aa 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs @@ -1,4 +1,5 @@ using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Aspects; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Infrastructure.Persistence.Models; using MongoDB.Bson; @@ -6,6 +7,7 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; +[TraceMethods] public class GenericSubjectsRepository(IClientSessionHandle session, IMongoDatabase database, string collection) : ISubjectsRepository where TDocument : ISubjectDocument { diff --git a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj index b839ac7..e255c64 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj +++ b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/CrowdParlay.Social.IntegrationTests/Services/OpenTelemetryInMemoryStorage.cs b/tests/CrowdParlay.Social.IntegrationTests/Services/OpenTelemetryInMemoryStorage.cs new file mode 100644 index 0000000..1b96e20 --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Services/OpenTelemetryInMemoryStorage.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; +using OpenTelemetry.Metrics; + +namespace CrowdParlay.Social.IntegrationTests.Services; + +public class OpenTelemetryInMemoryStorage +{ + public readonly List Activities = new(); + public readonly List Metrics = new(); +} \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs index be014ba..051f098 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs @@ -1,9 +1,14 @@ -using CrowdParlay.Social.Infrastructure.Communication.Services; +using System.Diagnostics; +using CrowdParlay.Social.Infrastructure.Communication.Services; using MassTransit; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenTelemetry.Exporter; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using SimpleActivityExportProcessor = OpenTelemetry.SimpleActivityExportProcessor; namespace CrowdParlay.Social.IntegrationTests.Services; @@ -18,28 +23,57 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { ["MongoDb:ConnectionString"] = mongoDbConfiguration.ConnectionString, ["MongoDb:Database"] = mongoDbConfiguration.Database, + ["OpenTelemetry:ServiceName"] = "social", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:8200", + ["OpenTelemetry:AdditionalTraceSources"] = "CrowdParlay.Social.*", + ["OpenTelemetry:MeterName"] = "CrowdParlay.Social.Metrics", ["REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, ["USERS_GRPC_ADDRESS"] = "https://users:5104", - ["TELEMETRY_SOURCE_NAME"] = "Social", - ["TELEMETRY_OTLP_EXPORTER_ENDPOINT"] = "http://localhost:8200", ["CORS_ORIGINS"] = "http://localhost;http://localhost:1234", ["DATA_PROTECTION_REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, })); - builder.ConfigureServices(services => - { - services.RemoveAll(typeof(IUsersService)); - services.AddScoped(); - services.Decorate(); + builder.ConfigureServices(ConfigureMockServices); + builder.ConfigureServices(ConfigureOpenTelemetryInMemoryExporter); + builder.ConfigureServices(ConfigureRabbitMqTestHarness); + } - var massTransitDescriptors = services - .Where(service => service.ServiceType.Namespace?.Split('.') is [nameof(MassTransit), ..]) - .ToArray(); + private static void ConfigureMockServices(IServiceCollection services) + { + services.RemoveAll(typeof(IUsersService)); + services.AddScoped(); + services.Decorate(); + } - foreach (var descriptor in massTransitDescriptors) - services.Remove(descriptor); + private static void ConfigureOpenTelemetryInMemoryExporter(IServiceCollection services) + { + var storage = new OpenTelemetryInMemoryStorage(); + services.AddSingleton(storage); + + var openTelemetryBuilder = services.AddOpenTelemetry().WithTracing(tracerProviderBuilder => + { + var inMemoryExporter = new InMemoryExporter(storage.Activities); + tracerProviderBuilder.AddProcessor(new SimpleActivityExportProcessor(inMemoryExporter)); + services.AddSingleton(inMemoryExporter); + }); - services.AddMassTransitTestHarness(bus => bus.AddDelayedMessageScheduler()); + openTelemetryBuilder.WithMetrics(meterProviderBuilder => + { + var inMemoryExporter = new InMemoryExporter(storage.Metrics); + meterProviderBuilder.AddReader(new BaseExportingMetricReader(inMemoryExporter)); + services.AddSingleton(inMemoryExporter); }); } + + private static void ConfigureRabbitMqTestHarness(IServiceCollection services) + { + var massTransitDescriptors = services + .Where(service => service.ServiceType.Namespace?.Split('.') is [nameof(MassTransit), ..]) + .ToArray(); + + foreach (var descriptor in massTransitDescriptors) + services.Remove(descriptor); + + services.AddMassTransitTestHarness(bus => bus.AddDelayedMessageScheduler()); + } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Services/UsersServiceMock.cs b/tests/CrowdParlay.Social.IntegrationTests/Services/UsersServiceMock.cs index 0990074..de6ee0a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Services/UsersServiceMock.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Services/UsersServiceMock.cs @@ -1,5 +1,8 @@ +using CrowdParlay.Social.Aspects; + namespace CrowdParlay.Social.IntegrationTests.Services; +[TraceMethods] public class UsersServiceMock : IUsersService { public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => Task.FromResult(new UserDto diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/TracingTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/TracingTests.cs new file mode 100644 index 0000000..51278a5 --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/TracingTests.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using CrowdParlay.Social.Application.Services; +using CrowdParlay.Social.IntegrationTests.Services; +using OpenTelemetry.Exporter; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class TracingTests(WebApplicationContext context) : IAssemblyFixture +{ + private readonly IServiceProvider _services = context.Services; + + [Fact] + public async Task UsersServiceProducesTraces() + { + // Arrange + var storage = _services.GetRequiredService(); + var exporter = _services.GetRequiredService>(); + + var scopeFactory = _services.GetRequiredService(); + using var scope = scopeFactory.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService(); + + var userId = Guid.NewGuid(); + + // Act + await users.GetByIdAsync(userId); + exporter.ForceFlush(); + + // Assert + storage.Activities.Should() + .ContainSingle(activity => + activity.Source.Name == typeof(UsersServiceMock).FullName + && activity.OperationName == nameof(UsersServiceMock.GetByIdAsync)) + .Which.Tags.Should().Contain("parameters.id", $"\"{userId}\""); + } + + [Fact] + public async Task CommentsServiceProducesTraces() + { + // Arrange + var storage = _services.GetRequiredService(); + var exporter = _services.GetRequiredService>(); + + var scopeFactory = _services.GetRequiredService(); + using var scope = scopeFactory.CreateScope(); + var discussions = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); + + var authorId = Guid.NewGuid(); + var discussion = await discussions.CreateAsync(authorId, "Discussion 1", "Content."); + await comments.ReplyToDiscussionAsync(discussion.Id, authorId, "Comment."); + + // Act + await comments.GetRepliesAsync(discussion.Id, true, null, 0, 100); + exporter.ForceFlush(); + + // Assert + storage.Activities.Should() + .ContainSingle(activity => + activity.Source.Name == typeof(CommentsService).FullName + && activity.OperationName == nameof(CommentsService.GetRepliesAsync)) + .Which.Tags.Should().Contain("parameters.subjectId", $"\"{discussion.Id}\"") + .And.Contain("parameters.flatten", "true") + .And.Contain("parameters.viewerId", "null") + .And.Contain("parameters.offset", "0") + .And.Contain("parameters.count", "100"); + } +} \ No newline at end of file