From fcd922f1c7cfb8cb2f33933d4c2e371e5a7d24c1 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Fri, 22 Nov 2024 14:32:56 +0100 Subject: [PATCH] Pass tracecontext to pdf generator --- .../Extensions/ServiceCollectionExtensions.cs | 16 ++++-- .../Telemetry/Telemetry.PdfGeneratorClient.cs | 12 +++++ .../Telemetry/TelemetryActivityExtensions.cs | 2 +- .../Clients/Pdf/PdfGeneratorClient.cs | 45 +++++++++++++++- .../Controllers/PdfControllerTests.cs | 9 ++++ .../CustomWebApplicationFactory.cs | 1 + ...ys_Be_A_Root_Trace_Unless_Pdf.verified.txt | 51 +++++++++++++++++++ .../TelemetryEnrichingMiddlewareTests.cs | 33 +++++++++++- .../Internal/Pdf/PdfServiceTests.cs | 4 ++ 9 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/Altinn.App.Core/Features/Telemetry/Telemetry.PdfGeneratorClient.cs create mode 100644 test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.Should_Always_Be_A_Root_Trace_Unless_Pdf.verified.txt diff --git a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs index 2b4e41f47..9c69159f0 100644 --- a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs @@ -356,6 +356,15 @@ public async Task StartAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + /// + /// PDF generation works by using a headless browser to render the frontend of an app instance. + /// To make debugging PDF generation failures easier, we want requests originating from the PDF generator to be + /// contained in the root trace (process/next) as children. The frontend will set this header when making requests to the app backend in PDF mode. + /// + /// Request headers attached to the span + /// + private static bool IsPdfGeneratorRequest(IHeaderDictionary headers) => headers.ContainsKey("X-Altinn-IsPdf"); + internal sealed class OtelPropagator : TextMapPropagator { private readonly TextMapPropagator _inner; @@ -370,8 +379,9 @@ public override PropagationContext Extract( Func?> getter ) { - if (carrier is HttpRequest) + if (carrier is HttpRequest req && !IsPdfGeneratorRequest(req.Headers)) return default; + return _inner.Extract(context, carrier, getter); } @@ -392,7 +402,7 @@ internal sealed class AspNetCorePropagator : DistributedContextPropagator PropagatorGetterCallback? getter ) { - if (carrier is IHeaderDictionary) + if (carrier is IHeaderDictionary headers && !IsPdfGeneratorRequest(headers)) return null; return _inner.ExtractBaggage(carrier, getter); @@ -405,7 +415,7 @@ public override void ExtractTraceIdAndState( out string? traceState ) { - if (carrier is IHeaderDictionary) + if (carrier is IHeaderDictionary headers && !IsPdfGeneratorRequest(headers)) { traceId = null; traceState = null; diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.PdfGeneratorClient.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.PdfGeneratorClient.cs new file mode 100644 index 000000000..8066b3c32 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.PdfGeneratorClient.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + internal Activity? StartGeneratePdfClientActivity() + { + var activity = ActivitySource.StartActivity("PdfGeneratorClient.GeneratePdf"); + return activity; + } +} diff --git a/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs b/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs index b2c8dab43..7f147c1fa 100644 --- a/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs +++ b/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs @@ -344,7 +344,7 @@ internal static Activity SetProblemDetails(this Activity activity, ProblemDetail internal static void Errored(this Activity activity, Exception? exception = null, string? error = null) { activity.SetStatus(ActivityStatusCode.Error, error); - if(exception is not null) + if (exception is not null) { activity.AddException(exception); } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs index 1bdbb0aaa..b525d17c2 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs @@ -1,11 +1,15 @@ +using System.Diagnostics; using System.Text; using System.Text.Json; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Internal.Pdf; using Altinn.App.Core.Models.Pdf; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OpenTelemetry.Context.Propagation; namespace Altinn.App.Core.Infrastructure.Clients.Pdf; @@ -15,20 +19,25 @@ namespace Altinn.App.Core.Infrastructure.Clients.Pdf; /// public class PdfGeneratorClient : IPdfGeneratorClient { + private static readonly TextMapPropagator _w3cPropagator = new TraceContextPropagator(); + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly PdfGeneratorSettings _pdfGeneratorSettings; private readonly PlatformSettings _platformSettings; private readonly IUserTokenProvider _userTokenProvider; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly Telemetry? _telemetry; /// /// Initializes a new instance of the class. /// + /// The logger. /// The HttpClient to use in communication with the PDF generator service. /// /// All generic settings needed for communication with the PDF generator service. @@ -36,19 +45,24 @@ public class PdfGeneratorClient : IPdfGeneratorClient /// Links to platform services /// A service able to identify the JWT for currently authenticated user. /// http context + /// Telemetry service public PdfGeneratorClient( + ILogger logger, HttpClient httpClient, IOptions pdfGeneratorSettings, IOptions platformSettings, IUserTokenProvider userTokenProvider, - IHttpContextAccessor httpContextAccessor + IHttpContextAccessor httpContextAccessor, + Telemetry? telemetry = null ) { + _logger = logger; _httpClient = httpClient; _userTokenProvider = userTokenProvider; _pdfGeneratorSettings = pdfGeneratorSettings.Value; _platformSettings = platformSettings.Value; _httpContextAccessor = httpContextAccessor; + _telemetry = telemetry; } /// @@ -60,6 +74,8 @@ public async Task GeneratePdf(Uri uri, CancellationToken ct) /// public async Task GeneratePdf(Uri uri, string? footerContent, CancellationToken ct) { + using var activity = _telemetry?.StartGeneratePdfClientActivity(); + bool hasWaitForSelector = !string.IsNullOrWhiteSpace(_pdfGeneratorSettings.WaitForSelector); PdfGeneratorRequest generatorRequest = new() { @@ -73,6 +89,33 @@ public async Task GeneratePdf(Uri uri, string? footerContent, Cancellati }, }; + if (Activity.Current is { } propagateActivity) + { + // We want the frontend to attach the current trace context to requests + // when making downstream requests back to the app backend. + // This makes it easier to debug issues (such as slow backend requests during PDF generation). + // The frontend expects to see the "traceparent" and "tracestate" values as cookies (as they are easily propagated). + // It will then pass them back to the backend in the "traceparent" and "tracestate" headers as per W3C spec. + _w3cPropagator.Inject( + new PropagationContext(propagateActivity.Context, default), + generatorRequest.Cookies, + (c, k, v) => + { + if (k != "traceparent" && k != "tracestate") + _logger.LogWarning("Unexpected key '{Key}' when propagating trace context (expected W3C)", k); + + c.Add( + new PdfGeneratorCookieOptions + { + Name = $"altinn-telemetry-{k}", + Value = v, + Domain = uri.Host, + } + ); + } + ); + } + generatorRequest.Cookies.Add( new PdfGeneratorCookieOptions { Value = _userTokenProvider.GetUserToken(), Domain = uri.Host } ); diff --git a/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs index 242477e4a..7625dae95 100644 --- a/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs @@ -83,7 +83,10 @@ public async Task Request_In_Dev_Should_Generate() var handler = new Mock(); var httpClient = new HttpClient(handler.Object); + var logger = new Mock>(); + var pdfGeneratorClient = new PdfGeneratorClient( + logger.Object, httpClient, _pdfGeneratorSettingsOptions, _platformSettingsOptions, @@ -163,7 +166,10 @@ public async Task Request_In_Dev_Should_Include_Frontend_Version() var handler = new Mock(); var httpClient = new HttpClient(handler.Object); + var logger = new Mock>(); + var pdfGeneratorClient = new PdfGeneratorClient( + logger.Object, httpClient, _pdfGeneratorSettingsOptions, _platformSettingsOptions, @@ -245,7 +251,10 @@ public async Task Request_In_TT02_Should_Ignore_Frontend_Version() var handler = new Mock(); var httpClient = new HttpClient(handler.Object); + var logger = new Mock>(); + var pdfGeneratorClient = new PdfGeneratorClient( + logger.Object, httpClient, _pdfGeneratorSettingsOptions, _platformSettingsOptions, diff --git a/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs b/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs index a5ea3272c..95422408e 100644 --- a/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs +++ b/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs @@ -189,6 +189,7 @@ CancellationToken cancellationToken request.Headers, (c, k, v) => c.TryAddWithoutValidation(k, v) ); + Assert.Contains(request.Headers, h => h.Key == "traceparent"); // traceparent is mandatory in W3C } return base.SendAsync(request, cancellationToken); } diff --git a/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.Should_Always_Be_A_Root_Trace_Unless_Pdf.verified.txt b/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.Should_Always_Be_A_Root_Trace_Unless_Pdf.verified.txt new file mode 100644 index 000000000..c719e047a --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.Should_Always_Be_A_Root_Trace_Unless_Pdf.verified.txt @@ -0,0 +1,51 @@ +{ + Activities: [ + { + ActivityName: GET {org}/{app}/api/v1/applicationmetadata, + Tags: [ + { + http.request.method: GET + }, + { + http.response.status_code: 200 + }, + { + http.route: {org}/{app}/api/v1/applicationmetadata + }, + { + network.protocol.version: 1.1 + }, + { + server.address: localhost + }, + { + TestId: Guid_1 + }, + { + url.path: /tdd/contributer-restriction/api/v1/applicationmetadata + }, + { + url.scheme: http + }, + { + user.authentication.level: 4 + }, + { + user.authentication.method: Mock + }, + { + user.id: 10 + }, + { + user.name: User10 + }, + { + user.party.id: Scrubbed + } + ], + IdFormat: W3C, + Kind: Server + } + ], + Metrics: [] +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.cs b/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.cs index 239efead3..4d825c93b 100644 --- a/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.cs +++ b/test/Altinn.App.Api.Tests/Middleware/TelemetryEnrichingMiddlewareTests.cs @@ -17,7 +17,8 @@ public TelemetryEnrichingMiddlewareTests(WebApplicationFactory factory, private (TelemetrySink Telemetry, Func Request) AnalyzeTelemetry( string token, - bool includeTraceContext = false + bool includeTraceContext = false, + bool includePdfHeader = false ) { this.OverrideServicesForThisTest = (services) => @@ -35,6 +36,8 @@ public TelemetryEnrichingMiddlewareTests(WebApplicationFactory factory, var telemetry = this.Services.GetRequiredService(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (includePdfHeader) + client.DefaultRequestHeaders.Add("X-Altinn-IsPdf", "true"); return (telemetry, async () => await client.GetStringAsync($"/{org}/{app}/api/v1/applicationmetadata")); } @@ -110,4 +113,32 @@ public async Task Should_Always_Be_A_Root_Trace() await telemetry.Snapshot(activity, c => c.ScrubMember(Telemetry.Labels.UserPartyId)); } + + [Fact] + public async Task Should_Always_Be_A_Root_Trace_Unless_Pdf() + { + var partyId = Random.Shared.Next(); + var principal = PrincipalUtil.GetUserPrincipal(10, partyId, 4); + var token = JwtTokenMock.GenerateToken(principal, new TimeSpan(1, 1, 1)); + + var (telemetry, request) = AnalyzeTelemetry(token, includeTraceContext: true, includePdfHeader: true); + ActivitySpanId parentSpanId; + using (var parentActivity = telemetry.Object.ActivitySource.StartActivity("TestParentActivity")) + { + Assert.NotNull(parentActivity); + parentSpanId = parentActivity.SpanId; + await request(); + } + await telemetry.WaitForServerActivity(); + + var activities = telemetry.CapturedActivities; + var activity = Assert.Single(activities, a => a.Kind == ActivityKind.Server); + Assert.True(activity.IsAllDataRequested); + Assert.True(activity.Recorded); + Assert.Equal("Microsoft.AspNetCore", activity.Source.Name); + Assert.NotNull(activity.ParentId); + Assert.Equal(parentSpanId, activity.ParentSpanId); + + await telemetry.Snapshot(activity, c => c.ScrubMember(Telemetry.Labels.UserPartyId)); + } } diff --git a/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs index aafcbc907..fbd689d81 100644 --- a/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs @@ -86,7 +86,9 @@ public async Task ValidRequest_ShouldReturnPdf() ); var httpClient = new HttpClient(delegatingHandler); + var logger = new Mock>(); var pdfGeneratorClient = new PdfGeneratorClient( + logger.Object, httpClient, _pdfGeneratorSettingsOptions, _platformSettingsOptions, @@ -114,7 +116,9 @@ public async Task ValidRequest_PdfGenerationFails_ShouldThrowException() ); var httpClient = new HttpClient(delegatingHandler); + var logger = new Mock>(); var pdfGeneratorClient = new PdfGeneratorClient( + logger.Object, httpClient, _pdfGeneratorSettingsOptions, _platformSettingsOptions,