From 54c541a64d89eac631a436f594f9641d7aa0f5d1 Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Tue, 19 Mar 2024 15:47:59 +0300 Subject: [PATCH] Imported Tingle.AspNetCore.ApplicationInsights --- .github/workflows/cleanup.yml | 1 + README.md | 1 + Tingle.Extensions.sln | 21 ++ .../ApplicationInsightsSample.csproj | 11 + .../Controllers/ValuesController.cs | 32 +++ samples/ApplicationInsightsSample/Program.cs | 16 ++ .../Properties/launchSettings.json | 27 +++ samples/ApplicationInsightsSample/README.md | 19 ++ .../appsettings.json | 13 ++ samples/Directory.Build.props | 1 + .../ActivitySourceDependencyCollector.cs | 205 ++++++++++++++++++ .../ExtrasTelemetryInitializer.cs | 63 ++++++ .../HeadersTelemetryInitializer.cs | 23 ++ .../HttpContextExtensions.cs | 27 +++ .../IServiceCollectionExtensions.cs | 67 ++++++ .../InsightsJsonSerializerContext.cs | 7 + .../README.md | 58 +++++ ...ngle.AspNetCore.ApplicationInsights.csproj | 17 ++ .../TrackProblemsAttribute.cs | 43 ++++ .../ExtrasTelemetryInitializerTests.cs | 113 ++++++++++ .../HeadersTelemetryInitializerTests.cs | 55 +++++ ...spNetCore.ApplicationInsights.Tests.csproj | 7 + 22 files changed, 827 insertions(+) create mode 100644 samples/ApplicationInsightsSample/ApplicationInsightsSample.csproj create mode 100644 samples/ApplicationInsightsSample/Controllers/ValuesController.cs create mode 100644 samples/ApplicationInsightsSample/Program.cs create mode 100644 samples/ApplicationInsightsSample/Properties/launchSettings.json create mode 100644 samples/ApplicationInsightsSample/README.md create mode 100644 samples/ApplicationInsightsSample/appsettings.json create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/ActivitySourceDependencyCollector.cs create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/ExtrasTelemetryInitializer.cs create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/HeadersTelemetryInitializer.cs create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/HttpContextExtensions.cs create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/IServiceCollectionExtensions.cs create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/InsightsJsonSerializerContext.cs create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/README.md create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/Tingle.AspNetCore.ApplicationInsights.csproj create mode 100644 src/Tingle.AspNetCore.ApplicationInsights/TrackProblemsAttribute.cs create mode 100644 tests/Tingle.AspNetCore.ApplicationInsights.Tests/ExtrasTelemetryInitializerTests.cs create mode 100644 tests/Tingle.AspNetCore.ApplicationInsights.Tests/HeadersTelemetryInitializerTests.cs create mode 100644 tests/Tingle.AspNetCore.ApplicationInsights.Tests/Tingle.AspNetCore.ApplicationInsights.Tests.csproj diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 1a6e07c4..906ceffc 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -12,6 +12,7 @@ jobs: fail-fast: true matrix: suite: + - { name: 'Tingle.AspNetCore.ApplicationInsights' } - { name: 'Tingle.AspNetCore.Authentication' } - { name: 'Tingle.AspNetCore.Authorization' } - { name: 'Tingle.AspNetCore.DataProtection.MongoDB' } diff --git a/README.md b/README.md index 2995ceaf..4b572fda 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This repository contains projects/libraries for adding useful functionality to . |Package|Description| |--|--| +|[`Tingle.AspNetCore.ApplicationInsights`](https://www.nuget.org/packages/Tingle.AspNetCore.ApplicationInsights/)|Convenience functionality for Application Insights on AspNetCore such as collecting problem details. See [docs](./src/Tingle.AspNetCore.ApplicationInsights/README.md) and [sample](./samples/ApplicationInsightsSample)| |[`Tingle.AspNetCore.Authentication`](https://www.nuget.org/packages/Tingle.AspNetCore.Authentication/)|Convenience authentication functionality such as pass through and pre-shared key authentication mechanisms. See [docs](./src/Tingle.AspNetCore.Authentication/README.md) and [sample](./samples/AuthenticationSample)| |[`Tingle.AspNetCore.Authorization`](https://www.nuget.org/packages/Tingle.AspNetCore.Authorization/)|Additional authorization functionality such as handlers and requirements. See [docs](./src/Tingle.AspNetCore.Authorization/README.md) and [sample](./samples/AuthorizationSample)| |[`Tingle.AspNetCore.DataProtection.MongoDB`](https://www.nuget.org/packages/Tingle.AspNetCore.DataProtection.MongoDB/)|Data Protection store in [MongoDB](https://mongodb.com) for ASP.NET Core. See [docs](./src/Tingle.AspNetCore.DataProtection.MongoDB/README.md) and [sample](./samples/DataProtectionMongoDBSample).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index d5eb3110..56afd79d 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -8,6 +8,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9546186D-D4E src\Directory.Build.props = src\Directory.Build.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.ApplicationInsights", "src\Tingle.AspNetCore.ApplicationInsights\Tingle.AspNetCore.ApplicationInsights.csproj", "{644FFD56-7880-4EFB-BBB1-E29FC7EBFD3F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authentication", "src\Tingle.AspNetCore.Authentication\Tingle.AspNetCore.Authentication.csproj", "{98F3A2B7-5774-4E38-8FA0-FA13B6134454}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authorization", "src\Tingle.AspNetCore.Authorization\Tingle.AspNetCore.Authorization.csproj", "{22690754-DCFB-4CD2-968D-239C1952B52C}" @@ -49,6 +51,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{815F0941 tests\Directory.Build.props = tests\Directory.Build.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.ApplicationInsights.Tests", "tests\Tingle.AspNetCore.ApplicationInsights.Tests\Tingle.AspNetCore.ApplicationInsights.Tests.csproj", "{6CD03B65-8D8F-42D4-8E81-33D9FD932AEE}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authentication.Tests", "tests\Tingle.AspNetCore.Authentication.Tests\Tingle.AspNetCore.Authentication.Tests.csproj", "{A324CC70-36DD-4B38-9EC8-9069F6130FAC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authorization.Tests", "tests\Tingle.AspNetCore.Authorization.Tests\Tingle.AspNetCore.Authorization.Tests.csproj", "{E67CB6B9-6F42-4E63-9603-810B5B9FBF57}" @@ -90,6 +94,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{CE71 samples\Directory.Build.props = samples\Directory.Build.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationInsightsSample", "samples\ApplicationInsightsSample\ApplicationInsightsSample.csproj", "{3C4028B5-26F4-464A-9B0A-F2EA83E6C3C5}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreSessionState", "samples\AspNetCoreSessionState\AspNetCoreSessionState.csproj", "{CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthenticationSample", "samples\AuthenticationSample\AuthenticationSample.csproj", "{E04EC969-2539-46E9-B918-8C8B7BEB8828}" @@ -116,6 +122,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {644FFD56-7880-4EFB-BBB1-E29FC7EBFD3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {644FFD56-7880-4EFB-BBB1-E29FC7EBFD3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {644FFD56-7880-4EFB-BBB1-E29FC7EBFD3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {644FFD56-7880-4EFB-BBB1-E29FC7EBFD3F}.Release|Any CPU.Build.0 = Release|Any CPU {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Debug|Any CPU.Build.0 = Debug|Any CPU {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -188,6 +198,10 @@ Global {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.Build.0 = Release|Any CPU + {6CD03B65-8D8F-42D4-8E81-33D9FD932AEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CD03B65-8D8F-42D4-8E81-33D9FD932AEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CD03B65-8D8F-42D4-8E81-33D9FD932AEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CD03B65-8D8F-42D4-8E81-33D9FD932AEE}.Release|Any CPU.Build.0 = Release|Any CPU {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Debug|Any CPU.Build.0 = Debug|Any CPU {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -260,6 +274,10 @@ Global {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.Build.0 = Release|Any CPU + {3C4028B5-26F4-464A-9B0A-F2EA83E6C3C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C4028B5-26F4-464A-9B0A-F2EA83E6C3C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C4028B5-26F4-464A-9B0A-F2EA83E6C3C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C4028B5-26F4-464A-9B0A-F2EA83E6C3C5}.Release|Any CPU.Build.0 = Release|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Debug|Any CPU.Build.0 = Debug|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -301,6 +319,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {644FFD56-7880-4EFB-BBB1-E29FC7EBFD3F} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {98F3A2B7-5774-4E38-8FA0-FA13B6134454} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {22690754-DCFB-4CD2-968D-239C1952B52C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} @@ -319,6 +338,7 @@ Global {A803DE4B-B050-48F2-82A1-8E947D8FB96C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {29035EF2-2391-4441-AAC5-85AA43586EEB} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {6CD03B65-8D8F-42D4-8E81-33D9FD932AEE} = {815F0941-3B70-4705-A583-AF627559595C} {A324CC70-36DD-4B38-9EC8-9069F6130FAC} = {815F0941-3B70-4705-A583-AF627559595C} {E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C} {E28F4E8D-148B-4583-A27D-E1DA2CC08167} = {815F0941-3B70-4705-A583-AF627559595C} @@ -337,6 +357,7 @@ Global {978023EA-2ED5-4A28-96AD-4BB914EF2BE5} = {815F0941-3B70-4705-A583-AF627559595C} {A8AB6597-DEBB-4828-AE1B-A11FD818E428} = {815F0941-3B70-4705-A583-AF627559595C} {8E611861-09A3-4AE4-8392-E7CB9BE02B3B} = {815F0941-3B70-4705-A583-AF627559595C} + {3C4028B5-26F4-464A-9B0A-F2EA83E6C3C5} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {E04EC969-2539-46E9-B918-8C8B7BEB8828} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {B97964EC-658A-4205-AA5A-BB814B191C35} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} diff --git a/samples/ApplicationInsightsSample/ApplicationInsightsSample.csproj b/samples/ApplicationInsightsSample/ApplicationInsightsSample.csproj new file mode 100644 index 00000000..59ab1a4a --- /dev/null +++ b/samples/ApplicationInsightsSample/ApplicationInsightsSample.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/samples/ApplicationInsightsSample/Controllers/ValuesController.cs b/samples/ApplicationInsightsSample/Controllers/ValuesController.cs new file mode 100644 index 00000000..17ef25f6 --- /dev/null +++ b/samples/ApplicationInsightsSample/Controllers/ValuesController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; + +#pragma warning disable IDE0060 // Remove unused parameter + +namespace ApplicationInsightsSample.Controllers; + +[Route("api/[controller]")] +[ApiController] +[TrackProblems] +public class ValuesController : ControllerBase +{ + // GET: api/Values + [HttpGet] + public IEnumerable Get() + { + return new string[] { "value1", "value2" }; + } + + // GET: api/Values/5 + [HttpGet("{id}", Name = "Get")] + public string Get(int id) + { + return "value"; + } + + // POST: api/Values + [HttpPost] + public IActionResult Post([FromBody] string value) + { + return Problem(title: "test_error_code", detail: "This is a test error", statusCode: 400); + } +} diff --git a/samples/ApplicationInsightsSample/Program.cs b/samples/ApplicationInsightsSample/Program.cs new file mode 100644 index 00000000..585814d1 --- /dev/null +++ b/samples/ApplicationInsightsSample/Program.cs @@ -0,0 +1,16 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddApplicationInsightsTelemetry() + .AddApplicationInsightsTelemetryHeaders() + .AddApplicationInsightsTelemetryExtras(); + +builder.Services.AddControllers() + .AddControllersAsServices(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.MapControllers(); + +app.Run(); diff --git a/samples/ApplicationInsightsSample/Properties/launchSettings.json b/samples/ApplicationInsightsSample/Properties/launchSettings.json new file mode 100644 index 00000000..a0061c5c --- /dev/null +++ b/samples/ApplicationInsightsSample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50073", + "sslPort": 44328 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ApplicationInsightsSample": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/ApplicationInsightsSample/README.md b/samples/ApplicationInsightsSample/README.md new file mode 100644 index 00000000..315e7040 --- /dev/null +++ b/samples/ApplicationInsightsSample/README.md @@ -0,0 +1,19 @@ +##USAGE + +## Application Insights Instrumentation Key +Get the key for your ApplicationInsights resource on Azure. + +Either replace the `00000000-0000-0000-0000-000000000000` in `appsettings.json` with the key, +or configure it using the user-secret command, e.g.: + +dotnet user-secrets set AppSettings:YouTubeApiKey + + +## Running the sample + +1. Run the application (F5), +2. Make a HTTP GET request to https://{your-endpoint}/api/values e.g. `https://localhost:50073/api/values` +3. Make a HTTP POST request to https://{your-endpoint}/api/values e.g. `https://localhost:50073/api/values` +4. Wait for about 2 minutes and check the insights for both requests +5. The failed request should have more data e.g. `Client`, `IpAddress`, `problem.title`, 'problem.detail', `headers` e.t.c. + diff --git a/samples/ApplicationInsightsSample/appsettings.json b/samples/ApplicationInsightsSample/appsettings.json new file mode 100644 index 00000000..a6190c5a --- /dev/null +++ b/samples/ApplicationInsightsSample/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + + // replace the key below with the actual instrumentation key, or set it in the secrets + "ApplicationInsights:InstrumentationKey": "00000000-0000-0000-0000-000000000000" +} diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props index 899830dd..6a3def23 100644 --- a/samples/Directory.Build.props +++ b/samples/Directory.Build.props @@ -4,6 +4,7 @@ $(NoWarn);CA1822;CA1031;IDE0060;IDE0059 + Exe false diff --git a/src/Tingle.AspNetCore.ApplicationInsights/ActivitySourceDependencyCollector.cs b/src/Tingle.AspNetCore.ApplicationInsights/ActivitySourceDependencyCollector.cs new file mode 100644 index 00000000..aa4c1169 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/ActivitySourceDependencyCollector.cs @@ -0,0 +1,205 @@ +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.Extensions.Hosting; +using System.Diagnostics; + +namespace Tingle.AspNetCore.ApplicationInsights; + +// See https://github.com/microsoft/ApplicationInsights-dotnet/issues/1427 +internal class ActivitySourceDependencyCollector : IHostedService +{ + private readonly TelemetryClient client; + private readonly IDictionary activities; + private readonly ActivityListener? listener; + + public ActivitySourceDependencyCollector(TelemetryClient client, IDictionary activities) + { + this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.activities = activities ?? throw new ArgumentNullException(nameof(activities)); + + if (activities.Count > 0) + { + listener = new ActivityListener + { + ShouldListenTo = ShouldListenTo, + Sample = Sample, + ActivityStopped = ActivityStopped, + }; + } + } + + private bool ShouldListenTo(ActivitySource source) => !string.IsNullOrWhiteSpace(source.Name) && activities.ContainsKey(source.Name); + + private ActivitySamplingResult Sample(ref ActivityCreationOptions options) => activities[options.Source.Name]; + + internal void ActivityStopped(Activity activity) + { + // extensibility point - can chain more telemetry extraction methods here + var telemetry = ExtractDependencyTelemetry(activity); + if (telemetry == null) + { + return; + } + + // properly fill dependency telemetry operation context + if (activity.IdFormat == ActivityIdFormat.W3C) + { + telemetry.Context.Operation.Id = activity.TraceId.ToHexString(); + if (activity.ParentSpanId != default) + { + telemetry.Context.Operation.ParentId = activity.ParentSpanId.ToHexString(); + } + + telemetry.Id = activity.SpanId.ToHexString(); + } + else + { + telemetry.Id = activity.Id; + telemetry.Context.Operation.Id = activity.RootId; + telemetry.Context.Operation.ParentId = activity.ParentId; + } + + telemetry.Timestamp = activity.StartTimeUtc; + + //telemetry.Properties["DiagnosticSource"] = diagnosticListener.Name; + telemetry.Properties["Activity"] = activity.OperationName; + + client.TrackDependency(telemetry); + } + + internal static DependencyTelemetry ExtractDependencyTelemetry(Activity activity) + { + var telemetry = new DependencyTelemetry + { + Id = activity.Id, + Duration = activity.Duration, + Name = activity.OperationName, + }; + + Uri? requestUri = null; + string? component = null; + string? queryStatement = null; + string? httpUrl = null; + string? peerAddress = null; + string? peerService = null; + + foreach (KeyValuePair tag in activity.Tags) + { + // interpret Tags as defined by OpenTracing conventions + // https://github.com/opentracing/specification/blob/master/semantic_conventions.md + switch (tag.Key) + { + case "component": + { + component = tag.Value; + break; + } + + case "db.statement": + { + queryStatement = tag.Value; + break; + } + + case "error": + { + if (bool.TryParse(tag.Value, out var failed)) + { + telemetry.Success = !failed; + continue; // skip Properties + } + + break; + } + + case "http.status_code": + { + telemetry.ResultCode = tag.Value; + continue; // skip Properties + } + + case "http.method": + { + continue; // skip Properties + } + + case "http.url": + { + httpUrl = tag.Value; + if (Uri.TryCreate(tag.Value, UriKind.RelativeOrAbsolute, out requestUri)) + { + continue; // skip Properties + } + + break; + } + + case "peer.address": + { + peerAddress = tag.Value; + break; + } + + case "peer.hostname": + { + telemetry.Target = tag.Value; + continue; // skip Properties + } + + case "peer.service": + { + peerService = tag.Value; + break; + } + } + + // if more than one tag with the same name is specified, the first one wins + // verify if still needed once https://github.com/Microsoft/ApplicationInsights-dotnet/issues/562 is resolved + if (!telemetry.Properties.ContainsKey(tag.Key)) + { + telemetry.Properties.Add(tag); + } + } + + if (string.IsNullOrEmpty(telemetry.Type)) + { + telemetry.Type = peerService ?? component /*?? diagnosticListener.Name*/ ?? activity.OperationName; + } + + if (string.IsNullOrEmpty(telemetry.Target)) + { + // 'peer.address' can be not user-friendly, thus use only if nothing else specified + telemetry.Target = requestUri?.Host ?? peerAddress; + } + + if (string.IsNullOrEmpty(telemetry.Name)) + { + telemetry.Name = activity.OperationName; + } + + if (string.IsNullOrEmpty(telemetry.Data)) + { + telemetry.Data = queryStatement ?? requestUri?.OriginalString ?? httpUrl; + } + + return telemetry; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + if (listener is not null) + { + ActivitySource.AddActivityListener(listener); + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + listener?.Dispose(); + return Task.CompletedTask; + } +} diff --git a/src/Tingle.AspNetCore.ApplicationInsights/ExtrasTelemetryInitializer.cs b/src/Tingle.AspNetCore.ApplicationInsights/ExtrasTelemetryInitializer.cs new file mode 100644 index 00000000..bee0ca52 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/ExtrasTelemetryInitializer.cs @@ -0,0 +1,63 @@ +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Http; +using System.Text.RegularExpressions; + +namespace Tingle.AspNetCore.ApplicationInsights; + +internal partial class ExtrasTelemetryInitializer(IHttpContextAccessor httpContextAccessor) : ITelemetryInitializer +{ + private const string HeaderXAppPackageId = "X-App-Package-Id"; + private const string HeaderXAppVersionName = "X-App-Version-Name"; + private const string HeaderXAppVersionCode = "X-App-Version-Code"; + private const string KeyAppClient = "Client"; + private const string KeyAppIpAddress = "IpAddress"; + + public void Initialize(ITelemetry telemetry) + { + static void AddIfNotExsits(IDictionary dictionary, string key, string? value) + { + key = GetPrefixFormat().Replace(key, string.Empty).ToLowerInvariant(); + if (!dictionary.ContainsKey(key) && !string.IsNullOrWhiteSpace(value)) dictionary[key] = value; + } + + static string? GetIpAddress(System.Net.IPAddress? address) + { + if (address is null) return null; + + // if the IP is an IPv4 mapped to IPv6, remap it + var addr = address; + if (addr.IsIPv4MappedToIPv6) + { + addr = addr.MapToIPv4(); + } + + return addr.ToString(); + } + + HttpContext? httpContext; + if (telemetry is RequestTelemetry request && (httpContext = httpContextAccessor?.HttpContext) != null) + { + var headers = httpContext.Request.Headers; + + // populate the package Id + AddIfNotExsits(request.Properties, HeaderXAppPackageId, headers[HeaderXAppPackageId].FirstOrDefault()); + + // populate the version name + AddIfNotExsits(request.Properties, HeaderXAppVersionName, headers[HeaderXAppVersionName].FirstOrDefault()); + + // populate the version code + AddIfNotExsits(request.Properties, HeaderXAppVersionCode, headers[HeaderXAppVersionCode].FirstOrDefault()); + + // populate the client from user-agent + AddIfNotExsits(request.Properties, KeyAppClient, httpContext.GetUserAgent()); + + // populate the IP address + AddIfNotExsits(request.Properties, KeyAppIpAddress, GetIpAddress(httpContext.Connection?.RemoteIpAddress)); + } + } + + [GeneratedRegex("^[Xx]-", RegexOptions.Compiled)] + private static partial Regex GetPrefixFormat(); +} diff --git a/src/Tingle.AspNetCore.ApplicationInsights/HeadersTelemetryInitializer.cs b/src/Tingle.AspNetCore.ApplicationInsights/HeadersTelemetryInitializer.cs new file mode 100644 index 00000000..15424894 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/HeadersTelemetryInitializer.cs @@ -0,0 +1,23 @@ +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Http; +using SC = Tingle.AspNetCore.ApplicationInsights.InsightsJsonSerializerContext; + +namespace Tingle.AspNetCore.ApplicationInsights; + +internal class HeadersTelemetryInitializer(IHttpContextAccessor httpContextAccessor) : ITelemetryInitializer +{ + private const string KeyHeaders = "Headers"; + + public void Initialize(ITelemetry telemetry) + { + HttpContext? httpContext; + if (telemetry is RequestTelemetry rt && (httpContext = httpContextAccessor?.HttpContext) != null) + { + var headers = httpContext.Request.Headers; + var dict = headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Select(s => s!).ToArray()); + rt.Properties[KeyHeaders] = System.Text.Json.JsonSerializer.Serialize(dict, SC.Default.IDictionaryStringStringArray); + } + } +} diff --git a/src/Tingle.AspNetCore.ApplicationInsights/HttpContextExtensions.cs b/src/Tingle.AspNetCore.ApplicationInsights/HttpContextExtensions.cs new file mode 100644 index 00000000..c228a71b --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/HttpContextExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Extension methods on +/// +public static class HttpContextExtensions +{ + /// + /// Get the UserAgent making the request. + /// This is usually present in the User-Agent header. + /// + /// The to extract from + /// + public static string? GetUserAgent(this HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (httpContext.Request.Headers.TryGetValue(HeaderNames.UserAgent, out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } +} diff --git a/src/Tingle.AspNetCore.ApplicationInsights/IServiceCollectionExtensions.cs b/src/Tingle.AspNetCore.ApplicationInsights/IServiceCollectionExtensions.cs new file mode 100644 index 00000000..8d8aef49 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/IServiceCollectionExtensions.cs @@ -0,0 +1,67 @@ +using Microsoft.ApplicationInsights.Extensibility; +using System.Diagnostics; +using Tingle.AspNetCore.ApplicationInsights; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for application insights +/// +public static class IServiceCollectionExtensions +{ + /// + /// Adds that collects details about the request source + /// + /// + /// + public static IServiceCollection AddApplicationInsightsTelemetryExtras(this IServiceCollection services) + { + // Required to resolve the request from the HttpContext + services.AddHttpContextAccessor(); + + // according to docs link below, this registration should be singleton + // https://docs.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core#adding-telemetryinitializers + return services.AddSingleton(); + } + + /// + /// Adds that collects all request headers + /// + /// + /// + public static IServiceCollection AddApplicationInsightsTelemetryHeaders(this IServiceCollection services) + { + // Required to resolve the request from the HttpContext + services.AddHttpContextAccessor(); + + // according to docs link below, this registration should be singleton + // https://docs.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core#adding-telemetryinitializers + return services.AddSingleton(); + } + + /// + /// Adds dependency collector for the new . + /// + /// + /// + /// + /// + public static IServiceCollection AddActivitySourceDependencyCollector(this IServiceCollection services, + IEnumerable activities, + ActivitySamplingResult samplingResult = ActivitySamplingResult.PropagationData) + { + return services.AddActivitySourceDependencyCollector(activities.ToDictionary(a => a, _ => samplingResult)); + } + + /// + /// Adds dependency collector for the new . + /// + /// + /// + /// + public static IServiceCollection AddActivitySourceDependencyCollector(this IServiceCollection services, + IDictionary activities) + { + return services.AddHostedService(p => ActivatorUtilities.CreateInstance(p, [activities])); + } +} diff --git a/src/Tingle.AspNetCore.ApplicationInsights/InsightsJsonSerializerContext.cs b/src/Tingle.AspNetCore.ApplicationInsights/InsightsJsonSerializerContext.cs new file mode 100644 index 00000000..5c249dc3 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/InsightsJsonSerializerContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.ApplicationInsights; + +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(IDictionary))] +internal partial class InsightsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.AspNetCore.ApplicationInsights/README.md b/src/Tingle.AspNetCore.ApplicationInsights/README.md new file mode 100644 index 00000000..abe151bd --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/README.md @@ -0,0 +1,58 @@ +# Tingle.AspNetCore.ApplicationInsights + +> [!CAUTION] +> This documentation for `Tingle.AspNetCore.ApplicationInsights` may not cover the most recent version of the code. Use it sparingly +> +> See + +This is a customized library developed by Tingle. The `ApplicationInsights` package in AspNetCore offers functionality to understand how the application is performing and how it is being used. `Tingle.AspNetCore.ApplicationInsights` has additional functionality for Application Insights on AspNetCore mainly used in building Tingle APIs and Services. + +## Adding to Service Collections + +The following logic is added to `Program.cs` file: + +```csharp +// Enables Application Insights telemetry collection +builder.Services.AddApplicationInsightsTelemetryExtras(builder.Configuration) +``` + +This library has a number of extensibility points, one of them being the telemetry initializer. To implement the telemetry initializer, a class is created that implements the `ITelemetryInitializer` interface. In this case, `ExtrasTelemetryInitializer` is a class implementing `ITelemetryInitializer` interface. The `ExtrasTelemetryInitializer` extends ApplicationInsights telemetry collection by supplying additional information about the application which includes the `AppPackageId`, `AppVersionName`, `AppVersionCode`, `AppClient`, `AppIpAddress` and `AppKind`. + +In the `ITelemetryInitializer` interface's `Initialize` method, a `HttpContext` object is created. Whenever a new HTTP request or response is made, a `HttpContext` object is created which wraps all HTTP related information in one place. The `HttpContext` object is accessed through the `IHttpContextAccessor` and its default implementation `HttpAccessor`. It is necessary to use the `IHttpContextAccessor` so as to access the `HttpContext` object within a service. + +The initializers are used to mark every collected telemetry item with the current web request identity so that traces and exceptions can be correlated to corresponding requests. + +The library is used to track error details in application insights when the response is `BadRequestObjectResult` with the value of type `ProblemDetails`. + +From the request header, the application's package ID, version name, version code, client, IP address and kind properties are extracted so that the traces and exceptions can be correlated/matched to the corresponding requests. + +## Configuration + +The instrumentation key is specified in configuration. The following code sample shows how to specify an instrumentation key in `appsettings.json.` + +```json +{ + "ApplicationInsights:InstrumentationKey":"#{ApplicationInsightsInstrumentationKey}#" +} +``` + +## Sample Usage + +```csharp +[TrackProblems] +[ApiVersion("1")] +[Route("v{version:apiVersion}/[controller]")] +public class DummyController : ControllerBase +{ + [HttpPost] + [ProducesResponseType(typeof(RequestEntry), 200)] + [ProducesResponseType(typeof(ErrorModel), 400)] + public async Task SendAsync([FromBody] SendRequestModel model) + { + ... + // In case of a bad request + return Problem(title: "error_title", description: "more detailed description", statusCode: 400); + ... + } +} +``` diff --git a/src/Tingle.AspNetCore.ApplicationInsights/Tingle.AspNetCore.ApplicationInsights.csproj b/src/Tingle.AspNetCore.ApplicationInsights/Tingle.AspNetCore.ApplicationInsights.csproj new file mode 100644 index 00000000..30e5f540 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/Tingle.AspNetCore.ApplicationInsights.csproj @@ -0,0 +1,17 @@ + + + + Convenience functionality for Application Insights on AspNetCore + net7.0;net8.0 + + + + + + + + + + + + diff --git a/src/Tingle.AspNetCore.ApplicationInsights/TrackProblemsAttribute.cs b/src/Tingle.AspNetCore.ApplicationInsights/TrackProblemsAttribute.cs new file mode 100644 index 00000000..5a5b1a36 --- /dev/null +++ b/src/Tingle.AspNetCore.ApplicationInsights/TrackProblemsAttribute.cs @@ -0,0 +1,43 @@ +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.AspNetCore.Mvc.Filters; +using SC = Tingle.AspNetCore.ApplicationInsights.InsightsJsonSerializerContext; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// Track the problem details in application insights when the response is a +/// with value of type . +/// The properties are seen as custom properties of an application insights telemetry record +/// +/// Whether to include errors from . +public sealed class TrackProblemsAttribute(bool includeErrors = true) : ActionFilterAttribute +{ + /// + /// Sets the custom properties in + /// + /// + public override void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ObjectResult or) + { + if (or.Value is ProblemDetails pd) + { + var telemetry = context.HttpContext.Features.Get(); + if (telemetry != null) + { + telemetry.Properties["problem.type"] = pd.Type; + telemetry.Properties["problem.title"] = pd.Title; + telemetry.Properties["problem.status"] = pd.Status.ToString(); + telemetry.Properties["problem.detail"] = pd.Detail; + telemetry.Properties["problem.instance"] = pd.Instance; + + // collect validation errors for validation problems type if allowed and available + if (includeErrors && pd is ValidationProblemDetails vpd) + { + telemetry.Properties["problem.errors"] = System.Text.Json.JsonSerializer.Serialize(vpd.Errors, SC.Default.IDictionaryStringStringArray); + } + } + } + } + } +} diff --git a/tests/Tingle.AspNetCore.ApplicationInsights.Tests/ExtrasTelemetryInitializerTests.cs b/tests/Tingle.AspNetCore.ApplicationInsights.Tests/ExtrasTelemetryInitializerTests.cs new file mode 100644 index 00000000..23be4b0d --- /dev/null +++ b/tests/Tingle.AspNetCore.ApplicationInsights.Tests/ExtrasTelemetryInitializerTests.cs @@ -0,0 +1,113 @@ +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using System.Net; + +namespace Tingle.AspNetCore.ApplicationInsights.Tests; + +public class ExtrasTelemetryInitializerTests +{ + [Fact] + public void Skips_Non_RequestTelemetry() + { + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var initializer = new ExtrasTelemetryInitializer(httpContextAccessor); + var telemetry = new TraceTelemetry(); + Assert.Empty(telemetry.Properties); + initializer.Initialize(telemetry); + Assert.Empty(telemetry.Properties); // remains unchanged + } + + [Fact] + public void AddsAllProperties() + { + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + // populate values in the context and the request + var ip = MakeRandomIPAddress(); + httpContext.Connection.RemoteIpAddress = ip; + var httpRequest = httpContext.Request; + httpRequest.Headers[HeaderNames.UserAgent] = "okhttp/3.0.3"; + httpRequest.Headers["X-App-Package-Id"] = "com.tingle.app"; + httpRequest.Headers["X-App-Version-Name"] = "1.0.1"; + httpRequest.Headers["X-App-Version-Code"] = "112"; + + // prepare the initializer + var initializer = new ExtrasTelemetryInitializer(httpContextAccessor); + var telemetry = new RequestTelemetry(); + Assert.Empty(telemetry.Properties); + + // execute + initializer.Initialize(telemetry); + + // assert + var properties = new SortedDictionary(telemetry.Properties); + var actualKeys = properties.Keys.ToList(); + var actualValues = properties.Values.ToList(); + Assert.Equal(["app-package-id", "app-version-code", "app-version-name", "client", "ipaddress"], actualKeys); + Assert.Equal(["com.tingle.app", "112", "1.0.1", "okhttp/3.0.3", ip.ToString()], actualValues); + } + + [Fact] + public void SkipsNonPresentSources() + { + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + // populate values in the context and the request + var ip = MakeRandomIPAddress(); + httpContext.Connection.RemoteIpAddress = ip; + var httpRequest = httpContext.Request; + httpRequest.Headers[HeaderNames.UserAgent] = "Tingle.Services.Clients/1.0.0"; + + // prepare the initializer + var initializer = new ExtrasTelemetryInitializer(httpContextAccessor); + var telemetry = new RequestTelemetry(); + Assert.Empty(telemetry.Properties); + + // execute + initializer.Initialize(telemetry); + + // assert + var properties = new SortedDictionary(telemetry.Properties); + var actualKeys = properties.Keys.ToList(); + var actualValues = properties.Values.ToList(); + Assert.Equal(["client", "ipaddress"], actualKeys); + Assert.Equal(["Tingle.Services.Clients/1.0.0", ip.ToString()], actualValues); + } + + [Fact] + public void SkipsNullIp() + { + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + // populate values in the context and the request + var httpRequest = httpContext.Request; + httpRequest.Headers[HeaderNames.UserAgent] = "Tingle.Services.Clients/1.0.0"; + + // prepare the initializer + var initializer = new ExtrasTelemetryInitializer(httpContextAccessor); + var telemetry = new RequestTelemetry(); + Assert.Empty(telemetry.Properties); + + // execute + initializer.Initialize(telemetry); + + // assert + var properties = new SortedDictionary(telemetry.Properties); + var actualKeys = properties.Keys.ToList(); + var actualValues = properties.Values.ToList(); + Assert.Equal(["client"], actualKeys); + Assert.Equal(["Tingle.Services.Clients/1.0.0"], actualValues); + } + + private static IPAddress MakeRandomIPAddress() + { + var rnd = new Random(BitConverter.ToInt32(Guid.NewGuid().ToByteArray(), 0)); + var bytes = Enumerable.Range(0, 4).Select(_ => Convert.ToByte(rnd.Next(1, 254))).ToArray(); + return new IPAddress(bytes); + } +} diff --git a/tests/Tingle.AspNetCore.ApplicationInsights.Tests/HeadersTelemetryInitializerTests.cs b/tests/Tingle.AspNetCore.ApplicationInsights.Tests/HeadersTelemetryInitializerTests.cs new file mode 100644 index 00000000..2582ab52 --- /dev/null +++ b/tests/Tingle.AspNetCore.ApplicationInsights.Tests/HeadersTelemetryInitializerTests.cs @@ -0,0 +1,55 @@ +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; + +namespace Tingle.AspNetCore.ApplicationInsights.Tests; + +public class HeadersTelemetryInitializerTests +{ + [Fact] + public void Skips_Non_RequestTelemetry() + { + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var initializer = new HeadersTelemetryInitializer(httpContextAccessor); + var telemetry = new TraceTelemetry(); + Assert.Empty(telemetry.Properties); + initializer.Initialize(telemetry); + Assert.Empty(telemetry.Properties); // remains unchanged + } + + [Fact] + public void AddsAllProperties() + { + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + // populate values in the context and the request + var httpRequest = httpContext.Request; + httpRequest.Headers[HeaderNames.ContentType] = "application/json"; + httpRequest.Headers[HeaderNames.ETag] = "AAAAAAAAAAA="; + httpRequest.Headers["X-Workspace-Id"] = "112"; + + // prepare the initializer + var initializer = new HeadersTelemetryInitializer(httpContextAccessor); + var telemetry = new RequestTelemetry(); + Assert.Empty(telemetry.Properties); + + // execute + initializer.Initialize(telemetry); + + // assert + var expected = new Dictionary + { + ["Content-Type"] = ["application/json"], + ["ETag"] = ["AAAAAAAAAAA="], + ["X-Workspace-Id"] = ["112"], + }; + var properties = new SortedDictionary(telemetry.Properties); + var kvp = Assert.Single(properties); + Assert.Equal("Headers", kvp.Key); + Assert.NotNull(kvp.Value); + var actual = System.Text.Json.JsonSerializer.Deserialize>(kvp.Value); + Assert.Equal(expected, actual); + } +} diff --git a/tests/Tingle.AspNetCore.ApplicationInsights.Tests/Tingle.AspNetCore.ApplicationInsights.Tests.csproj b/tests/Tingle.AspNetCore.ApplicationInsights.Tests/Tingle.AspNetCore.ApplicationInsights.Tests.csproj new file mode 100644 index 00000000..0c79d794 --- /dev/null +++ b/tests/Tingle.AspNetCore.ApplicationInsights.Tests/Tingle.AspNetCore.ApplicationInsights.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + +