Skip to content

Commit dcd24ac

Browse files
committed
Add IQueueState inspector
1 parent 579a0cf commit dcd24ac

File tree

5 files changed

+97
-71
lines changed

5 files changed

+97
-71
lines changed

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,25 @@ The default memory buffer feeding the worker thread is capped to 10,000 items, a
3939
.WriteTo.Async(a => a.File("logs/myapp.log"), bufferSize: 500)
4040
```
4141

42-
### Monitoring
42+
### Health Monitoring via the Buffer Inspection interface
4343

44-
Typically, one should assign adequate buffer capacity to enable the wrapped sinks to ingest the events as they are processed without ever approaching the limit. In order to gain awareness of the processing backlog becoming abnormal, it's possible to instrument the Async sink by suppling a `monitor` callback that allows for periodic inspection of the backlog
44+
The Async wrapper is primarily intended to allow one to achieve minimal logging latency at all times, even when writing to sinks that may momentarily block during the course of their processing (e.g., a File sink might block for a low number of ms while flushing). The dropping behavior is an important failsafe in that it avoids having an unbounded buffering behaviour should logging frequency overwhelm the sink, or the sink ingestion throughput degrades to a major degree.
45+
46+
In practice, this configuration (assuming one provisions an adequate `bufferSize`) achieves an efficient and resilient logging configuration that can handle load gracefully. The key risk is of course that events may be dropped when the buffer threshold gets breached. The `inspector` allows one to arrange for your Application's health monitoring mechanism to actively validate that the buffer allocation is not being exceeded in practice.
4547

4648
```csharp
47-
void LogBufferMonitor(buffer : BlockingQueue<Serilog.Events.LogEvent> queue)
49+
// Example check: log message to an out of band alarm channel if logging is showing signs of getting overwhelmed
50+
void PeriodicMonitorCheck(IQueueState inspector)
4851
{
49-
var usagePct = queue.Count * 100 / queue.BoundedCapacity;
50-
if (usagePct > 50) SelfLog.WriteLine("Log buffer exceeded {0:p0} usage (limit: {1})", usage, queue.BoundedCapacity);
52+
var usagePct = inspector.Count * 100 / inspector.BoundedCapacity;
53+
if (usagePct > 50) SelfLog.WriteLine("Log buffer exceeded {0:p0} usage (limit: {1})", usagePct, inspector.BoundedCapacity);
5154
}
5255

53-
// Wait for any queued event to be accepted by the `File` log before allowing the calling thread
54-
// to resume its application work after a logging call when there are 10,000 LogEvents waiting
55-
.WriteTo.Async(a => a.File("logs/myapp.log"), monitorIntervalSeconds: 60, monitor: LogBufferMonitor)
56+
// Allow a backlog of up to 10,000 items to be maintained (dropping extras if full)
57+
.WriteTo.Async(a => a.File("logs/myapp.log"), inspector: out IQueueState inspector) ...
58+
59+
// Wire the inspector through to health monitoring and/or metrics in order to periodically emit a metric, raise an alarm, etc.
60+
... healthMonitoring.RegisterCheck(() => new PeriodicMonitorCheck(inspector));
5661
```
5762

5863
### Blocking

src/Serilog.Sinks.Async/LoggerConfigurationAsyncExtensions.cs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,70 @@ public static LoggerConfiguration Async(
3838
/// <paramref name="blockWhenFull"/> the queue will block or subsequent events will be dropped until
3939
/// room is made in the queue.</param>
4040
/// <param name="blockWhenFull">Block when the queue is full, instead of dropping events.</param>
41-
/// <param name="monitorIntervalSeconds">Interval between invocations of <paramref name="monitor"/>.</param>
42-
/// <param name="monitor">Callback to facilitate health checking the internal queue. Frequency is controlled by <paramref name="monitorIntervalSeconds"/>.</param>
4341
/// <returns>A <see cref="LoggerConfiguration"/> allowing configuration to continue.</returns>
4442
public static LoggerConfiguration Async(
4543
this LoggerSinkConfiguration loggerSinkConfiguration,
4644
Action<LoggerSinkConfiguration> configure,
4745
int bufferSize = 10000,
48-
bool blockWhenFull = false,
49-
int monitorIntervalSeconds = 10,
50-
Action<System.Collections.Concurrent.BlockingCollection<Events.LogEvent>> monitor = null)
46+
bool blockWhenFull = false)
5147
{
5248
return LoggerSinkConfiguration.Wrap(
5349
loggerSinkConfiguration,
54-
wrappedSink => new BackgroundWorkerSink(wrappedSink, bufferSize, blockWhenFull, monitorIntervalSeconds, monitor),
50+
wrappedSink => new BackgroundWorkerSink(wrappedSink, bufferSize, blockWhenFull),
5551
configure);
5652
}
5753

54+
/// <summary>
55+
/// Configure a sink to be invoked asynchronously, on a background worker thread.
56+
/// Provides an <paramref name="inspector"/> that can be used to check the live state of the buffer for health monitoring purposes.
57+
/// </summary>
58+
/// <param name="loggerSinkConfiguration">The <see cref="LoggerSinkConfiguration"/> being configured.</param>
59+
/// <param name="configure">An action that configures the wrapped sink.</param>
60+
/// <param name="bufferSize">The size of the concurrent queue used to feed the background worker thread. If
61+
/// the thread is unable to process events quickly enough and the queue is filled, depending on
62+
/// <paramref name="blockWhenFull"/> the queue will block or subsequent events will be dropped until
63+
/// room is made in the queue.</param>
64+
/// <param name="blockWhenFull">Block when the queue is full, instead of dropping events.</param>
65+
/// <param name="inspector">Provides a way to inspect the state of the queue for health monitoring purposes.</param>
66+
/// <returns>A <see cref="LoggerConfiguration"/> allowing configuration to continue.</returns>
67+
public static LoggerConfiguration Async(
68+
this LoggerSinkConfiguration loggerSinkConfiguration,
69+
Action<LoggerSinkConfiguration> configure,
70+
out IQueueState inspector,
71+
int bufferSize = 10000,
72+
bool blockWhenFull = false)
73+
{
74+
// Cannot assign directly to the out param from within the lambda, so we need a temp
75+
IQueueState stateLens = null;
76+
var result = LoggerSinkConfiguration.Wrap(
77+
loggerSinkConfiguration,
78+
wrappedSink =>
79+
{
80+
var sink = new BackgroundWorkerSink(wrappedSink, bufferSize, blockWhenFull);
81+
stateLens = sink;
82+
return sink;
83+
},
84+
configure);
85+
inspector = stateLens;
86+
return result;
87+
}
88+
}
89+
90+
/// <summary>
91+
/// Provides a way to inspect the current state of Async wrapper's ingestion queue.
92+
/// </summary>
93+
public interface IQueueState
94+
{
95+
/// <summary>
96+
/// Count of items currently awaiting ingestion.
97+
/// </summary>
98+
/// <exception cref="T:System.ObjectDisposedException">The Sink has been disposed.</exception>
99+
int Count { get; }
100+
101+
/// <summary>
102+
/// Maximum number of items permitted to be held in the buffer awaiting ingestion.
103+
/// </summary>
104+
/// <exception cref="T:System.ObjectDisposedException">The Sink has been disposed.</exception>
105+
int BufferSize { get; }
58106
}
59107
}

src/Serilog.Sinks.Async/Serilog.Sinks.Async.csproj

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<AssemblyVersion>1.0.0</AssemblyVersion>
66
<VersionPrefix>1.2.0</VersionPrefix>
77
<Authors>Jezz Santos;Serilog Contributors</Authors>
8-
<TargetFrameworks>net45;netstandard1.1;netstandard1.2</TargetFrameworks>
8+
<TargetFrameworks>net45;netstandard1.1</TargetFrameworks>
99
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1010
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1111
<AssemblyName>Serilog.Sinks.Async</AssemblyName>
@@ -26,10 +26,6 @@
2626
<PackageReference Include="Serilog" Version="2.5.0" />
2727
</ItemGroup>
2828

29-
<PropertyGroup Condition=" '$(TargetFramework)' == 'netstandard1.1' ">
30-
<DefineConstants>$(DefineConstants);NETSTANDARD_NO_TIMER</DefineConstants>
31-
</PropertyGroup>
32-
3329
<ItemGroup Condition=" '$(TargetFramework)' == 'net45' ">
3430
<Reference Include="System" />
3531
<Reference Include="Microsoft.CSharp" />
@@ -39,9 +35,4 @@
3935
<PackageReference Include="System.Collections.Concurrent" Version="4.0.12" />
4036
</ItemGroup>
4137

42-
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard1.2' ">
43-
<PackageReference Include="System.Collections.Concurrent" Version="4.0.12" />
44-
<PackageReference Include="System.Threading.Timer" Version="4.0.1" />
45-
</ItemGroup>
46-
4738
</Project>

src/Serilog.Sinks.Async/Sinks/Async/BackgroundWorkerSink.cs

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,20 @@
88

99
namespace Serilog.Sinks.Async
1010
{
11-
sealed class BackgroundWorkerSink : ILogEventSink, IDisposable
11+
sealed class BackgroundWorkerSink : ILogEventSink, IQueueState, IDisposable
1212
{
1313
readonly ILogEventSink _pipeline;
1414
readonly bool _blockWhenFull;
1515
readonly BlockingCollection<LogEvent> _queue;
1616
readonly Task _worker;
17-
#if! NETSTANDARD_NO_TIMER
18-
readonly Timer _monitorCallbackInvocationTimer;
19-
#endif
20-
public BackgroundWorkerSink(
21-
ILogEventSink pipeline, int bufferCapacity,
22-
bool blockWhenFull,
23-
int monitorIntervalSeconds = 0, Action<BlockingCollection<LogEvent>> monitor = null)
17+
18+
public BackgroundWorkerSink(ILogEventSink pipeline, int bufferCapacity, bool blockWhenFull)
2419
{
25-
if (pipeline == null) throw new ArgumentNullException(nameof(pipeline));
2620
if (bufferCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(bufferCapacity));
27-
if (monitorIntervalSeconds < 0) throw new ArgumentOutOfRangeException(nameof(monitorIntervalSeconds));
28-
_pipeline = pipeline;
21+
_pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
2922
_blockWhenFull = blockWhenFull;
3023
_queue = new BlockingCollection<LogEvent>(bufferCapacity);
3124
_worker = Task.Factory.StartNew(Pump, CancellationToken.None, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
32-
33-
if (monitor != null)
34-
{
35-
if (monitorIntervalSeconds < 1) throw new ArgumentOutOfRangeException(nameof(monitorIntervalSeconds), "must be >=1");
36-
#if! NETSTANDARD_NO_TIMER
37-
var interval = TimeSpan.FromSeconds(monitorIntervalSeconds);
38-
_monitorCallbackInvocationTimer = new Timer(queue => monitor((BlockingCollection<LogEvent>)queue), _queue, interval, interval);
39-
#else
40-
throw new PlatformNotSupportedException($"Please use a platform supporting .netstandard1.2 or later to avail of the ${nameof(monitor)} facility.");
41-
#endif
42-
}
4325
}
4426

4527
public void Emit(LogEvent logEvent)
@@ -74,11 +56,6 @@ public void Dispose()
7456
// Allow queued events to be flushed
7557
_worker.Wait();
7658

77-
#if! NETSTANDARD_NO_TIMER
78-
// Only stop monitoring when we've actually completed flushing
79-
_monitorCallbackInvocationTimer?.Dispose();
80-
#endif
81-
8259
(_pipeline as IDisposable)?.Dispose();
8360
}
8461

@@ -96,5 +73,9 @@ void Pump()
9673
SelfLog.WriteLine("{0} fatal error in worker thread: {1}", typeof(BackgroundWorkerSink), ex);
9774
}
9875
}
76+
77+
int IQueueState.Count => _queue.Count;
78+
79+
int IQueueState.BufferSize => _queue.BoundedCapacity;
9980
}
10081
}

test/Serilog.Sinks.Async.Tests/BackgroundWorkerSinkSpec.cs

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System;
2-
using System.Collections.Concurrent;
32
using System.Collections.Generic;
43
using System.Diagnostics;
54
using System.Linq;
@@ -85,12 +84,12 @@ public async Task WhenEmitMultipleTimes_ThenRelaysToInnerSink()
8584
}
8685

8786
[Fact]
88-
public async Task GivenDefaultConfig_WhenQueueOverCapacity_DoesNotBlock()
87+
public async Task GivenDefaultConfig_WhenRequestsExceedCapacity_DoesNotBlock()
8988
{
9089
var batchTiming = Stopwatch.StartNew();
9190
using (var sink = new BackgroundWorkerSink(_logger, 1, blockWhenFull: false /*default*/))
9291
{
93-
// Cause a delay when emmitting to the inner sink, allowing us to easily fill the queue to capacity
92+
// Cause a delay when emitting to the inner sink, allowing us to easily fill the queue to capacity
9493
// while the first event is being propagated
9594
var acceptInterval = TimeSpan.FromMilliseconds(500);
9695
_innerSink.DelayEmit = acceptInterval;
@@ -115,7 +114,7 @@ public async Task GivenDefaultConfig_WhenQueueOverCapacity_DoesNotBlock()
115114
}
116115

117116
[Fact]
118-
public async Task GivenDefaultConfig_WhenRequestsOverCapacity_ThenDropsEventsAndRecovers()
117+
public async Task GivenDefaultConfig_WhenRequestsExceedCapacity_ThenDropsEventsAndRecovers()
119118
{
120119
using (var sink = new BackgroundWorkerSink(_logger, 1, blockWhenFull: false /*default*/))
121120
{
@@ -186,29 +185,31 @@ public async Task GivenConfiguredToBlock_WhenQueueFilled_ThenBlocks()
186185
}
187186
}
188187

189-
#if !NETSTANDARD_NO_TIMER
190188
[Fact]
191-
public void MonitorArgumentAffordsBacklogHealthMonitoringFacility()
189+
public async Task InspectorOutParameterAffordsHealthMonitoringHook()
192190
{
193-
bool logWasObservedToHaveReachedHalfFull = false;
194-
void inspectBuffer(BlockingCollection<LogEvent> queue) =>
195-
196-
logWasObservedToHaveReachedHalfFull = logWasObservedToHaveReachedHalfFull
197-
|| queue.Count * 100 / queue.BoundedCapacity >= 50;
198-
199-
var collector = new MemorySink { DelayEmit = TimeSpan.FromSeconds(3) };
191+
var collector = new MemorySink { DelayEmit = TimeSpan.FromSeconds(2) };
192+
// 2 spaces in queue; 1 would make the second log entry eligible for dropping if consumer does not activate instantaneously
193+
var bufferSize = 2;
200194
using (var logger = new LoggerConfiguration()
201-
.WriteTo.Async(w => w.Sink(collector), bufferSize: 2, monitorIntervalSeconds: 1, monitor: inspectBuffer)
195+
.WriteTo.Async(w => w.Sink(collector), bufferSize: 2, inspector: out IQueueState inspector)
202196
.CreateLogger())
203197
{
204-
logger.Information("Something to block the pipe");
205-
logger.Information("I'll just leave this here pending for a few seconds so I can observe it");
206-
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(2));
198+
Assert.Equal(bufferSize, inspector.BufferSize);
199+
Assert.Equal(0, inspector.Count);
200+
logger.Information("Something to freeze the processing for 2s");
201+
await Task.Delay(TimeSpan.FromMilliseconds(200));
202+
// Can be taken from queue either instantanously or be awaiting consumer to take
203+
Assert.InRange(inspector.Count, 0, 1);
204+
logger.Information("Something that will sit in the queue");
205+
// Unless we are put to sleep for a Rip Van Winkle period, either:
206+
// a) the BackgroundWorker will be emitting the item [and incurring the 2s delay we established], leaving a single item in the buffer
207+
// or b) neither will have been picked out of the buffer yet.
208+
await Task.Delay(TimeSpan.FromMilliseconds(200));
209+
Assert.InRange(inspector.Count, 1, 2);
210+
Assert.Equal(bufferSize, inspector.BufferSize);
207211
}
208-
209-
Assert.True(logWasObservedToHaveReachedHalfFull);
210212
}
211-
#endif
212213

213214
private BackgroundWorkerSink CreateSinkWithDefaultOptions()
214215
{

0 commit comments

Comments
 (0)