├── .github └── workflows │ └── ci.yml ├── .gitignore ├── README.md ├── Serilog.HttpClient.sln ├── global.json ├── samples ├── Serilog.HttpClient.Samples.AspNetCore │ ├── Controllers │ │ └── HomeController.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Serilog.HttpClient.Samples.AspNetCore.csproj │ ├── Services │ │ ├── IMyService.cs │ │ └── MyService.cs │ ├── Startup.cs │ └── appsettings.json └── Serilog.HttpClient.Samples.ConsoleApp │ ├── Program.cs │ └── Serilog.HttpClient.Samples.ConsoleApp.csproj └── src ├── Serilog.HttpClient.Tests ├── LoggingDelegatingHandlerTests.cs ├── MaskHelperTests.cs ├── Serilog.HttpClient.Tests.csproj ├── Support │ └── Extensions.cs └── Usings.cs └── Serilog.HttpClient ├── DestructuringPolicies ├── JsonDocumentDestructuringPolicy.cs └── JsonNetDestructuringPolicy.cs ├── Extensions ├── HttpClientBuilderExtensions.cs ├── JsonExtension.cs └── LoggerConfigurationExtensions.cs ├── LogEntryParameters.cs ├── LogMode.cs ├── LoggingDelegatingHandler.cs ├── Models ├── HttpClientContext.cs ├── HttpClientRequestContext.cs └── HttpClientResponseContext.cs ├── RequestLoggingOptions.cs └── Serilog.HttpClient.csproj /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | - master 7 | 8 | jobs: 9 | test: 10 | runs-on: [windows-latest] 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup .NET 16 | uses: actions/setup-dotnet@v2 17 | 18 | - name: Install dependencies 19 | run: dotnet restore 20 | 21 | - name: Build 22 | run: dotnet build --configuration Release --no-restore 23 | 24 | - name: Run tests 25 | run: dotnet test --configuration Release --no-restore --no-build --nologo 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | **/Properties/launchSettings.json 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # Visual Studio code coverage results 117 | *.coverage 118 | *.coveragexml 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # Click-Once directory 146 | publish/ 147 | 148 | # Publish Web Output 149 | *.[Pp]ublish.xml 150 | *.azurePubxml 151 | # TODO: Comment the next line if you want to checkin your web deploy settings 152 | # but database connection strings (with potential passwords) will be unencrypted 153 | *.pubxml 154 | *.publishproj 155 | 156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 157 | # checkin your Azure Web App publish settings, but sensitive information contained 158 | # in these scripts will be unencrypted 159 | PublishScripts/ 160 | 161 | # NuGet Packages 162 | *.nupkg 163 | # The packages folder can be ignored because of Package Restore 164 | **/packages/* 165 | # except build/, which is used as an MSBuild target. 166 | !**/packages/build/ 167 | # Uncomment if necessary however generally it will be regenerated when needed 168 | #!**/packages/repositories.config 169 | # NuGet v3's project.json files produces more ignorable files 170 | *.nuget.props 171 | *.nuget.targets 172 | 173 | # Microsoft Azure Build Output 174 | csx/ 175 | *.build.csdef 176 | 177 | # Microsoft Azure Emulator 178 | ecf/ 179 | rcf/ 180 | 181 | # Windows Store app package directories and files 182 | AppPackages/ 183 | BundleArtifacts/ 184 | Package.StoreAssociation.xml 185 | _pkginfo.txt 186 | 187 | # Visual Studio cache files 188 | # files ending in .cache can be ignored 189 | *.[Cc]ache 190 | # but keep track of directories ending in .cache 191 | !*.[Cc]ache/ 192 | 193 | # Others 194 | ClientBin/ 195 | ~$* 196 | *~ 197 | *.dbmdl 198 | *.dbproj.schemaview 199 | *.jfm 200 | *.pfx 201 | *.publishsettings 202 | orleans.codegen.cs 203 | 204 | # Since there are multiple workflows, uncomment next line to ignore bower_components 205 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 206 | #bower_components/ 207 | 208 | # RIA/Silverlight projects 209 | Generated_Code/ 210 | 211 | # Backup & report files from converting an old project file 212 | # to a newer Visual Studio version. Backup files are not needed, 213 | # because we have git ;-) 214 | _UpgradeReport_Files/ 215 | Backup*/ 216 | UpgradeLog*.XML 217 | UpgradeLog*.htm 218 | 219 | # SQL Server files 220 | *.mdf 221 | *.ldf 222 | *.ndf 223 | 224 | # Business Intelligence projects 225 | *.rdl.data 226 | *.bim.layout 227 | *.bim_*.settings 228 | 229 | # Microsoft Fakes 230 | FakesAssemblies/ 231 | 232 | # GhostDoc plugin setting file 233 | *.GhostDoc.xml 234 | 235 | # Node.js Tools for Visual Studio 236 | .ntvs_analysis.dat 237 | node_modules/ 238 | 239 | # Typescript v1 declaration files 240 | typings/ 241 | 242 | # Visual Studio 6 build log 243 | *.plg 244 | 245 | # Visual Studio 6 workspace options file 246 | *.opt 247 | 248 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 249 | *.vbw 250 | 251 | # Visual Studio LightSwitch build output 252 | **/*.HTMLClient/GeneratedArtifacts 253 | **/*.DesktopClient/GeneratedArtifacts 254 | **/*.DesktopClient/ModelManifest.xml 255 | **/*.Server/GeneratedArtifacts 256 | **/*.Server/ModelManifest.xml 257 | _Pvt_Extensions 258 | 259 | # Paket dependency manager 260 | .paket/paket.exe 261 | paket-files/ 262 | 263 | # FAKE - F# Make 264 | .fake/ 265 | 266 | # JetBrains Rider 267 | .idea/ 268 | *.sln.iml 269 | 270 | # CodeRush 271 | .cr/ 272 | 273 | # Python Tools for Visual Studio (PTVS) 274 | __pycache__/ 275 | *.pyc 276 | 277 | # Cake - Uncomment if you are using it 278 | # tools/** 279 | # !tools/packages.config 280 | 281 | # Telerik's JustMock configuration file 282 | *.jmconfig 283 | 284 | # BizTalk build output 285 | *.btp.cs 286 | *.btm.cs 287 | *.odx.cs 288 | *.xsd.cs 289 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serilog.HttpClient 2 | A logging handler for HttpClient: 3 | 4 | - Data masking for sensitive information 5 | - Captures request/response body based on response status and configuration 6 | - Captures request/response header based on response status and configuration 7 | - Request/response body size truncation for preventing performance penalties 8 | - Log levels based on response status code (Warning for status >= 400, Error for status >= 500) 9 | 10 | ### Instructions 11 | 12 | **First**, install the _Serilog.HttpClient_ [NuGet package](https://www.nuget.org/packages/Serilog.HttpClient) into your app. 13 | 14 | ```shell 15 | dotnet add package Serilog.HttpClient 16 | ``` 17 | 18 | Add Json destructing policies using AddJsonDestructuringPolicies() when configuring LoggerConfiguration like below: 19 | 20 | ```csharp 21 | Serilog.Log.Logger = new LoggerConfiguration() 22 | .WriteTo.File(new JsonFormatter(),"log.json") 23 | .WriteTo.Console(outputTemplate: 24 | "[{Timestamp:HH:mm:ss} {Level:u3}] {Message} {NewLine}{Properties} {NewLine}{Exception}{NewLine}", 25 | theme: SystemConsoleTheme.Literate) 26 | .AddJsonDestructuringPolicies() 27 | .CreateLogger(); 28 | ``` 29 | 30 | In your application's _Startup.cs_, add the middleware with `UseSerilogPlus()`: 31 | 32 | ```csharp 33 | public void ConfigureServices(IServiceCollection services) 34 | { 35 | // ... 36 | 37 | services 38 | .AddHttpClient() 39 | .LogRequestResponse(); 40 | 41 | // or 42 | 43 | services 44 | .AddHttpClient() 45 | .LogRequestResponse(p => 46 | { 47 | p.LogMode = LogMode.LogAll; 48 | p.RequestHeaderLogMode = LogMode.LogAll; 49 | p.RequestBodyLogMode = LogMode.LogAll; 50 | p.RequestBodyLogTextLengthLimit = 5000; 51 | p.ResponseHeaderLogMode = LogMode.LogFailures; 52 | p.ResponseBodyLogMode = LogMode.LogFailures; 53 | p.ResponseBodyLogTextLengthLimit = 5000; 54 | p.MaskFormat = "*****"; 55 | p.MaskedProperties.Clear(); 56 | p.MaskedProperties.Add("*password*"); 57 | p.MaskedProperties.Add("*token*"); 58 | }); 59 | } 60 | `` 61 | 62 | ### Sample Logged Item 63 | 64 | ```json 65 | { 66 | "Timestamp": "2022-08-24T14:51:58.5829167+04:30", 67 | "Level": "Information", 68 | "MessageTemplate": "HTTP Client {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms", 69 | "Properties": { 70 | "RequestMethod": "GET", 71 | "RequestPath": "/api/users", 72 | "StatusCode": 200, 73 | "Elapsed": 803.8034, 74 | "__4": "Serilog.HttpClient.Models.HttpClientContext", 75 | "Context": { 76 | "_typeTag": "HttpClientContext", 77 | "Request": { 78 | "_typeTag": "HttpRequestInfo", 79 | "Method": "GET", 80 | "Scheme": "https", 81 | "Host": "reqres.in", 82 | "Path": "/api/users", 83 | "QueryString": "?page=2", 84 | "Query": { 85 | "page": "2" 86 | }, 87 | "BodyString": "", 88 | "Body": null, 89 | "Headers": {} 90 | }, 91 | "Response": { 92 | "_typeTag": "HttpResponseInfo", 93 | "StatusCode": 200, 94 | "IsSucceed": true, 95 | "ElapsedMilliseconds": 803.8034, 96 | "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}", 97 | "Body": { 98 | "page": 2, 99 | "per_page": 6, 100 | "total": 12, 101 | "total_pages": 2, 102 | "data": [ 103 | { 104 | "id": 7, 105 | "email": "michael.lawson@reqres.in", 106 | "first_name": "Michael", 107 | "last_name": "Lawson", 108 | "avatar": "https://reqres.in/img/faces/7-image.jpg" 109 | }, 110 | { 111 | "id": 8, 112 | "email": "lindsay.ferguson@reqres.in", 113 | "first_name": "Lindsay", 114 | "last_name": "Ferguson", 115 | "avatar": "https://reqres.in/img/faces/8-image.jpg" 116 | }, 117 | { 118 | "id": 9, 119 | "email": "tobias.funke@reqres.in", 120 | "first_name": "Tobias", 121 | "last_name": "Funke", 122 | "avatar": "https://reqres.in/img/faces/9-image.jpg" 123 | }, 124 | { 125 | "id": 10, 126 | "email": "byron.fields@reqres.in", 127 | "first_name": "Byron", 128 | "last_name": "Fields", 129 | "avatar": "https://reqres.in/img/faces/10-image.jpg" 130 | }, 131 | { 132 | "id": 11, 133 | "email": "george.edwards@reqres.in", 134 | "first_name": "George", 135 | "last_name": "Edwards", 136 | "avatar": "https://reqres.in/img/faces/11-image.jpg" 137 | }, 138 | { 139 | "id": 12, 140 | "email": "rachel.howell@reqres.in", 141 | "first_name": "Rachel", 142 | "last_name": "Howell", 143 | "avatar": "https://reqres.in/img/faces/12-image.jpg" 144 | } 145 | ], 146 | "support": { 147 | "url": "https://reqres.in/#support-heading", 148 | "text": "To keep ReqRes free, contributions towards server costs are appreciated!" 149 | } 150 | }, 151 | "Headers": { 152 | "Date": "Wed, 24 Aug 2022 10:21:58 GMT", 153 | "Connection": "keep-alive", 154 | "X-Powered-By": "Express", 155 | "Access-Control-Allow-Origin": "*", 156 | "ETag": "W/\"406-ut0vzoCuidvyMf8arZpMpJ6ZRDw\"", 157 | "Via": "1.1 vegur", 158 | "Cache-Control": "max-age=14400", 159 | "CF-Cache-Status": "HIT", 160 | "Age": "2415", 161 | "Accept-Ranges": "bytes", 162 | "Expect-CT": "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"", 163 | "Report-To": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v3?s=f1TrobVjMWxrnhqzHsNhdjNX3rpKmTWetM3%2Br5%2FetZa7nMSr9OcUBg7pM8Pne8u4%2Fn0zButYRjFlQLFP60%2FgZZlIEtihThpuv89S2FkCVTOCAHKZBislBwC6Qg%3D%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}", 164 | "NEL": "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}", 165 | "Server": "cloudflare", 166 | "CF-RAY": "73fb5d392c7f901f-FRA" 167 | } 168 | } 169 | }, 170 | "SourceContext": "Serilog.HttpClient.LoggingDelegatingHandler" 171 | }, 172 | "Renderings": { 173 | "Elapsed": [ 174 | { 175 | "Format": "0.0000", 176 | "Rendering": "803.8034" 177 | } 178 | ] 179 | } 180 | } 181 | ``` 182 | 183 | -------------------------------------------------------------------------------- /Serilog.HttpClient.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # 4 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AA7D5182-4380-4FEE-9C29-E82A7E18B238}" 5 | EndProject 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{73B9E424-BC51-4858-AB3B-5A848EC79CA1}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.HttpClient", "src\Serilog.HttpClient\Serilog.HttpClient.csproj", "{31475D8A-0222-4C02-8A8F-75E1C79187E5}" 9 | EndProject 10 | 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}" 11 | EndProject 12 | 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}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.HttpClient.Tests", "src\Serilog.HttpClient.Tests\Serilog.HttpClient.Tests.csproj", "{59FC1E95-1976-448D-AD15-DA273680780E}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {31475D8A-0222-4C02-8A8F-75E1C79187E5}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {A4CD6F06-4763-44CE-B748-4BCF186FE584}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {5D943CED-45BE-44F2-8662-A957793EA8A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {5D943CED-45BE-44F2-8662-A957793EA8A0}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {5D943CED-45BE-44F2-8662-A957793EA8A0}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {5D943CED-45BE-44F2-8662-A957793EA8A0}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {59FC1E95-1976-448D-AD15-DA273680780E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {59FC1E95-1976-448D-AD15-DA273680780E}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {59FC1E95-1976-448D-AD15-DA273680780E}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {59FC1E95-1976-448D-AD15-DA273680780E}.Release|Any CPU.Build.0 = Release|Any CPU 38 | EndGlobalSection 39 | GlobalSection(NestedProjects) = preSolution 40 | {31475D8A-0222-4C02-8A8F-75E1C79187E5} = {AA7D5182-4380-4FEE-9C29-E82A7E18B238} 41 | {A4CD6F06-4763-44CE-B748-4BCF186FE584} = {73B9E424-BC51-4858-AB3B-5A848EC79CA1} 42 | {5D943CED-45BE-44F2-8662-A957793EA8A0} = {73B9E424-BC51-4858-AB3B-5A848EC79CA1} 43 | {59FC1E95-1976-448D-AD15-DA273680780E} = {AA7D5182-4380-4FEE-9C29-E82A7E18B238} 44 | EndGlobalSection 45 | EndGlobal 46 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.0", 4 | "rollForward": "latestMajor", 5 | "allowPrerelease": true 6 | } 7 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Controllers/HomeController.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Serilog.HttpClient.Samples.AspNetCore.Services; 4 | 5 | namespace Serilog.HttpClient.Samples.AspNetCore.Controllers 6 | { 7 | public class HomeController : Controller 8 | { 9 | private readonly IMyService _myService; 10 | 11 | public HomeController(IMyService myService) 12 | { 13 | _myService = myService; 14 | } 15 | 16 | public async Task Index() 17 | { 18 | var result = await _myService.SendRequest(); 19 | return Ok(result); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | using Serilog.Formatting.Compact; 10 | using Serilog.HttpClient.Extensions; 11 | 12 | namespace Serilog.HttpClient.Samples.AspNetCore 13 | { 14 | public class Program 15 | { 16 | public static void Main(string[] args) 17 | { 18 | CreateHostBuilder(args).Build().Run(); 19 | } 20 | 21 | public static IHostBuilder CreateHostBuilder(string[] args) => 22 | Host.CreateDefaultBuilder(args) 23 | .UseSerilogPlus(p => 24 | { 25 | p.WriteTo.Console(); 26 | p.WriteTo.File(new CompactJsonFormatter(), "App_Data/Logs/log.json"); 27 | }) 28 | .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); 29 | } 30 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:30748", 7 | "sslPort": 44345 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "Serilog.HttpClient.Samples.AspNetCore": { 19 | "commandName": "Project", 20 | "dotnetRunMessages": "true", 21 | "launchBrowser": true, 22 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Serilog.HttpClient.Samples.AspNetCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Services/IMyService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Serilog.HttpClient.Samples.AspNetCore.Services 4 | { 5 | public interface IMyService 6 | { 7 | Task SendRequest(); 8 | } 9 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Services/MyService.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using System.Threading.Tasks; 3 | 4 | namespace Serilog.HttpClient.Samples.AspNetCore.Services 5 | { 6 | public class MyService : IMyService 7 | { 8 | private readonly System.Net.Http.HttpClient _httpClient; 9 | 10 | public MyService(System.Net.Http.HttpClient httpClient) 11 | { 12 | _httpClient = httpClient; 13 | } 14 | 15 | public Task SendRequest() 16 | { 17 | return _httpClient.GetFromJsonAsync("https://reqres.in/api/users?page=2"); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | using Correlate.AspNetCore; 8 | using Correlate.DependencyInjection; 9 | using Microsoft.AspNetCore.Builder; 10 | using Microsoft.AspNetCore.Hosting; 11 | using Microsoft.AspNetCore.Http; 12 | using Microsoft.Extensions.DependencyInjection; 13 | using Microsoft.Extensions.Hosting; 14 | using Serilog.HttpClient.Extensions; 15 | using Serilog.HttpClient.Samples.AspNetCore.Services; 16 | 17 | namespace Serilog.HttpClient.Samples.AspNetCore 18 | { 19 | public class Startup 20 | { 21 | public void ConfigureServices(IServiceCollection services) 22 | { 23 | services.AddCorrelate(options => options.RequestHeaders = new [] { "X-Correlation-ID" }); 24 | 25 | services 26 | .AddHttpClient() 27 | .CorrelateRequests("X-Correlation-ID") 28 | .LogRequestResponse(p => 29 | { 30 | p.LogMode = LogMode.LogAll; 31 | p.RequestHeaderLogMode = LogMode.LogAll; 32 | p.RequestBodyLogMode = LogMode.LogAll; 33 | p.RequestBodyLogTextLengthLimit = 5000; 34 | p.ResponseHeaderLogMode = LogMode.LogAll; 35 | p.ResponseBodyLogMode = LogMode.LogAll; 36 | p.ResponseBodyLogTextLengthLimit = 5000; 37 | p.MaskFormat = "*****"; 38 | p.MaskedProperties.Clear(); 39 | p.MaskedProperties.Add("*password*"); 40 | p.MaskedProperties.Add("*token*"); 41 | }) 42 | // /*OR*/ .LogRequestResponse() 43 | .ConfigurePrimaryHttpMessageHandler(p => new HttpClientHandler() 44 | { 45 | //Proxy = new WebProxy("127.0.0.1", 8888) 46 | }); 47 | //or 48 | 49 | services.AddControllers(); 50 | } 51 | 52 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 53 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 54 | { 55 | if (env.IsDevelopment()) 56 | { 57 | app.UseDeveloperExceptionPage(); 58 | } 59 | 60 | app.UseCorrelate(); 61 | app.UseSerilogPlusRequestLogging(); 62 | 63 | app.UseRouting(); 64 | 65 | app.UseEndpoints(endpoints => 66 | { 67 | endpoints.MapControllerRoute( 68 | name: "default", 69 | pattern: "{controller=Home}/{action=Index}/{id?}"); 70 | }); 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.AspNetCore/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*" 3 | } 4 | -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.ConsoleApp/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Net.Http.Json; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Options; 6 | using Serilog.Formatting.Json; 7 | using Serilog.HttpClient.Extensions; 8 | using Serilog.Sinks.SystemConsole.Themes; 9 | 10 | namespace Serilog.HttpClient.Samples.ConsoleApp 11 | { 12 | class Program 13 | { 14 | static void Main(string[] args) 15 | { 16 | Serilog.Log.Logger = new LoggerConfiguration() 17 | .WriteTo.File(new JsonFormatter(),$"log-{DateTime.Now:yyyyMMdd-HHmmss}.json") 18 | .WriteTo.Console(outputTemplate: 19 | "[{Timestamp:HH:mm:ss} {Level:u3}] {Message} {NewLine}{Properties} {NewLine}{Exception}{NewLine}", 20 | theme: SystemConsoleTheme.Literate) 21 | .AddJsonDestructuringPolicies() 22 | .Enrich.FromLogContext() 23 | .CreateLogger(); 24 | 25 | var loggingHandler = new LoggingDelegatingHandler(new RequestLoggingOptions() 26 | { 27 | LogMode = LogMode.LogAll, 28 | RequestHeaderLogMode = LogMode.LogAll, 29 | RequestBodyLogMode = LogMode.LogAll, 30 | RequestBodyLogTextLengthLimit = 5000, 31 | ResponseHeaderLogMode = LogMode.LogAll, 32 | ResponseBodyLogMode = LogMode.LogAll, 33 | ResponseBodyLogTextLengthLimit = 5000, 34 | MaskFormat = "*****", 35 | MaskedProperties = { "password", "token" }, 36 | }); 37 | 38 | var c = new System.Net.Http.HttpClient(loggingHandler); 39 | var o = Task.Run(() => c.GetFromJsonAsync("https://reqres.in/api/users?page=2")).Result; 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /samples/Serilog.HttpClient.Samples.ConsoleApp/Serilog.HttpClient.Samples.ConsoleApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient.Tests/LoggingDelegatingHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Headers; 3 | using Moq; 4 | using Moq.Protected; 5 | using Serilog.Events; 6 | using Serilog.HttpClient.Extensions; 7 | using Serilog.HttpClient.Models; 8 | using Serilog.HttpClient.Tests.Support; 9 | using Serilog.Sinks.TestCorrelator; 10 | 11 | namespace Serilog.HttpClient.Tests; 12 | 13 | public class LoggingDelegatingHandlerTests 14 | { 15 | private readonly Mock _msgHandler = new(); 16 | 17 | [Fact] 18 | public void Test_Log_Request() 19 | { 20 | MockResponse(new HttpResponseMessage 21 | { 22 | StatusCode = HttpStatusCode.OK, 23 | Content = new StringContent("this is the response body"), 24 | Headers = 25 | { 26 | ETag = EntityTagHeaderValue.Any 27 | } 28 | }); 29 | 30 | var client = CreateHttpClient(new RequestLoggingOptions 31 | { 32 | ResponseBodyLogMode = LogMode.LogAll, 33 | }); 34 | 35 | using (TestCorrelator.CreateContext()) 36 | { 37 | client.SendAsync(new HttpRequestMessage 38 | { 39 | Method = HttpMethod.Post, 40 | RequestUri = new Uri("https://example.com/path?query=1"), 41 | Content = new StringContent("this is the request body"), 42 | Headers = 43 | { 44 | Referrer = new Uri("https://example.com/referrer") 45 | } 46 | }); 47 | 48 | var logEvents = TestCorrelator.GetLogEventsFromCurrentContext(); 49 | Assert.Single(logEvents); 50 | 51 | var logEvent = logEvents.First(); 52 | Assert.Equal("HTTP Client Request Completed {@Context}", logEvent.MessageTemplate.Text); 53 | Assert.Equal(LogEventLevel.Information, logEvent.Level); 54 | Assert.Null(logEvent.Exception); 55 | var request = ((StructureValue)((StructureValue)logEvent.Properties["Context"]).Properties.First(x => x.Name == nameof(HttpClientContext.Request)).Value).Properties.ToDictionary(x => x.Name); 56 | var response = ((StructureValue)((StructureValue)logEvent.Properties["Context"]).Properties.First(x => x.Name == nameof(HttpClientContext.Response)).Value).Properties.ToDictionary(x => x.Name); 57 | Assert.Equal("POST", request[nameof(HttpClientRequestContext.Method)].Value.ToScalar()); 58 | Assert.Equal("https", request[nameof(HttpClientRequestContext.Scheme)].Value.ToScalar()); 59 | Assert.Equal("example.com", request[nameof(HttpClientRequestContext.Host)].Value.ToScalar()); 60 | Assert.Equal("/path", request[nameof(HttpClientRequestContext.Path)].Value.ToScalar()); 61 | Assert.Equal("?query=1", request[nameof(HttpClientRequestContext.QueryString)].Value.ToScalar()); 62 | Assert.Equal("this is the request body", request[nameof(HttpClientRequestContext.BodyString)].Value.ToScalar()); 63 | Assert.Null(request[nameof(HttpClientRequestContext.Body)].Value.ToScalar()); 64 | Assert.Equal("Referer", request[nameof(HttpClientRequestContext.Headers)].Value.ToDictionary().First().Key.ToScalar()); 65 | Assert.Equal("https://example.com/referrer", request[nameof(HttpClientRequestContext.Headers)].Value.ToDictionary().First().Value.ToScalar()); 66 | 67 | Assert.Equal(200 , response[nameof(HttpClientResponseContext.StatusCode)].Value.ToScalar()); 68 | Assert.True((bool)response[nameof(HttpClientResponseContext.IsSucceed)].Value.ToScalar()); 69 | Assert.IsType(response[nameof(HttpClientResponseContext.ElapsedMilliseconds)].Value.ToScalar()); 70 | Assert.Equal("this is the response body", response[nameof(HttpClientResponseContext.BodyString)].Value.ToScalar()); 71 | Assert.Null(request[nameof(HttpClientResponseContext.Body)].Value.ToScalar()); 72 | Assert.Equal("ETag", response[nameof(HttpClientResponseContext.Headers)].Value.ToDictionary().First().Key.ToScalar()); 73 | Assert.Equal("*", response[nameof(HttpClientResponseContext.Headers)].Value.ToDictionary().First().Value.ToScalar()); 74 | } 75 | } 76 | 77 | [Fact] 78 | public void Test_Log_Filter_Request() 79 | { 80 | MockResponse(new HttpResponseMessage 81 | { 82 | StatusCode = HttpStatusCode.OK, 83 | Content = new StringContent("this is the response body"), 84 | Headers = 85 | { 86 | ETag = EntityTagHeaderValue.Any 87 | } 88 | }); 89 | 90 | var client = CreateHttpClient(new RequestLoggingOptions 91 | { 92 | ResponseBodyLogMode = LogMode.LogAll, 93 | LogFilter = (req, resp, elapsedMs, ex) => req.RequestUri.ToString() != "https://example.com/path?query=1" 94 | }); 95 | 96 | using (TestCorrelator.CreateContext()) 97 | { 98 | client.SendAsync(new HttpRequestMessage 99 | { 100 | Method = HttpMethod.Post, 101 | RequestUri = new Uri("https://example.com/path?query=1"), 102 | Content = new StringContent("this is the request body"), 103 | Headers = 104 | { 105 | Referrer = new Uri("https://example.com/referrer") 106 | } 107 | }); 108 | 109 | var logEvents = TestCorrelator.GetLogEventsFromCurrentContext(); 110 | Assert.Empty(logEvents); 111 | } 112 | } 113 | 114 | [Fact] 115 | public void Test_Log_Request_With_Customized_Log_Entry() 116 | { 117 | MockResponse(new HttpResponseMessage 118 | { 119 | StatusCode = HttpStatusCode.OK, 120 | Content = new StringContent("this is the response body"), 121 | Headers = 122 | { 123 | ETag = EntityTagHeaderValue.Any 124 | } 125 | }); 126 | 127 | var client = CreateHttpClient(new RequestLoggingOptions 128 | { 129 | ResponseBodyLogMode = LogMode.LogAll, 130 | MessageTemplate = "HTTP {RequestMethod} {RequestUri} responded {StatusCode} in {ElapsedMilliseconds:0.0000} ms", 131 | GetMessageTemplateProperties = (c, l) => new[] 132 | { 133 | new LogEventProperty("RequestMethod", new ScalarValue(c.Request.Method)), 134 | new LogEventProperty("RequestUri", new ScalarValue(c.Request.Url)), 135 | new LogEventProperty("StatusCode", new ScalarValue(c.Response.StatusCode)), 136 | new LogEventProperty("ElapsedMilliseconds", new ScalarValue(c.Response.ElapsedMilliseconds)) 137 | }, 138 | }); 139 | 140 | using (TestCorrelator.CreateContext()) 141 | { 142 | client.SendAsync(new HttpRequestMessage 143 | { 144 | Method = HttpMethod.Post, 145 | RequestUri = new Uri("https://example.com/path?query=1"), 146 | Content = new StringContent("this is the request body"), 147 | Headers = 148 | { 149 | Referrer = new Uri("https://example.com/referrer") 150 | } 151 | }); 152 | 153 | var logEvents = TestCorrelator.GetLogEventsFromCurrentContext(); 154 | Assert.Single(logEvents); 155 | 156 | var logEvent = logEvents.First(); 157 | Assert.Equal("HTTP {RequestMethod} {RequestUri} responded {StatusCode} in {ElapsedMilliseconds:0.0000} ms", 158 | logEvent.MessageTemplate.Text); 159 | Assert.Equal(LogEventLevel.Information, logEvent.Level); 160 | Assert.Null(logEvent.Exception); 161 | 162 | Assert.Equal("POST", logEvent.Properties["RequestMethod"].ToScalar()); 163 | Assert.Equal("https://example.com/path?query=1", logEvent.Properties["RequestUri"].ToScalar()); 164 | Assert.Equal(200, logEvent.Properties["StatusCode"].ToScalar()); 165 | Assert.IsType(logEvent.Properties["ElapsedMilliseconds"].ToScalar()); 166 | } 167 | } 168 | 169 | [Fact] 170 | public void Test_Log_Request_With_Masking() 171 | { 172 | MockResponse(new HttpResponseMessage 173 | { 174 | StatusCode = HttpStatusCode.OK, 175 | Content = new StringContent("{\"Password\": false, \"Token\": \"abcdef\"}"), 176 | Headers = 177 | { 178 | WwwAuthenticate = { new AuthenticationHeaderValue("Bearer") } 179 | } 180 | }); 181 | 182 | var client = CreateHttpClient(new RequestLoggingOptions 183 | { 184 | ResponseBodyLogMode = LogMode.LogAll, 185 | MaskedProperties = { "password", "token", "authorization", "*authenticate*" }, 186 | }); 187 | 188 | using (TestCorrelator.CreateContext()) 189 | { 190 | client.SendAsync(new HttpRequestMessage 191 | { 192 | Method = HttpMethod.Post, 193 | RequestUri = new Uri("https://example.com/path"), 194 | Content = new StringContent("{\"Authorization\": 1234, \"Password\": \"xyz\"}"), 195 | Headers = 196 | { 197 | Authorization = new AuthenticationHeaderValue("Bearer", "abcdef") 198 | } 199 | }); 200 | 201 | var logEvents = TestCorrelator.GetLogEventsFromCurrentContext(); 202 | Assert.Single(logEvents); 203 | 204 | var logEvent = logEvents.First(); 205 | var context = (StructureValue)logEvent.Properties["Context"]; 206 | var req = context.Properties.First(x => x.Name == nameof(HttpClientContext.Request)); 207 | var resp = context.Properties.First(x => x.Name == nameof(HttpClientContext.Response)); 208 | var requestHeaders = ((StructureValue)req.Value).Properties.First(x => x.Name == nameof(HttpClientRequestContext.Headers)).Value.ToDictionary(); ; 209 | var authHeader = requestHeaders.First(x => x.Key.ToScalar().ToString() == "Authorization").Value.ToString(); 210 | var responseBody = ((StructureValue)resp.Value).Properties.First(x => x.Name == nameof(HttpClientResponseContext.Body)).Value; 211 | var passwordValue = ((StructureValue)responseBody).Properties.First(x => x.Name == "Password").Value.ToScalar() 212 | .ToString(); 213 | Assert.Contains("\"*** MASKED ***\"", authHeader); 214 | Assert.Contains("*** MASKED ***", passwordValue); 215 | } 216 | } 217 | 218 | private void MockResponse(HttpResponseMessage response) 219 | { 220 | var mockedProtected = _msgHandler.Protected(); 221 | var setupApiRequest = mockedProtected.Setup>( 222 | "SendAsync", 223 | ItExpr.IsAny(), 224 | ItExpr.IsAny() 225 | ); 226 | var apiMockedResponse = setupApiRequest.ReturnsAsync(response); 227 | apiMockedResponse.Verifiable(); 228 | } 229 | 230 | private System.Net.Http.HttpClient CreateHttpClient(RequestLoggingOptions options) 231 | { 232 | Log.Logger = new LoggerConfiguration() 233 | .Enrich.FromLogContext() 234 | .AddJsonDestructuringPolicies() 235 | .WriteTo.TestCorrelator() 236 | .CreateLogger(); 237 | 238 | options.Logger = Log.Logger; 239 | 240 | var loggingHandler = new LoggingDelegatingHandler(options); 241 | loggingHandler.InnerHandler = _msgHandler.Object; 242 | var client = new System.Net.Http.HttpClient(loggingHandler); 243 | return client; 244 | } 245 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient.Tests/MaskHelperTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using Moq; 6 | using Moq.Protected; 7 | using Newtonsoft.Json.Linq; 8 | using Serilog.Events; 9 | using Serilog.HttpClient.Extensions; 10 | using Serilog.HttpClient.Tests.Support; 11 | using Serilog.Sinks.TestCorrelator; 12 | 13 | namespace Serilog.HttpClient.Tests; 14 | 15 | public class MaskHelperTests 16 | { 17 | [Fact] 18 | public void Test_MaskFields() 19 | { 20 | var blacklist = new string[] { "*token*" }; 21 | var mask = "*MASK*"; 22 | 23 | "{\"token\": \"abc\"}".TryGetJToken(out JToken jToken); 24 | var result = jToken.MaskFields(blacklist, mask).ToString().Replace("\r\n", string.Empty).Replace(" ", string.Empty); 25 | Assert.Equal("{\"token\":\"*MASK*\"}", result); 26 | 27 | "[{\"token\": \"abc\"}]".TryGetJToken(out jToken); 28 | result = jToken.MaskFields(blacklist, mask).ToString().Replace("\r\n", string.Empty).Replace(" ", string.Empty); 29 | Assert.Equal("[{\"token\":\"*MASK*\"}]", result); 30 | 31 | "{\"nested\": {\"token\": \"abc\"}}".TryGetJToken(out jToken); 32 | result = jToken.MaskFields(blacklist, mask).ToString().Replace("\r\n", string.Empty).Replace(" ", string.Empty); 33 | Assert.Equal("{\"nested\":{\"token\":\"*MASK*\"}}", result); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient.Tests/Serilog.HttpClient.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient.Tests/Support/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Serilog.Events; 2 | 3 | namespace Serilog.HttpClient.Tests.Support 4 | { 5 | public static class Extensions 6 | { 7 | public static object ToScalar(this LogEventPropertyValue @this) 8 | { 9 | if (@this is not ScalarValue) 10 | throw new ArgumentException("Must be a ScalarValue", nameof(@this)); 11 | return ((ScalarValue)@this).Value; 12 | } 13 | 14 | public static IReadOnlyDictionary ToDictionary(this LogEventPropertyValue @this) 15 | { 16 | if (@this is not DictionaryValue) 17 | throw new ArgumentException("Must be a DictionaryValue", nameof(@this)); 18 | 19 | return ((DictionaryValue)@this).Elements; 20 | } 21 | 22 | public static IReadOnlyList ToSequence(this LogEventPropertyValue @this) 23 | { 24 | if (@this is not SequenceValue) 25 | throw new ArgumentException("Must be a SequenceValue", nameof(@this)); 26 | 27 | return ((SequenceValue)@this).Elements; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient/DestructuringPolicies/JsonDocumentDestructuringPolicy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text.Json; 4 | using Serilog.Core; 5 | using Serilog.Events; 6 | 7 | namespace Serilog.HttpClient.DestructuringPolicies 8 | { 9 | internal class JsonDocumentDestructuringPolicy : IDestructuringPolicy 10 | { 11 | public bool TryDestructure(object value, ILogEventPropertyValueFactory _, out LogEventPropertyValue result) 12 | { 13 | if (!(value is JsonDocument jdoc)) 14 | { 15 | result = null; 16 | return false; 17 | } 18 | 19 | result = Destructure(jdoc.RootElement); 20 | return true; 21 | } 22 | 23 | static LogEventPropertyValue Destructure(in JsonElement jel) 24 | { 25 | switch (jel.ValueKind) 26 | { 27 | case JsonValueKind.Array: 28 | return new SequenceValue(jel.EnumerateArray().Select(ae => Destructure(in ae))); 29 | 30 | case JsonValueKind.False: 31 | return new ScalarValue(false); 32 | 33 | case JsonValueKind.True: 34 | return new ScalarValue(true); 35 | 36 | case JsonValueKind.Null: 37 | case JsonValueKind.Undefined: 38 | return new ScalarValue(null); 39 | 40 | case JsonValueKind.Number: 41 | return new ScalarValue(jel.GetDecimal()); 42 | 43 | case JsonValueKind.String: 44 | return new ScalarValue(jel.GetString()); 45 | 46 | case JsonValueKind.Object: 47 | return new StructureValue(jel.EnumerateObject().Select(jp => new LogEventProperty(jp.Name, Destructure(jp.Value)))); 48 | 49 | default: 50 | throw new ArgumentException("Unrecognized value kind " + jel.ValueKind + "."); 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient/DestructuringPolicies/JsonNetDestructuringPolicy.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Newtonsoft.Json.Linq; 4 | using Serilog.Core; 5 | using Serilog.Events; 6 | 7 | namespace Serilog.HttpClient.DestructuringPolicies 8 | { 9 | // taken from https://github.com/destructurama/json-net 10 | internal class JsonNetDestructuringPolicy : IDestructuringPolicy 11 | { 12 | public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, out LogEventPropertyValue result) 13 | { 14 | switch (value) 15 | { 16 | case JObject jo: 17 | result = Destructure(jo, propertyValueFactory); 18 | return true; 19 | case JArray ja: 20 | result = Destructure(ja, propertyValueFactory); 21 | return true; 22 | case JValue jv: 23 | result = Destructure(jv, propertyValueFactory); 24 | return true; 25 | } 26 | 27 | result = null; 28 | return false; 29 | } 30 | 31 | private static LogEventPropertyValue Destructure(JValue jv, ILogEventPropertyValueFactory propertyValueFactory) 32 | { 33 | return propertyValueFactory.CreatePropertyValue(jv.Value, true); 34 | } 35 | 36 | private static LogEventPropertyValue Destructure(JArray ja, ILogEventPropertyValueFactory propertyValueFactory) 37 | { 38 | var elems = ja.Select(t => propertyValueFactory.CreatePropertyValue(t, true)); 39 | return new SequenceValue(elems); 40 | } 41 | 42 | private static LogEventPropertyValue Destructure(JObject jo, ILogEventPropertyValueFactory propertyValueFactory) 43 | { 44 | string typeTag = null; 45 | var props = new List(jo.Count); 46 | 47 | foreach (var prop in jo.Properties()) 48 | { 49 | if (prop.Name == "$type") 50 | { 51 | if (prop.Value is JValue typeVal && typeVal.Value is string) 52 | { 53 | typeTag = (string)typeVal.Value; 54 | continue; 55 | } 56 | } 57 | else if (!LogEventProperty.IsValidName(prop.Name)) 58 | { 59 | return DestructureToDictionaryValue(jo, propertyValueFactory); 60 | } 61 | 62 | props.Add(new LogEventProperty(prop.Name, propertyValueFactory.CreatePropertyValue(prop.Value, true))); 63 | } 64 | 65 | return new StructureValue(props, typeTag); 66 | } 67 | 68 | private static LogEventPropertyValue DestructureToDictionaryValue(JObject jo, ILogEventPropertyValueFactory propertyValueFactory) 69 | { 70 | var elements = jo.Properties().Select( 71 | prop => 72 | new KeyValuePair( 73 | new ScalarValue(prop.Name), 74 | propertyValueFactory.CreatePropertyValue(prop.Value, true) 75 | ) 76 | ); 77 | return new DictionaryValue(elements); 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Extensions/HttpClientBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.DependencyInjection.Extensions; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace Serilog.HttpClient.Extensions 7 | { 8 | public static class HttpClientBuilderExtensions 9 | { 10 | /// 11 | /// Adds services required for logging request/response to each outgoing request. 12 | /// 13 | /// The to add the services to. 14 | /// The action used to configure . 15 | /// The so that additional calls can be chained. 16 | public static IHttpClientBuilder LogRequestResponse(this IHttpClientBuilder builder, 17 | Action configureOptions = null) 18 | { 19 | if (configureOptions == null) 20 | configureOptions = options => { }; 21 | if (builder is null) 22 | throw new ArgumentNullException(nameof(builder)); 23 | 24 | builder.Services.Configure(builder.Name, configureOptions); 25 | builder.Services.TryAddTransient(s => 26 | { 27 | var o = s.GetRequiredService>(); 28 | return new LoggingDelegatingHandler(o.Get(builder.Name), default, true); 29 | }); 30 | builder.AddHttpMessageHandler(s => s.GetRequiredService()); 31 | 32 | return builder; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Extensions/JsonExtension.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using Microsoft.Extensions.Primitives; 6 | using Newtonsoft.Json.Linq; 7 | 8 | namespace Serilog.HttpClient.Extensions 9 | { 10 | public static class JsonExtension 11 | { 12 | public static bool TryGetJToken(this string text, out JToken jToken) 13 | { 14 | jToken = null; 15 | text = text.Trim(); 16 | if ((text.StartsWith("{") && text.EndsWith("}")) || //For object 17 | (text.StartsWith("[") && text.EndsWith("]"))) //For array 18 | { 19 | try 20 | { 21 | jToken = JToken.Parse(text); 22 | return true; 23 | } 24 | catch(Exception) { 25 | return false; 26 | } 27 | } 28 | else 29 | { 30 | return false; 31 | } 32 | } 33 | 34 | /// 35 | /// Masks specified json string using provided options 36 | /// 37 | /// Json to mask 38 | /// Fields to mask 39 | /// Mask format 40 | /// 41 | /// 42 | public static JToken MaskFields(this JToken json, string[] blacklist, string mask) 43 | { 44 | if (blacklist == null) 45 | throw new ArgumentNullException(nameof(blacklist)); 46 | 47 | if (blacklist.Any() == false) 48 | return json; 49 | 50 | if (json is JArray jArray) 51 | { 52 | foreach (var jToken in jArray) 53 | { 54 | MaskFieldsFromJToken(jToken, blacklist, mask); 55 | } 56 | } 57 | else if (json is JObject jObject) 58 | { 59 | MaskFieldsFromJToken(jObject, blacklist, mask); 60 | } 61 | 62 | return json; 63 | } 64 | 65 | private static void MaskFieldsFromJToken(JToken token, string[] blacklist, string mask) 66 | { 67 | JContainer container = token as JContainer; 68 | if (container == null) 69 | { 70 | return; // abort recursive 71 | } 72 | 73 | List removeList = new List(); 74 | foreach (JToken jtoken in container.Children()) 75 | { 76 | if (jtoken is JProperty prop) 77 | { 78 | if (IsMaskMatch(prop.Path, blacklist)) 79 | { 80 | removeList.Add(jtoken); 81 | } 82 | } 83 | 84 | // call recursive 85 | MaskFieldsFromJToken(jtoken, blacklist, mask); 86 | } 87 | 88 | // replace 89 | foreach (JToken el in removeList) 90 | { 91 | var prop = (JProperty)el; 92 | prop.Value = mask; 93 | } 94 | } 95 | 96 | /// 97 | /// Check whether specified path must be masked 98 | /// 99 | /// 100 | /// 101 | /// 102 | public static bool IsMaskMatch(string path, string[] blacklist) 103 | { 104 | return blacklist.Any(item => Regex.IsMatch(path, WildCardToRegular(item), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)); 105 | } 106 | 107 | private static string WildCardToRegular(string value) 108 | { 109 | return "^" + Regex.Escape(value).Replace("\\*", ".*") + "$"; 110 | } 111 | 112 | /// 113 | /// Masks key-value paired items 114 | /// 115 | /// 116 | /// 117 | /// 118 | /// 119 | public static IEnumerable>> Mask( 120 | this IEnumerator>> keyValuePairs, string[] blacklist, 121 | string mask) 122 | { 123 | var valuePairs = new List>>(); 124 | while (keyValuePairs.MoveNext()) 125 | { 126 | var item = keyValuePairs.Current; 127 | if (IsMaskMatch(item.Key, blacklist)) 128 | valuePairs.Add(new KeyValuePair>(item.Key, new []{mask})); 129 | else 130 | valuePairs.Add(new KeyValuePair>(item.Key, item.Value)); 131 | } 132 | 133 | return valuePairs; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Extensions/LoggerConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Serilog.HttpClient.DestructuringPolicies; 2 | 3 | namespace Serilog.HttpClient.Extensions 4 | { 5 | public static class LoggerConfigurationExtensions 6 | { 7 | public static LoggerConfiguration AddJsonDestructuringPolicies(this LoggerConfiguration loggerConfiguration) 8 | { 9 | loggerConfiguration 10 | .Destructure.With() 11 | .Destructure.With(); 12 | 13 | return loggerConfiguration; 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/LogEntryParameters.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Serilog.HttpClient 4 | { 5 | /// 6 | /// Log entry output parameters 7 | /// 8 | public class LogEntryParameters 9 | { 10 | /// 11 | /// Message Template to log 12 | /// 13 | public string MessageTemplate { get; set; } 14 | 15 | /// 16 | /// Message parameter values as specified on message template 17 | /// 18 | public object[] MessageParameters { get; set; } 19 | 20 | /// 21 | /// Additional properties to enrich 22 | /// 23 | public Dictionary AdditionalProperties { get; set; } = new Dictionary(); 24 | } 25 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/LogMode.cs: -------------------------------------------------------------------------------- 1 | namespace Serilog.HttpClient 2 | { 3 | /// 4 | /// Determines when do logging 5 | /// 6 | public enum LogMode 7 | { 8 | /// 9 | /// Logs no data whether operation succeed or failed 10 | /// 11 | LogNone, 12 | 13 | /// 14 | /// Logs all http requests including success and failures 15 | /// 16 | LogAll, 17 | 18 | /// 19 | /// Log only failed http requests 20 | /// 21 | LogFailures 22 | } 23 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/LoggingDelegatingHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Serilog Contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | using System; 16 | using System.Collections.Generic; 17 | using System.Diagnostics; 18 | using System.Linq; 19 | using System.Net.Http; 20 | using System.Text.Json; 21 | using System.Threading; 22 | using System.Threading.Tasks; 23 | using System.Web; 24 | using Microsoft.Extensions.Options; 25 | using Newtonsoft.Json; 26 | using Newtonsoft.Json.Linq; 27 | using Serilog.Context; 28 | using Serilog.Debugging; 29 | using Serilog.Events; 30 | using Serilog.HttpClient.Extensions; 31 | using Serilog.HttpClient.Extensions; 32 | using Serilog.HttpClient.Models; 33 | using Serilog.Parsing; 34 | 35 | namespace Serilog.HttpClient 36 | { 37 | public class LoggingDelegatingHandler : DelegatingHandler 38 | { 39 | private readonly RequestLoggingOptions _options; 40 | private readonly ILogger _logger; 41 | private readonly MessageTemplate _messageTemplate; 42 | 43 | public LoggingDelegatingHandler( 44 | RequestLoggingOptions options, 45 | HttpMessageHandler httpMessageHandler = default, bool forHttpClientFactory = false) 46 | { 47 | _options = options ?? throw new ArgumentNullException(nameof(options)); 48 | _logger = options.Logger?.ForContext() ?? Serilog.Log.Logger.ForContext(); 49 | _messageTemplate = new MessageTemplateParser().Parse(options.MessageTemplate); 50 | 51 | #if NETCOREAPP3_1_OR_GREATER 52 | if (!forHttpClientFactory) 53 | { 54 | InnerHandler = httpMessageHandler ?? new SocketsHttpHandler(); 55 | } 56 | #else 57 | if (!forHttpClientFactory) 58 | { 59 | InnerHandler = httpMessageHandler ?? new HttpClientHandler(); 60 | } 61 | #endif 62 | 63 | } 64 | 65 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 66 | { 67 | var start = Stopwatch.GetTimestamp(); 68 | try 69 | { 70 | var resp = await base.SendAsync(request, cancellationToken); 71 | var elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp()); 72 | await LogRequest(request, resp, elapsedMs, null); 73 | return resp; 74 | } 75 | catch (Exception ex) 76 | { 77 | var elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp()); 78 | await LogRequest(request, null, elapsedMs, ex); 79 | throw; 80 | } 81 | } 82 | 83 | static double GetElapsedMilliseconds(long start, long stop) 84 | { 85 | return (stop - start) * 1000 / (double)Stopwatch.Frequency; 86 | } 87 | 88 | private async Task LogRequest(HttpRequestMessage req, HttpResponseMessage resp, double elapsedMs, 89 | Exception ex) 90 | { 91 | var level = _options.GetLevel(req, resp, elapsedMs, ex); 92 | if (!_logger.IsEnabled(level)) return; 93 | if (!_options.LogFilter(req, resp, elapsedMs, ex)) return; 94 | var requestBodyText = string.Empty; 95 | var responseBodyText = string.Empty; 96 | 97 | var isRequestOk = !(resp != null && (int)resp.StatusCode >= 400 || ex != null); 98 | if (_options.LogMode == LogMode.LogAll || 99 | (!isRequestOk && _options.LogMode == LogMode.LogFailures)) 100 | { 101 | object requestBody = null; 102 | if ((_options.RequestBodyLogMode == LogMode.LogAll || 103 | (!isRequestOk && _options.RequestBodyLogMode == LogMode.LogFailures))) 104 | { 105 | if (req.Content != null) 106 | requestBodyText = await req.Content.ReadAsStringAsync(); 107 | if (_options.LogRequestBodyAsStructuredObject && !string.IsNullOrWhiteSpace(requestBodyText)) 108 | { 109 | JToken token; 110 | if (requestBodyText.TryGetJToken(out token)) 111 | { 112 | var jToken = token.MaskFields(_options.MaskedProperties.ToArray(), 113 | _options.MaskFormat); 114 | requestBodyText = jToken.ToString(); 115 | requestBody = jToken; 116 | } 117 | 118 | if (requestBodyText.Length > _options.RequestBodyLogTextLengthLimit) 119 | requestBodyText = requestBodyText.Substring(0, _options.RequestBodyLogTextLengthLimit); 120 | } 121 | } 122 | else 123 | { 124 | requestBodyText = "(Not Logged)"; 125 | } 126 | 127 | var requestHeaders = new Dictionary(); 128 | if (_options.RequestHeaderLogMode == LogMode.LogAll || 129 | (!isRequestOk && _options.RequestHeaderLogMode == LogMode.LogFailures)) 130 | { 131 | try 132 | { 133 | var valuesByKey = req.Headers.GetEnumerator() 134 | .Mask(_options.MaskedProperties.ToArray(), _options.MaskFormat); 135 | foreach (var item in valuesByKey) 136 | { 137 | if (item.Value.Count() > 1) 138 | requestHeaders.Add(item.Key, item.Value); 139 | else 140 | requestHeaders.Add(item.Key, item.Value.First()); 141 | } 142 | } 143 | catch (Exception headerParseException) 144 | { 145 | SelfLog.WriteLine("Cannot parse request header: " + headerParseException); 146 | } 147 | } 148 | 149 | var requestQuery = new Dictionary(); 150 | try 151 | { 152 | if (!string.IsNullOrWhiteSpace(req.RequestUri.Query)) 153 | { 154 | var q = HttpUtility.ParseQueryString(req.RequestUri.Query); 155 | 156 | foreach (var key in q.AllKeys) 157 | { 158 | requestQuery.Add(key, q[key]); 159 | } 160 | } 161 | } 162 | catch (Exception) 163 | { 164 | SelfLog.WriteLine("Cannot parse query string"); 165 | } 166 | 167 | var requestData = new HttpClientRequestContext 168 | { 169 | Url = req.RequestUri.ToString(), 170 | Method = req.Method?.Method, 171 | Scheme = req.RequestUri.Scheme, 172 | Host = req.RequestUri.Host, 173 | Path = req.RequestUri.AbsolutePath, 174 | QueryString = req.RequestUri.Query, 175 | Query = requestQuery, 176 | BodyString = requestBodyText, 177 | Body = requestBody, 178 | Headers = requestHeaders 179 | }; 180 | 181 | object responseBody = null; 182 | if ((_options.ResponseBodyLogMode == LogMode.LogAll || 183 | (!isRequestOk && _options.ResponseBodyLogMode == LogMode.LogFailures))) 184 | { 185 | if (resp?.Content != null) 186 | responseBodyText = await resp?.Content.ReadAsStringAsync(); 187 | if (_options.LogResponseBodyAsStructuredObject && !string.IsNullOrWhiteSpace(responseBodyText)) 188 | { 189 | JToken jToken; 190 | if (responseBodyText.TryGetJToken(out jToken)) 191 | { 192 | jToken = jToken.MaskFields(_options.MaskedProperties.ToArray(), _options.MaskFormat); 193 | responseBodyText = jToken.ToString(); 194 | responseBody = jToken; 195 | } 196 | 197 | if (responseBodyText.Length > _options.ResponseBodyLogTextLengthLimit) 198 | responseBodyText = responseBodyText.Substring(0, _options.ResponseBodyLogTextLengthLimit); 199 | } 200 | } 201 | else 202 | { 203 | responseBodyText = "(Not Logged)"; 204 | } 205 | 206 | var responseHeaders = new Dictionary(); 207 | if (_options.ResponseHeaderLogMode == LogMode.LogAll || 208 | (!isRequestOk && _options.ResponseHeaderLogMode == LogMode.LogFailures) 209 | && resp != null) 210 | { 211 | try 212 | { 213 | var valuesByKey = resp.Headers.GetEnumerator() 214 | .Mask(_options.MaskedProperties.ToArray(), _options.MaskFormat); 215 | foreach (var item in valuesByKey) 216 | { 217 | if (item.Value.Count() > 1) 218 | responseHeaders.Add(item.Key, item.Value); 219 | else 220 | responseHeaders.Add(item.Key, item.Value.First()); 221 | } 222 | } 223 | catch (Exception headerParseException) 224 | { 225 | SelfLog.WriteLine("Cannot parse response header: " + headerParseException); 226 | } 227 | } 228 | 229 | var responseData = new HttpClientResponseContext 230 | { 231 | StatusCode = (int?)resp?.StatusCode, 232 | IsSucceed = isRequestOk, 233 | ElapsedMilliseconds = elapsedMs, 234 | BodyString = responseBodyText, 235 | Body = responseBody, 236 | Headers = responseHeaders 237 | }; 238 | 239 | var httpClientContext = new HttpClientContext { Request = requestData, Response = responseData }; 240 | var messageProperties = _options.GetMessageTemplateProperties(httpClientContext, _logger); 241 | var contextLogger = _logger; 242 | 243 | var traceId = Activity.Current?.TraceId ?? default(ActivityTraceId); 244 | var spanId = Activity.Current?.SpanId ?? default(ActivitySpanId); 245 | 246 | var evt = new LogEvent( 247 | DateTimeOffset.Now, 248 | level, 249 | ex , 250 | _messageTemplate, 251 | messageProperties, 252 | traceId, 253 | spanId); 254 | 255 | contextLogger.Write(evt); 256 | } 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Models/HttpClientContext.cs: -------------------------------------------------------------------------------- 1 | namespace Serilog.HttpClient.Models 2 | { 3 | /// 4 | /// HTTP client request/response contextual properties 5 | /// 6 | public class HttpClientContext 7 | { 8 | /// 9 | /// HTTP request information 10 | /// 11 | public HttpClientRequestContext Request { get; set; } 12 | 13 | /// 14 | /// HTTP response information 15 | /// 16 | public HttpClientResponseContext Response { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Models/HttpClientRequestContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net.Http; 3 | 4 | namespace Serilog.HttpClient.Models 5 | { 6 | /// 7 | /// HTTP request information 8 | /// 9 | public class HttpClientRequestContext 10 | { 11 | /// 12 | /// Url of request, for example https://test.com/rls?query=2 13 | /// 14 | public string Url { get; set; } 15 | 16 | /// 17 | /// HTTP method like GET, POST, ... 18 | /// 19 | public string Method { get; set; } 20 | 21 | /// 22 | /// HTTP request scheme, HTTP or HTTPS 23 | /// 24 | public string Scheme { get; set; } 25 | 26 | /// 27 | /// Host name like www.example.com 28 | /// 29 | public string Host { get; set; } 30 | 31 | /// 32 | /// Request path. for example /api/v1/tickets 33 | /// 34 | public string Path { get; set; } 35 | 36 | /// 37 | /// Request query string. for example trackId=123514325&page=10 38 | /// 39 | public string QueryString { get; set; } 40 | 41 | /// 42 | /// Query string as dictionary object for structured logging and searching on platforms like elastic, splunk ... 43 | /// 44 | public Dictionary Query { get; set; } 45 | 46 | /// 47 | /// Request body as string. request body maybe trimmed if exceeds length limit as specified in request logging option 48 | /// 49 | public string BodyString { get; set; } 50 | 51 | /// 52 | /// Request body as object for structured logging and searching on platforms like elastic, splunk. this property populated when enabled on request logging option (LogRequestBodyAsStructuredObject). default is true. 53 | /// 54 | public object Body { get; set; } 55 | 56 | /// 57 | /// Request headers. masking also applied on request headers. 58 | /// 59 | public Dictionary Headers { get; set; } 60 | } 61 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Models/HttpClientResponseContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Serilog.HttpClient.Models 4 | { 5 | /// 6 | /// HTTP response information 7 | /// 8 | public class HttpClientResponseContext 9 | { 10 | /// 11 | /// HTTP response status code 12 | /// 13 | public int? StatusCode { get; set; } 14 | 15 | /// 16 | /// Determines whether http request succeed or not. HTTP request determined as succeed if http status code less than 400 and no exception has been occured 17 | /// 18 | public bool IsSucceed { get; set; } 19 | 20 | /// 21 | /// Time separated that request have been executed 22 | /// 23 | public double ElapsedMilliseconds { get; set; } 24 | 25 | /// 26 | /// Response body as string. response body maybe trimmed if exceeds length limit as specified in request logging option 27 | /// 28 | public string BodyString { get; set; } 29 | 30 | /// 31 | /// Response body as object for structured logging and searching on platforms like elastic, splunk. this property populated when enabled on request logging option (LogResponseBodyAsStructuredObject). default is true. 32 | /// 33 | public object Body { get; set; } 34 | 35 | /// 36 | /// Request headers. masking also applied on request headers. 37 | /// 38 | public Dictionary Headers { get; set; } 39 | } 40 | } -------------------------------------------------------------------------------- /src/Serilog.HttpClient/RequestLoggingOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using Serilog.Events; 5 | using Serilog.HttpClient; 6 | using Serilog.HttpClient.Models; 7 | 8 | // ReSharper disable UnusedAutoPropertyAccessor.Global 9 | 10 | namespace Serilog.HttpClient 11 | { 12 | /// 13 | /// Contains options for the . 14 | /// 15 | public class RequestLoggingOptions 16 | { 17 | /// 18 | /// Gets or sets the message template. The default value is 19 | /// "HTTP Client Request Completed {@Context}". The 20 | /// template can contain any of the placeholders from the default template, names of properties 21 | /// 22 | /// 23 | /// The message template. 24 | /// 25 | public string MessageTemplate { get; set; } 26 | 27 | /// 28 | /// A function to specify the values of the MessageTemplateProperties. 29 | /// 30 | public Func> GetMessageTemplateProperties { get; set; } 31 | 32 | /// 33 | /// A function returning the based on the / information, 34 | /// the number of elapsed milliseconds required for handling the request, and an if one was thrown. 35 | /// The default behavior returns when the response status code is greater than 499 or if the 36 | /// is not null. Also default log level for 4xx range errors set to 37 | /// 38 | /// 39 | /// A function returning the . 40 | /// 41 | public Func GetLevel { get; set; } 42 | 43 | /// 44 | /// A function returning weather request should be logged based on the / information, 45 | /// the number of elapsed milliseconds required for handling the request, and an if one was thrown. 46 | /// 47 | public Func LogFilter { get; set; } 48 | 49 | /// 50 | /// The logger through which request completion events will be logged. The default is to use the 51 | /// static class. 52 | /// 53 | public ILogger Logger { get; set; } 54 | 55 | /// 56 | /// Determines when logging requests information. Default is true. 57 | /// 58 | public LogMode LogMode { get; set; } = LogMode.LogAll; 59 | 60 | /// 61 | /// Determines when logging request headers 62 | /// 63 | public LogMode RequestHeaderLogMode { get; set; } = LogMode.LogAll; 64 | 65 | /// 66 | /// Determines when logging request body data 67 | /// 68 | public LogMode RequestBodyLogMode { get; set; } = LogMode.LogAll; 69 | 70 | /// 71 | /// Determines weather to log request as structured object instead of string. This is useful when you use Elastic, Splunk or any other platform to search on object properties. Default is true. Masking only works when this options is enabled. 72 | /// 73 | public bool LogRequestBodyAsStructuredObject { get; set; } = true; 74 | 75 | /// 76 | /// Determines when logging response headers 77 | /// 78 | public LogMode ResponseHeaderLogMode { get; set; } = LogMode.LogAll; 79 | 80 | /// 81 | /// Determines when logging response body data 82 | /// 83 | public LogMode ResponseBodyLogMode { get; set; } = LogMode.LogFailures; 84 | 85 | /// 86 | /// Determines weather to log response as structured object instead of string. This is useful when you use Elastic, Splunk or any other platform to search on object properties. Default is true. Masking only works when this options is enabled. 87 | /// 88 | public bool LogResponseBodyAsStructuredObject { get; set; } = true; 89 | 90 | /// 91 | /// Properties to mask request/response body and headers before logging to output to prevent sensitive data leakage 92 | /// default is "*password*", "*token*", "*secret*", "*bearer*", "*authorization*","*otp" 93 | /// 94 | public IList MaskedProperties { get; } = new List() {"*password*", "*token*", "*secret*", "*bearer*", "*authorization*","*otp"}; 95 | 96 | /// 97 | /// Mask format to replace with masked data 98 | /// 99 | public string MaskFormat { get; set; } = "*** MASKED ***"; 100 | 101 | /// 102 | /// Maximum allowed length of response body text to capture in logs 103 | /// 104 | public int ResponseBodyLogTextLengthLimit { get; set; } = 4000; 105 | 106 | /// 107 | /// Maximum allowed length of request body text to capture in logs 108 | /// 109 | public int RequestBodyLogTextLengthLimit { get; set; } = 4000; 110 | 111 | /// 112 | /// Constructor 113 | /// 114 | public RequestLoggingOptions() 115 | { 116 | GetLevel = DefaultGetLevel; 117 | MessageTemplate = DefaultRequestCompletionMessageTemplate; 118 | GetMessageTemplateProperties = DefaultGetMessageTemplateProperties; 119 | LogFilter = DefaultLogFilter; 120 | } 121 | 122 | private static bool DefaultLogFilter(HttpRequestMessage req, HttpResponseMessage resp, double elapsedMs, Exception ex) 123 | { 124 | return true; 125 | } 126 | 127 | private static LogEventLevel DefaultGetLevel(HttpRequestMessage req, HttpResponseMessage resp, double elapsedMs, Exception ex) 128 | { 129 | var level = LogEventLevel.Information; 130 | if (ex != null || resp == null) 131 | level = LogEventLevel.Error; 132 | else if ((int)resp.StatusCode >= 500) 133 | level = LogEventLevel.Error; 134 | else if ((int)resp.StatusCode >= 400) 135 | level = LogEventLevel.Warning; 136 | 137 | return level; 138 | } 139 | 140 | const string DefaultRequestCompletionMessageTemplate = "HTTP Client Request Completed {@Context}"; 141 | 142 | static IEnumerable DefaultGetMessageTemplateProperties(HttpClientContext httpContextInfo, ILogger logger) 143 | { 144 | logger.BindProperty("Context", httpContextInfo, true, out var prop); 145 | if (prop != null) 146 | return new[] { prop }; 147 | 148 | return Array.Empty(); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Serilog.HttpClient/Serilog.HttpClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0;net6.0;net7.0;netcoreapp3.1;netstandard2.0;netstandard2.1;net8.0 5 | true 6 | Serilog http client logging delegating handler 7 | Alireza Vafi 8 | https://github.com/alirezavafi/serilog-httpclient 9 | https://github.com/alirezavafi/serilog-httpclient 10 | 3.0.0 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | --------------------------------------------------------------------------------