From 25ed9fea28e139ea7b2942eb2f8dbfa8e0681443 Mon Sep 17 00:00:00 2001 From: Alireza Vafi Date: Tue, 8 Jun 2021 03:44:50 +0430 Subject: [PATCH] initial ver. --- .gitignore | 288 ++++++++++++++++++ README.md | 224 ++++++++++++++ Serilog.HttpClient.sln | 37 +++ .../Controllers/HomeController.cs | 22 ++ .../Program.cs | 25 ++ .../Properties/launchSettings.json | 28 ++ ...rilog.HttpClient.Samples.AspNetCore.csproj | 28 ++ .../Services/IMyService.cs | 9 + .../Services/MyService.cs | 20 ++ .../Startup.cs | 72 +++++ .../appsettings.Development.json | 9 + .../appsettings.json | 10 + .../Program.cs | 35 +++ ...rilog.HttpClient.Samples.ConsoleApp.csproj | 18 ++ .../AggregatedDisposable.cs | 33 ++ .../HttpClientBuilderExtensions.cs | 35 +++ .../LogContextExtensions.cs | 53 ++++ src/Serilog.HttpClient/LogMode.cs | 21 ++ .../LoggingDelegatingHandler.cs | 221 ++++++++++++++ src/Serilog.HttpClient/MaskHelper.cs | 121 ++++++++ .../RequestLoggingOptions.cs | 104 +++++++ .../Serilog.HttpClient.csproj | 37 +++ 22 files changed, 1450 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Serilog.HttpClient.sln create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Controllers/HomeController.cs create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Program.cs create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Properties/launchSettings.json create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Serilog.HttpClient.Samples.AspNetCore.csproj create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Services/IMyService.cs create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Services/MyService.cs create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/Startup.cs create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.Development.json create mode 100644 samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.json create mode 100644 samples/Serilog.HttpClient.Samples.ConsoleApp/Program.cs create mode 100644 samples/Serilog.HttpClient.Samples.ConsoleApp/Serilog.HttpClient.Samples.ConsoleApp.csproj create mode 100644 src/Serilog.HttpClient/AggregatedDisposable.cs create mode 100644 src/Serilog.HttpClient/HttpClientBuilderExtensions.cs create mode 100644 src/Serilog.HttpClient/LogContextExtensions.cs create mode 100644 src/Serilog.HttpClient/LogMode.cs create mode 100644 src/Serilog.HttpClient/LoggingDelegatingHandler.cs create mode 100644 src/Serilog.HttpClient/MaskHelper.cs create mode 100644 src/Serilog.HttpClient/RequestLoggingOptions.cs create mode 100644 src/Serilog.HttpClient/Serilog.HttpClient.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..940794e --- /dev/null +++ b/.gitignore @@ -0,0 +1,288 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9d15f0 --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# Serilog.HttpClient +A logging handler for HttpClient: + +- Data masking for sensitive information +- Captures request/response body controlled by response status and configuration +- Captures request/response header controlled by response status and configuration +- Request/response body size truncation for preventing performance penalties +- Log levels based on response status code (Warning for status >= 400, Error for status >= 500) + +### Instructions + +**First**, install the _Serilog.HttpClient_ [NuGet package](https://www.nuget.org/packages/Serilog.HttpClient) into your app. + +```shell +dotnet add package Serilog.HttpClient +``` + +In your application's _Startup.cs_, add the middleware with `UseSerilogPlus()`: + +```csharp + public void ConfigureServices(IServiceCollection services) + { + // ... + + services + .AddHttpClient() + .LogRequestResponse(); + + // or + + services + .AddHttpClient() + .LogRequestResponse(p => + { + p.LogMode = LogMode.LogAll; + p.RequestHeaderLogMode = LogMode.LogAll; + p.RequestBodyLogMode = LogMode.LogAll; + p.RequestBodyLogTextLengthLimit = 5000; + p.ResponseHeaderLogMode = LogMode.LogFailures; + p.ResponseBodyLogMode = LogMode.LogFailures; + p.ResponseBodyLogTextLengthLimit = 5000; + p.MaskFormat = "*****"; + p.MaskedProperties.Clear(); + p.MaskedProperties.Add("*password*"); + p.MaskedProperties.Add("*token*"); + }); + } + `` + +### Sample Logged Item + + ```json + { + "@t": "2021-06-07T22:38:08.4416472Z", + "@mt": "HTTP Request Completed {@Context}", + "Context": { + "Request": { + "ClientIp": "::1", + "Method": "GET", + "Scheme": "http", + "Host": "localhost:5000", + "Path": "/Home/Index", + "QueryString": "", + "Query": {}, + "BodyString": "{\r\n \"query\": \"query listPageQuery($text: String!) {\\r\\n search(parameters: $text) {\\r\\n displayText\\r\\n contentType\\r\\n contentItemId\\r\\n alias {\\r\\n alias\\r\\n __typename\\r\\n }\\r\\n __typename\\r\\n }\\r\\n}\\r\\n\",\r\n \"variables\": {\r\n \"text\": \"{Term: \\\"test\\\"}\"\r\n }\r\n}", + "Body": { + "query": "query listPageQuery($text: String!) {\r\n search(parameters: $text) {\r\n displayText\r\n contentType\r\n contentItemId\r\n alias {\r\n alias\r\n __typename\r\n }\r\n __typename\r\n }\r\n}\r\n", + "variables": { + "text": "{Term: \"test\"}" + } + }, + "Header": { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Host": "localhost:5000", + "User-Agent": "PostmanRuntime/7.28.0", + "Content-Length": "289", + "X-Correlation-ID": "123", + "Postman-Token": "*** MASKED ***" + }, + "UserAgent": { + "_Raw": "PostmanRuntime/7.28.0", + "Browser": "Other", + "BrowserVersion": ".", + "OperatingSystem": "Other", + "OperatingSystemVersion": ".", + "Device": "Other", + "DeviceModel": "", + "DeviceManufacturer": "" + } + }, + "Response": { + "StatusCode": 200, + "IsSucceed": true, + "ElapsedMilliseconds": 1086.0481, + "BodyString": "{\r\n \"page\": 2,\r\n \"per_page\": 6,\r\n \"total\": 12,\r\n \"total_pages\": 2,\r\n \"data\": [\r\n {\r\n \"id\": 7,\r\n \"email\": \"michael.lawson@reqres.in\",\r\n \"first_name\": \"Michael\",\r\n \"last_name\": \"Lawson\",\r\n \"avatar\": \"https://reqres.in/img/faces/7-image.jpg\"\r\n },\r\n {\r\n \"id\": 8,\r\n \"email\": \"lindsay.ferguson@reqres.in\",\r\n \"first_name\": \"Lindsay\",\r\n \"last_name\": \"Ferguson\",\r\n \"avatar\": \"https://reqres.in/img/faces/8-image.jpg\"\r\n },\r\n {\r\n \"id\": 9,\r\n \"email\": \"tobias.funke@reqres.in\",\r\n \"first_name\": \"Tobias\",\r\n \"last_name\": \"Funke\",\r\n \"avatar\": \"https://reqres.in/img/faces/9-image.jpg\"\r\n },\r\n {\r\n \"id\": 10,\r\n \"email\": \"byron.fields@reqres.in\",\r\n \"first_name\": \"Byron\",\r\n \"last_name\": \"Fields\",\r\n \"avatar\": \"https://reqres.in/img/faces/10-image.jpg\"\r\n },\r\n {\r\n \"id\": 11,\r\n \"email\": \"george.edwards@reqres.in\",\r\n \"first_name\": \"George\",\r\n \"last_name\": \"Edwards\",\r\n \"avatar\": \"https://reqres.in/img/faces/11-image.jpg\"\r\n },\r\n {\r\n \"id\": 12,\r\n \"email\": \"rachel.howell@reqres.in\",\r\n \"first_name\": \"Rachel\",\r\n \"last_name\": \"Howell\",\r\n \"avatar\": \"https://reqres.in/img/faces/12-image.jpg\"\r\n }\r\n ],\r\n \"support\": {\r\n \"url\": \"https://reqres.in/#support-heading\",\r\n \"text\": \"To keep ReqRes free, contributions towards server costs are appreciated!\"\r\n }\r\n}", + "Body": { + "page": 2, + "per_page": 6, + "total": 12, + "total_pages": 2, + "data": [ + { + "id": 7, + "email": "michael.lawson@reqres.in", + "first_name": "Michael", + "last_name": "Lawson", + "avatar": "https://reqres.in/img/faces/7-image.jpg" + }, + { + "id": 8, + "email": "lindsay.ferguson@reqres.in", + "first_name": "Lindsay", + "last_name": "Ferguson", + "avatar": "https://reqres.in/img/faces/8-image.jpg" + }, + { + "id": 9, + "email": "tobias.funke@reqres.in", + "first_name": "Tobias", + "last_name": "Funke", + "avatar": "https://reqres.in/img/faces/9-image.jpg" + }, + { + "id": 10, + "email": "byron.fields@reqres.in", + "first_name": "Byron", + "last_name": "Fields", + "avatar": "https://reqres.in/img/faces/10-image.jpg" + }, + { + "id": 11, + "email": "george.edwards@reqres.in", + "first_name": "George", + "last_name": "Edwards", + "avatar": "https://reqres.in/img/faces/11-image.jpg" + }, + { + "id": 12, + "email": "rachel.howell@reqres.in", + "first_name": "Rachel", + "last_name": "Howell", + "avatar": "https://reqres.in/img/faces/12-image.jpg" + } + ], + "support": { + "url": "https://reqres.in/#support-heading", + "text": "To keep ReqRes free, contributions towards server costs are appreciated!" + } + }, + "Header": { + "Date": [ + "Mon, 07 Jun 2021 23:10:07 GMT" + ], + "Connection": [ + "keep-alive" + ], + "X-Powered-By": [ + "Express" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "ETag": [ + "W/\"406-ut0vzoCuidvyMf8arZpMpJ6ZRDw\"" + ], + "Via": [ + "1.1 vegur" + ], + "Cache-Control": [ + "max-age=14400" + ], + "CF-Cache-Status": [ + "HIT" + ], + "Age": [ + "114" + ], + "Accept-Ranges": [ + "bytes" + ], + "cf-request-id": [ + "0a8a56a64600002b7150089000000001" + ], + "Expect-CT": [ + "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"" + ], + "Report-To": [ + "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v2?s=YgyrFOvFSMIpafNd%2B4OGV0EZeungoLs0%2FQrtFZKlCviJAUJMt%2FoSmWF82X5OUxcsSJRhyYA%2FwAZOJ0dW%2FlwZq9OSkmcVczmVR8NLQTCTFXs95%2Bv%2BikEF\"}],\"group\":\"cf-nel\",\"max_age\":604800}" + ], + "NEL": [ + "{\"report_to\":\"cf-nel\",\"max_age\":604800}" + ], + "Server": [ + "cloudflare" + ], + "CF-RAY": [ + "65bd8d5069aa2b71-FRA" + ], + "Alt-Svc": [ + "h3-27=\":443\"", + "h3-28=\":443\"", + "h3-29=\":443\"", + "h3=\":443\"" + ] + } + }, + "Diagnostics": {} + }, + "SourceContext": "Serilog.AspNetCore.RequestLoggingMiddleware", + "ActionId": "ee7ce05b-044b-442b-9f46-bd5bd6058cff", + "ActionName": "Serilog.HttpClient.Samples.AspNetCore.Controllers.HomeController.Index (Serilog.HttpClient.Samples.AspNetCore)", + "CorrelationId": "123", + "RequestId": "0HM9A0JCHS60P:00000002", + "RequestPath": "/Home/Index", + "ConnectionId": "0HM9A0JCHS60P", + "EnvironmentUserName": "DESKTOP-SP4IR37\\AV", + "MachineName": "DESKTOP-SP4IR37", + "EventId": "C2110DE4" + } + ``` + diff --git a/Serilog.HttpClient.sln b/Serilog.HttpClient.sln new file mode 100644 index 0000000..31c034e --- /dev/null +++ b/Serilog.HttpClient.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AA7D5182-4380-4FEE-9C29-E82A7E18B238}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{73B9E424-BC51-4858-AB3B-5A848EC79CA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.HttpClient", "src\Serilog.HttpClient\Serilog.HttpClient.csproj", "{31475D8A-0222-4C02-8A8F-75E1C79187E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.HttpClient.Samples.ConsoleApp", "samples\Serilog.HttpClient.Samples.ConsoleApp\Serilog.HttpClient.Samples.ConsoleApp.csproj", "{A4CD6F06-4763-44CE-B748-4BCF186FE584}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.HttpClient.Samples.AspNetCore", "samples\Serilog.HttpClient.Samples.AspNetCore\Serilog.HttpClient.Samples.AspNetCore.csproj", "{5D943CED-45BE-44F2-8662-A957793EA8A0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Release|Any CPU.Build.0 = Release|Any CPU + {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Release|Any CPU.Build.0 = Release|Any CPU + {5D943CED-45BE-44F2-8662-A957793EA8A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D943CED-45BE-44F2-8662-A957793EA8A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D943CED-45BE-44F2-8662-A957793EA8A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D943CED-45BE-44F2-8662-A957793EA8A0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {31475D8A-0222-4C02-8A8F-75E1C79187E5} = {AA7D5182-4380-4FEE-9C29-E82A7E18B238} + {A4CD6F06-4763-44CE-B748-4BCF186FE584} = {73B9E424-BC51-4858-AB3B-5A848EC79CA1} + {5D943CED-45BE-44F2-8662-A957793EA8A0} = {73B9E424-BC51-4858-AB3B-5A848EC79CA1} + EndGlobalSection +EndGlobal diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Controllers/HomeController.cs b/samples/Serilog.HttpClient.Samples.AspNetCore/Controllers/HomeController.cs new file mode 100644 index 0000000..917ee92 --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Controllers/HomeController.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Serilog.HttpClient.Samples.AspNetCore.Services; + +namespace Serilog.HttpClient.Samples.AspNetCore.Controllers +{ + public class HomeController : Controller + { + private readonly IMyService _myService; + + public HomeController(IMyService myService) + { + _myService = myService; + } + + public async Task Index() + { + var result = await _myService.SendRequest(); + return Ok(result); + } + } +} \ No newline at end of file diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Program.cs b/samples/Serilog.HttpClient.Samples.AspNetCore/Program.cs new file mode 100644 index 0000000..5c31112 --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Program.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog.Formatting.Compact; + +namespace Serilog.HttpClient.Samples.AspNetCore +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseSerilogPlus(p => p.WriteTo.File(new CompactJsonFormatter(),"App_Data/Logs/log.json")) + .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); + } +} \ No newline at end of file diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Properties/launchSettings.json b/samples/Serilog.HttpClient.Samples.AspNetCore/Properties/launchSettings.json new file mode 100644 index 0000000..6a2d606 --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:30748", + "sslPort": 44345 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Serilog.HttpClient.Samples.AspNetCore": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Serilog.HttpClient.Samples.AspNetCore.csproj b/samples/Serilog.HttpClient.Samples.AspNetCore/Serilog.HttpClient.Samples.AspNetCore.csproj new file mode 100644 index 0000000..f989bbe --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Serilog.HttpClient.Samples.AspNetCore.csproj @@ -0,0 +1,28 @@ + + + + net5.0 + + + + + + + + + + + + appsettings.json + + + + + + + + + + + + diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Services/IMyService.cs b/samples/Serilog.HttpClient.Samples.AspNetCore/Services/IMyService.cs new file mode 100644 index 0000000..f45960c --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Services/IMyService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Serilog.HttpClient.Samples.AspNetCore.Services +{ + public interface IMyService + { + Task SendRequest(); + } +} \ No newline at end of file diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Services/MyService.cs b/samples/Serilog.HttpClient.Samples.AspNetCore/Services/MyService.cs new file mode 100644 index 0000000..1870a7e --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Services/MyService.cs @@ -0,0 +1,20 @@ +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace Serilog.HttpClient.Samples.AspNetCore.Services +{ + public class MyService : IMyService + { + private readonly System.Net.Http.HttpClient _httpClient; + + public MyService(System.Net.Http.HttpClient httpClient) + { + _httpClient = httpClient; + } + + public Task SendRequest() + { + return _httpClient.GetFromJsonAsync("https://reqres.in/api/users?page=2"); + } + } +} \ No newline at end of file diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/Startup.cs b/samples/Serilog.HttpClient.Samples.AspNetCore/Startup.cs new file mode 100644 index 0000000..2bab4d3 --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/Startup.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Correlate.AspNetCore; +using Correlate.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog.HttpClient.Samples.AspNetCore.Services; + +namespace Serilog.HttpClient.Samples.AspNetCore +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddCorrelate(options => options.RequestHeaders = new [] { "X-Correlation-ID" }); + + services + .AddHttpClient() + .CorrelateRequests("X-Correlation-ID") + .LogRequestResponse(p => + { + p.LogMode = LogMode.LogAll; + p.RequestHeaderLogMode = LogMode.LogAll; + p.RequestBodyLogMode = LogMode.LogAll; + p.RequestBodyLogTextLengthLimit = 5000; + p.ResponseHeaderLogMode = LogMode.LogAll; + p.ResponseBodyLogMode = LogMode.LogAll; + p.ResponseBodyLogTextLengthLimit = 5000; + p.MaskFormat = "*****"; + p.MaskedProperties.Clear(); + p.MaskedProperties.Add("*password*"); + p.MaskedProperties.Add("*token*"); + }) + // /*OR*/ .LogRequestResponse() + .ConfigurePrimaryHttpMessageHandler(p => new HttpClientHandler() + { + //Proxy = new WebProxy("127.0.0.1", 8888) + }); + //or + + services.AddControllers(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseCorrelate(); + app.UseSerilogPlusRequestLogging(); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + }); + } + } +} \ No newline at end of file diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.Development.json b/samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.json b/samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Serilog.HttpClient.Samples.ConsoleApp/Program.cs b/samples/Serilog.HttpClient.Samples.ConsoleApp/Program.cs new file mode 100644 index 0000000..dc22221 --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.ConsoleApp/Program.cs @@ -0,0 +1,35 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Serilog.Formatting.Json; + +namespace Serilog.HttpClient.Samples.ConsoleApp +{ + class Program + { + static void Main(string[] args) + { + Serilog.Log.Logger = new LoggerConfiguration() + .WriteTo.File(new JsonFormatter(),"log.json") + .CreateLogger(); + + var loggingHandler = new LoggingDelegatingHandler(new RequestLoggingOptions() + { + LogMode = LogMode.LogAll, + RequestHeaderLogMode = LogMode.LogAll, + RequestBodyLogMode = LogMode.LogAll, + RequestBodyLogTextLengthLimit = 5000, + ResponseHeaderLogMode = LogMode.LogFailures, + ResponseBodyLogMode = LogMode.LogFailures, + ResponseBodyLogTextLengthLimit = 5000, + MaskFormat = "*****", + MaskedProperties = { "password", "token" }, + }); + + var c = new System.Net.Http.HttpClient(loggingHandler); + var o = Task.Run(() => c.GetFromJsonAsync("https://reqres.in/api/users?page=2")).Result; + } + } +} \ No newline at end of file diff --git a/samples/Serilog.HttpClient.Samples.ConsoleApp/Serilog.HttpClient.Samples.ConsoleApp.csproj b/samples/Serilog.HttpClient.Samples.ConsoleApp/Serilog.HttpClient.Samples.ConsoleApp.csproj new file mode 100644 index 0000000..307fad8 --- /dev/null +++ b/samples/Serilog.HttpClient.Samples.ConsoleApp/Serilog.HttpClient.Samples.ConsoleApp.csproj @@ -0,0 +1,18 @@ + + + + Exe + net5.0 + + + + + + + + + + + + + diff --git a/src/Serilog.HttpClient/AggregatedDisposable.cs b/src/Serilog.HttpClient/AggregatedDisposable.cs new file mode 100644 index 0000000..ea56692 --- /dev/null +++ b/src/Serilog.HttpClient/AggregatedDisposable.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Serilog.HttpClient +{ + /// + /// An aggregated disposable object for collection of disposable objects + /// + public class AggregatedDisposable : IDisposable + { + private readonly IEnumerable _disposables; + + /// + /// disposable objects to unify as single disposable + /// + /// + public AggregatedDisposable(IEnumerable disposables) + { + _disposables = disposables; + } + + /// + /// Dispose + /// + public void Dispose() + { + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/src/Serilog.HttpClient/HttpClientBuilderExtensions.cs b/src/Serilog.HttpClient/HttpClientBuilderExtensions.cs new file mode 100644 index 0000000..c3bc2c0 --- /dev/null +++ b/src/Serilog.HttpClient/HttpClientBuilderExtensions.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Serilog.HttpClient +{ + public static class HttpClientBuilderExtensions + { + /// + /// Adds services required for logging request/response to each outgoing request. + /// + /// The to add the services to. + /// The action used to configure . + /// The so that additional calls can be chained. + public static IHttpClientBuilder LogRequestResponse(this IHttpClientBuilder builder, + Action configureOptions = null) + { + if (configureOptions == null) + configureOptions = options => { }; + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + builder.Services.Configure(builder.Name, configureOptions); + builder.Services.TryAddTransient(s => + { + var o = s.GetRequiredService>(); + return new LoggingDelegatingHandler(o.Get(builder.Name)); + }); + builder.AddHttpMessageHandler(s => s.GetRequiredService()); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Serilog.HttpClient/LogContextExtensions.cs b/src/Serilog.HttpClient/LogContextExtensions.cs new file mode 100644 index 0000000..c144905 --- /dev/null +++ b/src/Serilog.HttpClient/LogContextExtensions.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace Serilog.HttpClient +{ + /// + /// LogContext extensions for pushing multiple properties + /// + public static class LogContext + { + /// + /// Pushes list of key/value pair to LogContext + /// + /// list of key/value pair + /// destructure property value + /// + public static IDisposable PushProperties(IEnumerable> propertyValuePair, bool destructureObjects = false) + { + var disposables = propertyValuePair.Select(pair => Serilog.Context.LogContext.PushProperty(pair.Key, pair.Value, destructureObjects)); + return new AggregatedDisposable(disposables); + } + + /// + /// Pushes object properties to LogContext + /// + /// object to push properties + /// destructure property value + /// + public static IDisposable PushProperties(object values, bool destructureObjects = false) + { + var disposables = values.FlattenAsDictionary().Select(pair => Serilog.Context.LogContext.PushProperty(pair.Key, pair.Value, destructureObjects)); + return new AggregatedDisposable(disposables); + } + + private static IEnumerable> FlattenAsDictionary(this object values) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (values != null) + { + foreach (PropertyDescriptor propertyDescriptor in TypeDescriptor.GetProperties(values)) + { + object obj = propertyDescriptor.GetValue(values); + dict.Add(propertyDescriptor.Name, obj); + } + } + + return dict; + } + } +} \ No newline at end of file diff --git a/src/Serilog.HttpClient/LogMode.cs b/src/Serilog.HttpClient/LogMode.cs new file mode 100644 index 0000000..232c835 --- /dev/null +++ b/src/Serilog.HttpClient/LogMode.cs @@ -0,0 +1,21 @@ +namespace Serilog.HttpClient +{ + /// + /// Determines when do logging + /// + public enum LogMode + { + /// + /// Logs no data whether operation succeed or failed + /// + LogNone, + /// + /// Logs all including success and failures + /// + LogAll, + /// + /// Log only failures + /// + LogFailures + } +} \ No newline at end of file diff --git a/src/Serilog.HttpClient/LoggingDelegatingHandler.cs b/src/Serilog.HttpClient/LoggingDelegatingHandler.cs new file mode 100644 index 0000000..1dcc3cd --- /dev/null +++ b/src/Serilog.HttpClient/LoggingDelegatingHandler.cs @@ -0,0 +1,221 @@ +// Copyright 2019 Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.Extensions.Options; +using Serilog.Debugging; +using Serilog.Events; + +namespace Serilog.HttpClient +{ + public class LoggingDelegatingHandler : DelegatingHandler + { + private readonly RequestLoggingOptions _options; + private readonly ILogger _logger; + + public LoggingDelegatingHandler( + RequestLoggingOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = options.Logger?.ForContext() ?? Serilog.Log.Logger.ForContext(); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var start = Stopwatch.GetTimestamp(); + try + { + var resp = await base.SendAsync(request, cancellationToken); + var elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp()); + await LogRequest(request, resp, elapsedMs, null); + return resp; + } + catch (Exception ex) + { + var elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp()); + await LogRequest(request, null, elapsedMs, ex); + throw; + } + } + + static double GetElapsedMilliseconds(long start, long stop) + { + return (stop - start) * 1000 / (double) Stopwatch.Frequency; + } + + private async Task LogRequest(HttpRequestMessage req, HttpResponseMessage resp, double elapsedMs, + Exception ex) + { + var level = _options.GetLevel(req, resp, elapsedMs, ex); + if (!_logger.IsEnabled(level)) return; + + var requestBodyText = string.Empty; + var responseBodyText = string.Empty; + + var isRequestOk = !(resp != null && (int)resp.StatusCode >= 400 || ex != null); + if (_options.LogMode == LogMode.LogAll || + (!isRequestOk && _options.LogMode == LogMode.LogFailures)) + { + if (req.Content != null) + requestBodyText = await req.Content.ReadAsStringAsync(); + JsonDocument requestBody = null; + if ((_options.RequestBodyLogMode == LogMode.LogAll || + (!isRequestOk && _options.RequestBodyLogMode == LogMode.LogFailures))) + { + if (!string.IsNullOrWhiteSpace(requestBodyText)) + { + try { + requestBodyText = requestBodyText.MaskFields(_options.MaskedProperties.ToArray(), + _options.MaskFormat); + } catch (Exception) { } + + if (requestBodyText.Length > _options.RequestBodyLogTextLengthLimit) + requestBodyText = requestBodyText.Substring(0, _options.RequestBodyLogTextLengthLimit); + else + try { requestBody = System.Text.Json.JsonDocument.Parse(requestBodyText); }catch (Exception) { } + } + } + else + { + requestBodyText = "(Not Logged)"; + } + + var requestHeader = new Dictionary(); + if (_options.RequestHeaderLogMode == LogMode.LogAll || + (!isRequestOk && _options.RequestHeaderLogMode == LogMode.LogFailures)) + { + try + { + var valuesByKey = req.Headers + .Mask(_options.MaskedProperties.ToArray(), _options.MaskFormat).GroupBy(x => x.Key); + foreach (var item in valuesByKey) + { + if (item.Count() > 1) + requestHeader.Add(item.Key, item.SelectMany(x => x.Value)); + else + requestHeader.Add(item.Key, item.First().Value); + } + } + catch (Exception headerParseException) + { + SelfLog.WriteLine("Cannot parse request header: " + headerParseException); + } + } + + var requestQuery = new Dictionary(); + try + { + if (!string.IsNullOrWhiteSpace(req.RequestUri.Query)) + { + var q = HttpUtility.ParseQueryString(req.RequestUri.Query); + + foreach (var key in q.AllKeys) + { + requestQuery.Add(key, q[key]); + } + } + } + catch (Exception) + { + SelfLog.WriteLine("Cannot parse query string"); + } + + var requestData = new + { + Method = req.Method, + Scheme = req.RequestUri.Scheme, + Host = req.RequestUri.Host, + Path = req.RequestUri.AbsolutePath, + QueryString = req.RequestUri.Query, + Query = requestQuery, + BodyString = requestBodyText, + Body = requestBody, + Header = requestHeader, + }; + + object responseBody = null; + if ((_options.ResponseBodyLogMode == LogMode.LogAll || + (!isRequestOk && _options.ResponseBodyLogMode == LogMode.LogFailures))) + { + if (resp?.Content != null) + responseBodyText = await resp?.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(responseBodyText)) + { + try { + responseBodyText = responseBodyText.MaskFields(_options.MaskedProperties.ToArray(), + _options.MaskFormat); + } catch (Exception) { } + + if (responseBodyText.Length > _options.ResponseBodyLogTextLengthLimit) + responseBodyText = responseBodyText.Substring(0, _options.ResponseBodyLogTextLengthLimit); + else + try { responseBody = System.Text.Json.JsonDocument.Parse(responseBodyText); }catch (Exception) { } + } + } + else + { + responseBodyText = "(Not Logged)"; + } + + var responseHeader = new Dictionary(); + if (_options.ResponseHeaderLogMode == LogMode.LogAll || + (!isRequestOk && _options.ResponseHeaderLogMode == LogMode.LogFailures) + && resp != null) + { + + try + { + var valuesByKey = resp.Headers + .Mask(_options.MaskedProperties.ToArray(), _options.MaskFormat).GroupBy(x => x.Key); + foreach (var item in valuesByKey) + { + if (item.Count() > 1) + responseHeader.Add(item.Key, item.SelectMany(x => x.Value)); + else + responseHeader.Add(item.Key, item.First().Value); + } + } + catch (Exception headerParseException) + { + SelfLog.WriteLine("Cannot parse response header: " + headerParseException); + } + } + + var responseData = new + { + StatusCode = (int?)resp?.StatusCode, + IsSucceed = isRequestOk, + ElapsedMilliseconds = elapsedMs, + BodyString = responseBodyText, + Body = responseBody, + Header = responseHeader, + }; + + _logger.Write(level, ex, _options.MessageTemplate, new + { + Request = requestData, + Response = responseData, + }); + } + } + } +} \ No newline at end of file diff --git a/src/Serilog.HttpClient/MaskHelper.cs b/src/Serilog.HttpClient/MaskHelper.cs new file mode 100644 index 0000000..7dd4314 --- /dev/null +++ b/src/Serilog.HttpClient/MaskHelper.cs @@ -0,0 +1,121 @@ +// https://github.com/ThiagoBarradas/jsonmasking/blob/master/JsonMasking/JsonMasking.cs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Serilog.HttpClient +{ + /// + /// Masking extension for json strings + /// + public static class MaskHelper + { + /// + /// Masks specified json string using provided options + /// + /// Json to mask + /// Fields to mask + /// Mask format + /// + /// + public static string MaskFields(this string json, string[] blacklist, string mask) + { + if (string.IsNullOrWhiteSpace(json)) + { + return json; + } + + if (blacklist == null) + { + throw new ArgumentNullException(nameof(blacklist)); + } + + if (blacklist.Any() == false) + { + return json; + } + + var jsonObject = JsonConvert.DeserializeObject(json); + if (jsonObject is JArray jArray) + { + foreach (var jToken in jArray) + { + MaskFieldsFromJToken(jToken, blacklist, mask); + } + } + else if (jsonObject is JObject jObject) + { + MaskFieldsFromJToken(jObject, blacklist, mask); + } + + return jsonObject.ToString(); + } + + private static void MaskFieldsFromJToken(JToken token, string[] blacklist, string mask) + { + JContainer container = token as JContainer; + if (container == null) + { + return; // abort recursive + } + + List removeList = new List(); + foreach (JToken jtoken in container.Children()) + { + if (jtoken is JProperty prop) + { + if (IsMaskMatch(prop.Path, blacklist)) + { + removeList.Add(jtoken); + } + } + + // call recursive + MaskFieldsFromJToken(jtoken, blacklist, mask); + } + + // replace + foreach (JToken el in removeList) + { + var prop = (JProperty) el; + prop.Value = mask; + } + } + + /// + /// Check whether specified path must be masked + /// + /// + /// + /// + public static bool IsMaskMatch(string path, string[] blacklist) + { + return blacklist.Any(item => Regex.IsMatch(path, WildCardToRegular(item), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)); + } + + private static string WildCardToRegular(string value) + { + return "^" + Regex.Escape(value).Replace("\\*", ".*") + "$"; + } + + /// + /// Masks key-value paired items + /// + /// + /// + /// + /// + public static IEnumerable>> Mask(this IEnumerable>> keyValuePairs, string[] blacklist, + string mask) + { + return keyValuePairs.Select(pair => IsMaskMatch(pair.Key, blacklist) + ? new KeyValuePair>(pair.Key, new[] {mask} ) + : new KeyValuePair>(pair.Key, pair.Value)) + .ToList(); + } + } +} \ No newline at end of file diff --git a/src/Serilog.HttpClient/RequestLoggingOptions.cs b/src/Serilog.HttpClient/RequestLoggingOptions.cs new file mode 100644 index 0000000..e2ba3f5 --- /dev/null +++ b/src/Serilog.HttpClient/RequestLoggingOptions.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Serilog.Events; + +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace Serilog.HttpClient +{ + /// + /// Contains options for the . + /// + public class RequestLoggingOptions + { + const string DefaultRequestCompletionMessageTemplate = + "HTTP Client Request Completed {@Context}"; + + /// + /// Gets or sets the message template. The default value is + /// "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms". + /// + /// + /// The message template. + /// + public string MessageTemplate { get; set; } + + /// + /// A function returning the based on the / information, + /// the number of elapsed milliseconds required for handling the request, and an if one was thrown. + /// The default behavior returns when the response status code is greater than 499 or if the + /// is not null. Also default log level for 4xx range errors set to + /// + /// + /// A function returning the . + /// + public Func GetLevel { get; set; } + + /// + /// The logger through which request completion events will be logged. The default is to use the + /// static class. + /// + public ILogger Logger { get; set; } + /// + /// Determines when logging requests information. Default is true. + /// + public LogMode LogMode { get; set; } = LogMode.LogAll; + + /// + /// Determines when logging request headers + /// + public LogMode RequestHeaderLogMode { get; set; } = LogMode.LogAll; + /// + /// Determines when logging request body data + /// + public LogMode RequestBodyLogMode { get; set; } = LogMode.LogAll; + /// + /// Determines when logging response headers + /// + public LogMode ResponseHeaderLogMode { get; set; } = LogMode.LogAll; + /// + /// Determines when logging response body data + /// + public LogMode ResponseBodyLogMode { get; set; } = LogMode.LogFailures; + /// + /// Properties to mask before logging to output to prevent sensitive data leakage + /// + public IList MaskedProperties { get; } = + new List() {"*password*", "*token*", "*clientsecret*", "*bearer*", "*authorization*", "*client-secret*","*otp"}; + /// + /// Mask format to replace with masked data + /// + public string MaskFormat { get; set; } = "*** MASKED ***"; + /// + /// Maximum allowed length of response body text to capture in logs + /// + public int ResponseBodyLogTextLengthLimit { get; set; } = 4000; + /// + /// Maximum allowed length of request body text to capture in logs + /// + public int RequestBodyLogTextLengthLimit { get; set; } = 4000; + + /// + /// Constructor + /// + public RequestLoggingOptions() + { + MessageTemplate = DefaultRequestCompletionMessageTemplate; + GetLevel = DefaultGetLevel; + } + + static LogEventLevel DefaultGetLevel(HttpRequestMessage req, HttpResponseMessage resp, double elapsedMs, Exception ex) + { + var level = LogEventLevel.Information; + if (ex != null || resp == null) + level = LogEventLevel.Error; + else if ((int)resp.StatusCode >= 500) + level = LogEventLevel.Error; + else if ((int)resp.StatusCode >= 400) + level = LogEventLevel.Warning; + + return level; + } + } +} diff --git a/src/Serilog.HttpClient/Serilog.HttpClient.csproj b/src/Serilog.HttpClient/Serilog.HttpClient.csproj new file mode 100644 index 0000000..de5e414 --- /dev/null +++ b/src/Serilog.HttpClient/Serilog.HttpClient.csproj @@ -0,0 +1,37 @@ + + + + netstandard2.0;netstandard2.1;netcoreapp3.1;net5.0 + true + Serilog http client logging delegating handler + Alireza Vafi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +