diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..acd3bc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# If this file is renamed, the incrementing run attempt number will be reset. + +name: CI + +on: + push: + branches: [ "dev", "main" ] + pull_request: + branches: [ "dev", "main" ] + +env: + CI_BUILD_NUMBER_BASE: ${{ github.run_number }} + CI_TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + +jobs: + build: + + # The build must run on Windows so that .NET Framework targets can be built and tested. + runs-on: windows-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Setup + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Compute build number + shell: bash + run: | + echo "CI_BUILD_NUMBER=$(($CI_BUILD_NUMBER_BASE+2300))" >> $GITHUB_ENV + - name: Build and Publish + env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + ./Build.ps1 diff --git a/Build.ps1 b/Build.ps1 index 06a36af..e798284 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,44 +1,79 @@ +Write-Output "build: Tool versions follow" + +dotnet --version +dotnet --list-sdks + Write-Output "build: Build started" Push-Location $PSScriptRoot +try { + if(Test-Path .\artifacts) { + Write-Output "build: Cleaning ./artifacts" + Remove-Item ./artifacts -Force -Recurse + } -if(Test-Path .\artifacts) { - Write-Output "build: Cleaning ./artifacts" - Remove-Item ./artifacts -Force -Recurse -} + & dotnet restore --no-cache -& dotnet restore --no-cache + $dbp = [Xml] (Get-Content .\Directory.Version.props) + $versionPrefix = $dbp.Project.PropertyGroup.VersionPrefix -$branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:APPVEYOR_REPO_BRANCH]; -$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:APPVEYOR_BUILD_NUMBER]; -$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "main" -and $revision -ne "local"] + Write-Output "build: Package version prefix is $versionPrefix" -Write-Output "build: Package version suffix is $suffix" + $branch = @{ $true = $env:CI_TARGET_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:CI_TARGET_BRANCH]; + $revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:CI_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:CI_BUILD_NUMBER]; + $suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)) -replace '([^a-zA-Z0-9\-]*)', '')-$revision"}[$branch -eq "main" -and $revision -ne "local"] + $commitHash = $(git rev-parse --short HEAD) + $buildSuffix = @{ $true = "$($suffix)-$($commitHash)"; $false = "$($branch)-$($commitHash)" }[$suffix -ne ""] -foreach ($src in Get-ChildItem src/*) { - Push-Location $src + Write-Output "build: Package version suffix is $suffix" + Write-Output "build: Build version suffix is $buildSuffix" - Write-Output "build: Packaging project in $src" + & dotnet build -c Release --version-suffix=$buildSuffix /p:ContinuousIntegrationBuild=true + if($LASTEXITCODE -ne 0) { throw "Build failed" } - if ($suffix) { - & dotnet pack -c Release --include-source -o ../../artifacts --version-suffix=$suffix - } else { - & dotnet pack -c Release --include-source -o ../../artifacts + foreach ($src in Get-ChildItem src/*) { + Push-Location $src + + Write-Output "build: Packaging project in $src" + + if ($suffix) { + & dotnet pack -c Release --no-build --no-restore -o ../../artifacts --version-suffix=$suffix + } else { + & dotnet pack -c Release --no-build --no-restore -o ../../artifacts + } + if($LASTEXITCODE -ne 0) { throw "Packaging failed" } + + Pop-Location } - if($LASTEXITCODE -ne 0) { throw "Packaging failed" } - Pop-Location -} + foreach ($test in Get-ChildItem test/*.Tests) { + Push-Location $test + + Write-Output "build: Testing project in $test" + + & dotnet test -c Release --no-build --no-restore + if($LASTEXITCODE -ne 0) { throw "Testing failed" } + + Pop-Location + } + + if ($env:NUGET_API_KEY) { + # GitHub Actions will only supply this to branch builds and not PRs. We publish + # builds from any branch this action targets (i.e. main and dev). -foreach ($test in Get-ChildItem test/*.Tests) { - Push-Location $test + Write-Output "build: Publishing NuGet packages" - Write-Output "build: Testing project in $test" + foreach ($nupkg in Get-ChildItem artifacts/*.nupkg) { + & dotnet nuget push -k $env:NUGET_API_KEY -s https://api.nuget.org/v3/index.json "$nupkg" + if($LASTEXITCODE -ne 0) { throw "Publishing failed" } + } - & dotnet test -c Release - if($LASTEXITCODE -ne 0) { throw "Testing failed" } + if (!($suffix)) { + Write-Output "build: Creating release for version $versionPrefix" + iex "gh release create v$versionPrefix --title v$versionPrefix --generate-notes $(get-item ./artifacts/*.nupkg) $(get-item ./artifacts/*.snupkg)" + } + } +} finally { Pop-Location } - -Pop-Location diff --git a/Directory.Build.props b/Directory.Build.props index 0248539..c114992 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,21 @@ + + latest True - true - true + + true $(MSBuildThisFileDirectory)assets/Serilog.snk + false enable enable - latest + true + true + true + true + snupkg diff --git a/Directory.Version.props b/Directory.Version.props new file mode 100644 index 0000000..a7a8629 --- /dev/null +++ b/Directory.Version.props @@ -0,0 +1,5 @@ + + + 7.0.0 + + diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 42f5a75..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: '{build}' -skip_tags: true -image: - - Visual Studio 2022 - - Ubuntu2204 -build_script: -- pwsh: ./Build.ps1 -for: -- - matrix: - only: - - image: Ubuntu - build_script: - - sh build.sh -test: off -artifacts: -- path: artifacts/Serilog.*.nupkg -- path: artifacts/Serilog.*.snupkg -deploy: -- provider: NuGet - api_key: - secure: sDnchSg4TZIOK7oIUI6BJwFPNENTOZrGNsroGO1hehLJSvlHpFmpTwiX8+bgPD+Q - on: - branch: /^(main|dev)$/ -- provider: GitHub - auth_token: - secure: p4LpVhBKxGS5WqucHxFQ5c7C8cP74kbNB0Z8k9Oxx/PMaDQ1+ibmoexNqVU5ZlmX - artifact: /Serilog.*(\.|\.s)nupkg/ - tag: v$(appveyor_build_version) - on: - branch: main diff --git a/build.sh b/build.sh deleted file mode 100755 index cf241dc..0000000 --- a/build.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -set -e -dotnet --info -dotnet --list-sdks -dotnet restore - -echo "🤖 Attempting to build..." -for path in src/**/*.csproj; do - dotnet build -f netstandard1.3 -c Release ${path} - dotnet build -f netstandard2.0 -c Release ${path} -done - -echo "🤖 Running tests..." -for path in test/*.Tests/*.csproj; do - dotnet test -f netcoreapp2.0 -c Release ${path} -done diff --git a/global.json b/global.json new file mode 100644 index 0000000..ed7ea04 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.200", + "allowPrerelease": false, + "rollForward": "latestFeature" + } +} diff --git a/serilog-sinks-file.sln b/serilog-sinks-file.sln index b996c93..909a693 100644 --- a/serilog-sinks-file.sln +++ b/serilog-sinks-file.sln @@ -5,16 +5,16 @@ VisualStudioVersion = 17.5.33424.131 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{037440DE-440B-4129-9F7A-09B42D00397E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{E9D1B5E1-DEB9-4A04-8BAB-24EC7240ADAF}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sln", "sln", "{E9D1B5E1-DEB9-4A04-8BAB-24EC7240ADAF}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - appveyor.yml = appveyor.yml - Build.ps1 = Build.ps1 - build.sh = build.sh Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets README.md = README.md assets\Serilog.snk = assets\Serilog.snk + global.json = global.json + Build.ps1 = Build.ps1 + Directory.Version.props = Directory.Version.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7B927378-9F16-4F6F-B3F6-156395136646}" @@ -27,6 +27,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Serilog.Sinks.File.Tests", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample", "example\Sample\Sample.csproj", "{A34235A2-A717-4A1C-BF5C-F4A9E06E1260}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{3827A9BD-6D28-4A12-B1C0-32A9BD246EA6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{E5028523-6E46-4A86-AAB9-BF4B1FA5D41D}" + ProjectSection(SolutionItems) = preProject + .github\workflows\ci.yml = .github\workflows\ci.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +60,7 @@ Global {57E0ED0E-0F45-48AB-A73D-6A92B7C32095} = {037440DE-440B-4129-9F7A-09B42D00397E} {3C2D8E01-5580-426A-BDD9-EC59CD98E618} = {7B927378-9F16-4F6F-B3F6-156395136646} {A34235A2-A717-4A1C-BF5C-F4A9E06E1260} = {196B1544-C617-4D7C-96D1-628713BDD52A} + {E5028523-6E46-4A86-AAB9-BF4B1FA5D41D} = {3827A9BD-6D28-4A12-B1C0-32A9BD246EA6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EA0197B4-FCA8-4DF2-BF34-274FA41333D1} diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index 5440792..e3e8bcf 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -549,17 +549,26 @@ static LoggerConfiguration ConfigureFile( } catch (Exception ex) { - SelfLog.WriteLine("Unable to open file sink for {0}: {1}", path, ex); + // No logging failure listener can be configured here; in future we might allow for a static + // default listener, but in the meantime this improves `SelfLog` usefulness and consistency. + SelfLog.FailureListener.OnLoggingFailed( + typeof(FileLoggerConfigurationExtensions), + LoggingFailureKind.Final, + $"unable to open file sink for {path}", + events: null, + ex); if (propagateExceptions) throw; - return addSink(new NullSink(), LevelAlias.Maximum, null); + return addSink(new FailedSink(), restrictedToMinimumLevel, levelSwitch); } if (flushToDiskInterval.HasValue) { #pragma warning disable 618 + // `LoggerSinkConfiguration.Wrap()` is not used here because the target sink is expected + // to support `ILogEventSink`. sink = new PeriodicFlushToDiskSink(sink, flushToDiskInterval.Value); #pragma warning restore 618 } diff --git a/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj b/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj index 098835a..2b744c1 100644 --- a/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj +++ b/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj @@ -2,31 +2,22 @@ Write Serilog events to text files in plain or JSON format. - 6.0.1 Serilog Contributors net471;net462 - $(TargetFrameworks);net8.0;net6.0;netstandard2.0 - true + $(TargetFrameworks);net9.0;net8.0;net6.0;netstandard2.0 serilog;file - serilog-sink-nuget.png - https://serilog.net/images/serilog-sink-nuget.png https://github.com/serilog/serilog-sinks-file Apache-2.0 - https://github.com/serilog/serilog-sinks-file - git - Serilog - true - True - snupkg + serilog-sink-nuget.png README.md - + @@ -46,6 +37,10 @@ $(DefineConstants);ENUMERABLE_MAXBY + + $(DefineConstants);ENUMERABLE_MAXBY + + diff --git a/src/Serilog.Sinks.File/Sinks/File/FailedSink.cs b/src/Serilog.Sinks.File/Sinks/File/FailedSink.cs new file mode 100644 index 0000000..36e673b --- /dev/null +++ b/src/Serilog.Sinks.File/Sinks/File/FailedSink.cs @@ -0,0 +1,34 @@ +// Copyright © 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 Serilog.Core; +using Serilog.Debugging; +using Serilog.Events; + +namespace Serilog.Sinks.File; + +sealed class FailedSink : ILogEventSink, ISetLoggingFailureListener +{ + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + + public void Emit(LogEvent logEvent) + { + _failureListener.OnLoggingFailed(this, LoggingFailureKind.Final, "the sink could not be initialized", [logEvent], exception: null); + } + + public void SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } +} diff --git a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs index 0763fc3..32c0cd3 100644 --- a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs @@ -13,6 +13,8 @@ // limitations under the License. using System.Text; +using Serilog.Core; +using Serilog.Debugging; using Serilog.Events; using Serilog.Formatting; @@ -21,7 +23,7 @@ namespace Serilog.Sinks.File; /// /// Write log events to a disk file. /// -public sealed class FileSink : IFileSink, IDisposable +public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListener { readonly TextWriter _output; readonly FileStream _underlyingStream; @@ -31,6 +33,8 @@ public sealed class FileSink : IFileSink, IDisposable readonly object _syncRoot = new(); readonly WriteCountingStream? _countingStreamWrapper; + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + /// Construct a . /// Path to the file. /// Formatter used to convert log events to text. @@ -98,7 +102,7 @@ internal FileSink( } catch { - outputStream?.Dispose(); + outputStream.Dispose(); throw; } } @@ -132,7 +136,16 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) /// When is null public void Emit(LogEvent logEvent) { - ((IFileSink) this).EmitOrOverflow(logEvent); + if (!((IFileSink)this).EmitOrOverflow(logEvent)) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the log file size limit has been reached and no rolling behavior was specified", + [logEvent], + exception: null); + } } /// @@ -153,4 +166,9 @@ public void FlushToDisk() _underlyingStream.Flush(true); } } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } diff --git a/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs b/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs index c4272d9..82266f9 100644 --- a/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs @@ -22,12 +22,14 @@ namespace Serilog.Sinks.File; /// A sink wrapper that periodically flushes the wrapped sink to disk. /// [Obsolete("This type will be removed from the public API in a future version; use `WriteTo.File(flushToDiskInterval:)` instead.")] -public class PeriodicFlushToDiskSink : ILogEventSink, IDisposable +public sealed class PeriodicFlushToDiskSink : ILogEventSink, IDisposable, ISetLoggingFailureListener { readonly ILogEventSink _sink; readonly Timer _timer; int _flushRequired; + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + /// /// Construct a that wraps /// and flushes it at the specified . @@ -46,7 +48,17 @@ public PeriodicFlushToDiskSink(ILogEventSink sink, TimeSpan flushInterval) else { _timer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - SelfLog.WriteLine("{0} configured to flush {1}, but {2} not implemented", typeof(PeriodicFlushToDiskSink), sink, nameof(IFlushableFileSink)); + + // May be an opportunity to improve the failure listener API for these cases - the failure + // is important, but not exactly `Final`. + SelfLog.FailureListener.OnLoggingFailed( + // Class must be sealed in order for this to be safe - `this` may be partially constructed + // otherwise. + this, + LoggingFailureKind.Final, + $"configured to flush {sink}, but {nameof(IFlushableFileSink)} not implemented", + events: null, + exception: null); } } @@ -77,7 +89,21 @@ void FlushToDisk(IFlushableFileSink flushable) } catch (Exception ex) { - SelfLog.WriteLine("{0} could not flush the underlying sink to disk: {1}", typeof(PeriodicFlushToDiskSink), ex); + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Temporary, + "could not flush the underlying file to disk", + events: null, + ex); + } + } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + if (_sink is ISetLoggingFailureListener setLoggingFailureListener) + { + setLoggingFailureListener.SetFailureListener(failureListener); } } } diff --git a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs index dc8d33c..93c02c5 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -20,7 +20,7 @@ namespace Serilog.Sinks.File; -sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable +sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable, ISetLoggingFailureListener { readonly PathRoller _roller; readonly ITextFormatter _textFormatter; @@ -33,6 +33,8 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable readonly bool _rollOnFileSizeLimit; readonly FileLifecycleHooks? _hooks; + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + readonly object _syncRoot = new(); bool _isDisposed; DateTime? _nextCheckpoint; @@ -72,6 +74,7 @@ public void Emit(LogEvent logEvent) { if (logEvent == null) throw new ArgumentNullException(nameof(logEvent)); + bool failed; lock (_syncRoot) { if (_isDisposed) throw new ObjectDisposedException("The log file has been disposed."); @@ -84,12 +87,18 @@ public void Emit(LogEvent logEvent) AlignCurrentFileTo(now, nextSequence: true); } - /* TODO: We REALLY should add this to avoid stuff become missing undetected. - if (_currentFile == null) - { - SelfLog.WriteLine("Log event {0} was lost since it was not possible to open the file or create a new one.", logEvent.RenderMessage()); - } - */ + failed = _currentFile == null; + } + + if (failed) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the target file could not be opened or created", + [logEvent], + exception: null); } } @@ -170,14 +179,22 @@ void OpenFile(DateTime now, int? minSequence = null) new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks); _currentFileSequence = sequence; + + if (_currentFile is ISetLoggingFailureListener setLoggingFailureListener) + { + setLoggingFailureListener.SetFailureListener(_failureListener); + } } catch (IOException ex) { if (IOErrors.IsLockedFile(ex)) { - SelfLog.WriteLine( - "File target {0} was locked, attempting to open next in sequence (attempt {1})", path, - attempt + 1); + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Temporary, + $"file target {path} was locked, attempting to open next in sequence (attempt {attempt + 1})", + events: null, + exception: null); sequence = (sequence ?? 0) + 1; continue; } @@ -216,7 +233,7 @@ void ApplyRetentionPolicy(string currentFilePath, DateTime now) // ReSharper disable once ConvertClosureToMethodGroup var potentialMatches = Directory.GetFiles(_roller.LogFileDirectory, _roller.DirectorySearchPattern) .Select(f => Path.GetFileName(f)) - .Union(new[] { currentFileName }); + .Union([currentFileName]); var newestFirst = _roller .SelectMatches(potentialMatches) @@ -239,7 +256,12 @@ void ApplyRetentionPolicy(string currentFilePath, DateTime now) } catch (Exception ex) { - SelfLog.WriteLine("Error {0} while processing obsolete log file {1}", ex, fullPath); + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Temporary, + $"error while processing obsolete log file {fullPath}", + events: null, + ex); } } } @@ -286,4 +308,9 @@ public void FlushToDisk() _currentFile?.FlushToDisk(); } } + + public void SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } diff --git a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs index c753e46..485c1e4 100644 --- a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs +++ b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs @@ -16,6 +16,8 @@ using System.Security.AccessControl; using System.Text; +using Serilog.Core; +using Serilog.Debugging; using Serilog.Events; using Serilog.Formatting; @@ -25,7 +27,7 @@ namespace Serilog.Sinks.File; /// Write log events to a disk file. /// [Obsolete("This type will be removed from the public API in a future version; use `WriteTo.File(shared: true)` instead.")] -public sealed class SharedFileSink : IFileSink, IDisposable +public sealed class SharedFileSink : IFileSink, IDisposable, ISetLoggingFailureListener { readonly MemoryStream _writeBuffer; readonly string _path; @@ -34,6 +36,8 @@ public sealed class SharedFileSink : IFileSink, IDisposable readonly long? _fileSizeLimitBytes; readonly object _syncRoot = new(); + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + // The stream is reopened with a larger buffer if atomic writes beyond the current buffer size are needed. FileStream _fileOutput; int _fileStreamBufferLength = DefaultFileStreamBufferLength; @@ -59,7 +63,7 @@ public sealed class SharedFileSink : IFileSink, IDisposable public SharedFileSink(string path, ITextFormatter textFormatter, long? fileSizeLimitBytes, Encoding? encoding = null) { if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 1) - throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null"); + throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null."); _path = path ?? throw new ArgumentNullException(nameof(path)); _textFormatter = textFormatter ?? throw new ArgumentNullException(nameof(textFormatter)); @@ -149,7 +153,16 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) /// When is null public void Emit(LogEvent logEvent) { - ((IFileSink)this).EmitOrOverflow(logEvent); + if (!((IFileSink)this).EmitOrOverflow(logEvent)) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the log file size limit has been reached and no rolling behavior was specified", + [logEvent], + exception: null); + } } /// @@ -170,6 +183,11 @@ public void FlushToDisk() _fileOutput.Flush(true); } } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } #endif diff --git a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs index 6f5dc5c..3d6a0a1 100644 --- a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs +++ b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs @@ -15,6 +15,7 @@ #if OS_MUTEX using System.Text; +using Serilog.Core; using Serilog.Events; using Serilog.Formatting; using Serilog.Debugging; @@ -25,7 +26,7 @@ namespace Serilog.Sinks.File; /// Write log events to a disk file. /// [Obsolete("This type will be removed from the public API in a future version; use `WriteTo.File(shared: true)` instead.")] -public sealed class SharedFileSink : IFileSink, IDisposable +public sealed class SharedFileSink : IFileSink, IDisposable, ISetLoggingFailureListener { readonly TextWriter _output; readonly FileStream _underlyingStream; @@ -33,6 +34,8 @@ public sealed class SharedFileSink : IFileSink, IDisposable readonly long? _fileSizeLimitBytes; readonly object _syncRoot = new(); + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + const string MutexNameSuffix = ".serilog"; const int MutexWaitTimeout = 10000; readonly Mutex _mutex; @@ -81,7 +84,11 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) lock (_syncRoot) { if (!TryAcquireMutex()) - return true; // We didn't overflow, but, roll-on-size should not be attempted + { + // Support fallback chains. + throw new LoggingFailedException( + $"The shared file mutex could not be acquired within {MutexWaitTimeout} ms."); + } try { @@ -111,7 +118,16 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) /// When is null public void Emit(LogEvent logEvent) { - ((IFileSink)this).EmitOrOverflow(logEvent); + if (!((IFileSink)this).EmitOrOverflow(logEvent)) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the log file size limit has been reached and no rolling behavior was specified", + [logEvent], + exception: null); + } } /// @@ -149,13 +165,12 @@ bool TryAcquireMutex() { if (!_mutex.WaitOne(MutexWaitTimeout)) { - SelfLog.WriteLine("Shared file mutex could not be acquired within {0} ms", MutexWaitTimeout); return false; } } catch (AbandonedMutexException) { - SelfLog.WriteLine("Inherited shared file mutex after abandonment by another process"); + SelfLog.WriteLine("inherited the shared file mutex after abandonment by another process"); } return true; @@ -165,6 +180,11 @@ void ReleaseMutex() { _mutex.ReleaseMutex(); } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } #endif diff --git a/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs b/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs index b3ce3e2..374ef94 100644 --- a/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs @@ -17,6 +17,20 @@ public void WhenWritingCreationExceptionsAreSuppressed() .CreateLogger(); } + [Fact] + public void WhenWritingCreationExceptionsAreReported() + { + var listener = new CapturingLoggingFailureListener(); + + var logger = new LoggerConfiguration() + .WriteTo.Fallible(wt => wt.File(InvalidPath), listener) + .CreateLogger(); + + logger.Information("Hello"); + + Assert.Single(listener.FailedEvents); + } + [Fact] public void WhenAuditingCreationExceptionsPropagate() { diff --git a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs index 0c76349..b42a562 100644 --- a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text; +using Serilog.Core; using Xunit; using Serilog.Formatting.Json; using Serilog.Sinks.File.Tests.Support; @@ -59,8 +60,10 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var path = tmp.AllocateFilename("txt"); var evt = Some.LogEvent(new string('n', maxBytes / eventsToLimit)); + var listener = new CapturingLoggingFailureListener(); using (var sink = new FileSink(path, new JsonFormatter(), maxBytes)) { + ((ISetLoggingFailureListener)sink).SetFailureListener(listener); for (var i = 0; i < eventsToLimit * 2; i++) { sink.Emit(evt); @@ -70,6 +73,7 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var size = new FileInfo(path).Length; Assert.True(size > maxBytes); Assert.True(size < maxBytes * 2); + Assert.NotEmpty(listener.FailedEvents); } [Fact] diff --git a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs index 830af11..191e614 100644 --- a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs @@ -11,7 +11,7 @@ namespace Serilog.Sinks.File.Tests; public class RollingFileSinkTests : IDisposable { - private readonly ITestOutputHelper _testOutputHelper; + readonly ITestOutputHelper _testOutputHelper; public RollingFileSinkTests(ITestOutputHelper testOutputHelper) { @@ -172,7 +172,7 @@ public void WhenFirstOpeningFailedWithLockRetryDelayedUntilNextCheckpoint() e3 = Some.InformationEvent(e1.Timestamp.AddMinutes(5)), e4 = Some.InformationEvent(e1.Timestamp.AddMinutes(31)); LogEvent[] logEvents = new[] { e1, e2, e3, e4 }; - + foreach (var logEvent in logEvents) { Clock.SetTestDateTimeNow(logEvent.Timestamp.DateTime); @@ -210,7 +210,7 @@ public void WhenFirstOpeningFailedWithLockRetryDelayed30Minutes() e3 = Some.InformationEvent(e1.Timestamp.AddMinutes(5)), e4 = Some.InformationEvent(e1.Timestamp.AddMinutes(31)); LogEvent[] logEvents = new[] { e1, e2, e3, e4 }; - + SelfLog.Enable(_testOutputHelper.WriteLine); foreach (var logEvent in logEvents) { @@ -247,7 +247,7 @@ public void WhenFirstOpeningFailedWithoutLockRetryDelayed30Minutes() e3 = Some.InformationEvent(e1.Timestamp.AddMinutes(5)), e4 = Some.InformationEvent(e1.Timestamp.AddMinutes(31)); LogEvent[] logEvents = new[] { e1, e2, e3, e4 }; - + SelfLog.Enable(_testOutputHelper.WriteLine); foreach (var logEvent in logEvents) { diff --git a/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj b/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj index 9ad3db7..1d366c6 100644 --- a/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj +++ b/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj @@ -1,8 +1,9 @@ - net48;net8.0 + net48;net8.0;net9.0 true + false diff --git a/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs b/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs index dff0a0d..b784d2f 100644 --- a/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs @@ -1,4 +1,5 @@ -using Xunit; +using Serilog.Core; +using Xunit; using Serilog.Formatting.Json; using Serilog.Sinks.File.Tests.Support; @@ -56,8 +57,10 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var path = tmp.AllocateFilename("txt"); var evt = Some.LogEvent(new string('n', maxBytes / eventsToLimit)); + var listener = new CapturingLoggingFailureListener(); using (var sink = new SharedFileSink(path, new JsonFormatter(), maxBytes)) { + ((ISetLoggingFailureListener)sink).SetFailureListener(listener); for (var i = 0; i < eventsToLimit * 2; i++) { sink.Emit(evt); @@ -67,6 +70,7 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var size = new FileInfo(path).Length; Assert.True(size > maxBytes); Assert.True(size < maxBytes * 2); + Assert.NotEmpty(listener.FailedEvents); } [Fact] diff --git a/test/Serilog.Sinks.File.Tests/Support/CapturingLoggingFailureListener.cs b/test/Serilog.Sinks.File.Tests/Support/CapturingLoggingFailureListener.cs new file mode 100644 index 0000000..7bb4622 --- /dev/null +++ b/test/Serilog.Sinks.File.Tests/Support/CapturingLoggingFailureListener.cs @@ -0,0 +1,17 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Serilog.Sinks.File.Tests.Support; + +class CapturingLoggingFailureListener: ILoggingFailureListener +{ + public List FailedEvents { get; } = []; + + public void OnLoggingFailed(object sender, LoggingFailureKind kind, string message, IReadOnlyCollection? events, Exception? exception) + { + if (kind != LoggingFailureKind.Temporary && events != null) + { + FailedEvents.AddRange(events); + } + } +}