File: Buffering\GlobalBufferTests.cs
Web Access
Project: src\test\Libraries\Microsoft.Extensions.Telemetry.Tests\Microsoft.Extensions.Telemetry.Tests.csproj (Microsoft.Extensions.Telemetry.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#if NET9_0_OR_GREATER
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
 
namespace Microsoft.Extensions.Diagnostics.Buffering.Test;
 
public class GlobalBufferTests
{
    [Fact]
    public void Flush_EmitsStateOfThreadThatCreatedRecord()
    {
        using var provider = new CapturingProvider();
        using ServiceProvider services = CreateServices(provider);
        ILogger logger = services.GetRequiredService<ILogger<GlobalBufferTests>>();
 
        ActivityTraceId expectedTraceId = default;
        ActivitySpanId expectedSpanId = default;
        int loggingThreadId = RunOnDedicatedThread(() =>
        {
            using Activity activity = new Activity("logging");
            activity.SetIdFormat(ActivityIdFormat.W3C).Start();
            expectedTraceId = activity.TraceId;
            expectedSpanId = activity.SpanId;
 
            logger.LogWarning("Hello {Name}", "World");
        });
 
        using (Activity flushingActivity = new Activity("flushing"))
        {
            flushingActivity.SetIdFormat(ActivityIdFormat.W3C).Start();
            services.GetRequiredService<GlobalLogBuffer>().Flush();
        }
 
        CapturedRecord record = Assert.Single(provider.Records);
        Assert.Equal(expectedTraceId, record.ActivityTraceId);
        Assert.Equal(expectedSpanId, record.ActivitySpanId);
        Assert.Equal(loggingThreadId, record.ManagedThreadId);
        Assert.NotEqual(Environment.CurrentManagedThreadId, record.ManagedThreadId);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void Flush_EmitsNoTraceContext_WhenRecordCreatedWithoutW3CActivity(bool useHierarchicalActivity)
    {
        using var provider = new CapturingProvider();
        using ServiceProvider services = CreateServices(provider);
        ILogger logger = services.GetRequiredService<ILogger<GlobalBufferTests>>();
 
        _ = RunOnDedicatedThread(() =>
        {
            // ignore any ambient activity flowing from the test runner
            Activity.Current = null;
            using Activity? activity = useHierarchicalActivity ? new Activity("logging") : null;
            activity?.SetIdFormat(ActivityIdFormat.Hierarchical).Start();
 
            logger.LogWarning("Hello {Name}", "World");
        });
 
        // the activity of the flushing thread must not be attributed to the record
        using (Activity flushingActivity = new Activity("flushing"))
        {
            flushingActivity.SetIdFormat(ActivityIdFormat.W3C).Start();
            services.GetRequiredService<GlobalLogBuffer>().Flush();
        }
 
        CapturedRecord record = Assert.Single(provider.Records);
        Assert.Null(record.ActivityTraceId);
        Assert.Null(record.ActivitySpanId);
    }
 
    [Fact]
    public void Flush_EmitsMessageTemplate()
    {
        using var provider = new CapturingProvider();
        using ServiceProvider services = CreateServices(provider);
        ILogger logger = services.GetRequiredService<ILogger<GlobalBufferTests>>();
 
        logger.LogWarning("Hello {Name}", "World");
 
        services.GetRequiredService<GlobalLogBuffer>().Flush();
 
        Assert.Equal("Hello {Name}", Assert.Single(provider.Records).MessageTemplate);
    }
 
    [Fact]
    public void Flush_EmitsNoMessageTemplate_WhenStateIsAnObject()
    {
        using var provider = new CapturingProvider();
        using ServiceProvider services = CreateServices(provider);
        ILogger logger = services.GetRequiredService<ILogger<GlobalBufferTests>>();
 
        // the logger records state which isn't a list of name/value pairs as "{OriginalFormat}"
        logger.Log(LogLevel.Warning, new EventId(1), new ObjectState(), null, static (_, _) => "Hello World");
 
        services.GetRequiredService<GlobalLogBuffer>().Flush();
 
        CapturedRecord record = Assert.Single(provider.Records);
        Assert.Contains(new KeyValuePair<string, object?>("{OriginalFormat}", "object state"), record.Attributes);
        Assert.Null(record.MessageTemplate);
    }
 
    [Fact]
    public void Flush_EmitsException_OnlyForRecordsLoggedWithException()
    {
        using var provider = new CapturingProvider();
        using ServiceProvider services = CreateServices(provider);
        ILogger logger = services.GetRequiredService<ILogger<GlobalBufferTests>>();
 
        logger.LogWarning("Without exception");
        logger.LogWarning(new InvalidOperationException("Something failed"), "With exception");
 
        services.GetRequiredService<GlobalLogBuffer>().Flush();
 
        Assert.Null(Assert.Single(provider.Records, record => record.FormattedMessage == "Without exception").Exception);
        Assert.Equal("Something failed", Assert.Single(provider.Records, record => record.FormattedMessage == "With exception").Exception);
    }
 
    [Fact]
    public void Flush_EmitsNoDataOfPreviousRecords_WhenTrimmedRecordsAreReused()
    {
        using var provider = new CapturingProvider();
        using ServiceProvider services = new ServiceCollection()
            .AddLogging(builder => builder
                .AddProvider(provider)
                .AddGlobalBuffer(options =>
                {
                    // small enough for most records to be trimmed, so that their instances get reused
                    options.MaxBufferSizeInBytes = 4096;
                    options.Rules.Add(new LogBufferingFilterRule(logLevel: LogLevel.Warning));
                }))
            .BuildServiceProvider();
        ILogger logger = services.GetRequiredService<ILogger<GlobalBufferTests>>();
 
        _ = RunOnDedicatedThread(() =>
        {
            // ignore any ambient activity flowing from the test runner
            Activity.Current = null;
 
            // records with a trace context and a message template, which are trimmed and then reused...
            using (Activity activity = new Activity("logging"))
            {
                activity.SetIdFormat(ActivityIdFormat.W3C).Start();
                for (int i = 0; i < 100; i++)
                {
                    logger.LogWarning("Record {Index}", i);
                }
            }
 
            // ...for records without them
            for (int i = 0; i < 100; i++)
            {
                var state = new List<KeyValuePair<string, object?>> { new("Index", i) };
                logger.Log(LogLevel.Warning, new EventId(1), state, null, (_, _) => $"Other record {i}");
            }
        });
 
        services.GetRequiredService<GlobalLogBuffer>().Flush();
 
        Assert.InRange(provider.Records.Count, 2, 50);
        Assert.All(provider.Records, record =>
        {
            Assert.StartsWith("Other record", record.FormattedMessage);
            Assert.Equal("Index", Assert.Single(record.Attributes).Key);
            Assert.Null(record.ActivityTraceId);
            Assert.Null(record.ActivitySpanId);
            Assert.Null(record.MessageTemplate);
        });
    }
 
    private static ServiceProvider CreateServices(ILoggerProvider provider) =>
        new ServiceCollection()
            .AddLogging(builder => builder
                .AddProvider(provider)
                .AddGlobalBuffer(LogLevel.Warning))
            .BuildServiceProvider();
 
    private static int RunOnDedicatedThread(Action action)
    {
        int threadId = 0;
        Exception? exception = null;
        var thread = new Thread(() =>
        {
            threadId = Environment.CurrentManagedThreadId;
            try
            {
                action();
            }
#pragma warning disable CA1031 // Do not catch general exception types - the exception is reported on the test thread
            catch (Exception ex)
#pragma warning restore CA1031 // Do not catch general exception types
            {
                exception = ex;
            }
        });
 
        thread.Start();
        thread.Join();
 
        Assert.Null(exception);
        return threadId;
    }
 
    private sealed class CapturingProvider : ILoggerProvider, ILogger, IBufferedLogger
    {
        public List<CapturedRecord> Records { get; } = [];
 
        public ILogger CreateLogger(string categoryName) => this;
 
        public IDisposable? BeginScope<TState>(TState state)
            where TState : notnull => null;
 
        public bool IsEnabled(LogLevel logLevel) => true;
 
        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
        {
            // only buffered log records are of interest
        }
 
        public void LogRecords(IEnumerable<BufferedLogRecord> records)
        {
            // records must not be accessed after this method returns, hence copying them
            Records.AddRange(records.Select(record => new CapturedRecord(record)));
        }
 
        public void Dispose()
        {
            // nothing to dispose
        }
    }
 
    private sealed class ObjectState
    {
        public override string ToString() => "object state";
    }
 
    private sealed class CapturedRecord
    {
        public CapturedRecord(BufferedLogRecord record)
        {
            ActivityTraceId = record.ActivityTraceId;
            ActivitySpanId = record.ActivitySpanId;
            ManagedThreadId = record.ManagedThreadId;
            MessageTemplate = record.MessageTemplate;
            FormattedMessage = record.FormattedMessage;
            Exception = record.Exception;
            Attributes = record.Attributes.ToArray();
        }
 
        public ActivityTraceId? ActivityTraceId { get; }
        public ActivitySpanId? ActivitySpanId { get; }
        public int? ManagedThreadId { get; }
        public string? MessageTemplate { get; }
        public string? FormattedMessage { get; }
        public string? Exception { get; }
        public KeyValuePair<string, object?>[] Attributes { get; }
    }
}
#endif