File: src\Aspire.Hosting.Browsers\BrowserLogsEventLogger.cs
Web Access
Project: src\tests\Aspire.Hosting.Browsers.Tests\Aspire.Hosting.Browsers.Tests.csproj (Aspire.Hosting.Browsers.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
// Turns low-level CDP events into resource log lines. Keeping this logic stateful but transport-free lets tests cover
// redirects, timing, and console formatting without needing a live browser.
internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogger)
{
    private static readonly JsonWriterOptions s_structuredValueWriterOptions = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };
 
    private readonly string _sessionId = sessionId;
    private readonly ILogger _resourceLogger = resourceLogger;
    // Network request information arrives as several independent CDP events. A live page can redirect, fail, or serve
    // from cache/service worker before the terminal event arrives, so keep just enough per-request state to emit one
    // resource-log line when the request is complete.
    private readonly Dictionary<string, BrowserNetworkRequestState> _networkRequests = new(StringComparer.Ordinal);
 
    public void HandleEvent(BrowserLogsCdpProtocolEvent protocolEvent)
    {
        switch (protocolEvent)
        {
            case BrowserLogsConsoleApiCalledEvent consoleApiCalledEvent:
                LogConsoleMessage(consoleApiCalledEvent.Parameters);
                break;
            case BrowserLogsExceptionThrownEvent exceptionThrownEvent:
                LogUnhandledException(exceptionThrownEvent.Parameters);
                break;
            case BrowserLogsLogEntryAddedEvent logEntryAddedEvent:
                LogEntryAdded(logEntryAddedEvent.Parameters);
                break;
            case BrowserLogsRequestWillBeSentEvent requestWillBeSentEvent:
                TrackRequestStarted(requestWillBeSentEvent.Parameters);
                break;
            case BrowserLogsResponseReceivedEvent responseReceivedEvent:
                TrackResponseReceived(responseReceivedEvent.Parameters);
                break;
            case BrowserLogsLoadingFinishedEvent loadingFinishedEvent:
                TrackRequestCompleted(loadingFinishedEvent.Parameters);
                break;
            case BrowserLogsLoadingFailedEvent loadingFailedEvent:
                TrackRequestFailed(loadingFailedEvent.Parameters);
                break;
        }
    }
 
    private void LogConsoleMessage(BrowserLogsRuntimeConsoleApiCalledParameters parameters)
    {
        var level = parameters.Type ?? "log";
        var message = parameters.Args is { Length: > 0 }
            ? string.Join(" ", parameters.Args.Select(FormatRemoteObject).Where(static value => !string.IsNullOrEmpty(value)))
            : string.Empty;
 
        WriteLog(MapConsoleLevel(level), $"[console.{level}] {message}".TrimEnd());
    }
 
    private void LogUnhandledException(BrowserLogsExceptionThrownParameters parameters)
    {
        var exceptionDetails = parameters.ExceptionDetails;
        if (exceptionDetails is null)
        {
            return;
        }
 
        var message = exceptionDetails.Exception?.Description
            ?? exceptionDetails.Text
            ?? "Unhandled browser exception";
 
        var location = GetLocationSuffix(exceptionDetails);
        WriteLog(LogLevel.Error, $"[exception] {message}{location}");
    }
 
    private void LogEntryAdded(BrowserLogsLogEntryAddedParameters parameters)
    {
        var entry = parameters.Entry;
        if (entry is null)
        {
            return;
        }
 
        var level = entry.Level ?? "info";
        var text = entry.Text ?? string.Empty;
        var location = GetLocationSuffix(entry);
 
        WriteLog(MapLogEntryLevel(level), $"[log.{level}] {text}{location}".TrimEnd());
    }
 
    private void TrackRequestStarted(BrowserLogsRequestWillBeSentParameters parameters)
    {
        if (parameters.RequestId is not { Length: > 0 } requestId || parameters.Request is not { } request)
        {
            return;
        }
 
        var url = request.Url;
        var method = request.Method;
        if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(method))
        {
            return;
        }
 
        if (parameters.RedirectResponse is not null &&
            _networkRequests.Remove(requestId, out var redirectedRequest))
        {
            // CDP reuses the same request id when a redirect starts the next hop, so emit the completed hop before
            // overwriting it with the redirected request state.
            UpdateResponse(redirectedRequest, parameters.RedirectResponse);
            LogCompletedRequest(redirectedRequest, parameters.Timestamp, encodedDataLength: null, redirectUrl: url);
        }
 
        _networkRequests[requestId] = new BrowserNetworkRequestState
        {
            Method = method,
            ResourceType = NormalizeResourceType(parameters.Type),
            StartTimestamp = parameters.Timestamp,
            Url = url
        };
    }
 
    private void TrackResponseReceived(BrowserLogsResponseReceivedParameters parameters)
    {
        if (parameters.RequestId is not { Length: > 0 } requestId ||
            !_networkRequests.TryGetValue(requestId, out var request))
        {
            return;
        }
 
        if (parameters.Response is not null)
        {
            // Cache and service-worker flags are only available on the response event, while the duration and encoded
            // byte count arrive later on loadingFinished.
            UpdateResponse(request, parameters.Response);
        }
 
        if (parameters.Type is { Length: > 0 } resourceType)
        {
            request.ResourceType = NormalizeResourceType(resourceType);
        }
    }
 
    private void TrackRequestCompleted(BrowserLogsLoadingFinishedParameters parameters)
    {
        if (parameters.RequestId is not { Length: > 0 } requestId ||
            !_networkRequests.Remove(requestId, out var request))
        {
            return;
        }
 
        LogCompletedRequest(request, parameters.Timestamp, parameters.EncodedDataLength, redirectUrl: null);
    }
 
    private void TrackRequestFailed(BrowserLogsLoadingFailedParameters parameters)
    {
        if (parameters.RequestId is not { Length: > 0 } requestId ||
            !_networkRequests.Remove(requestId, out var request))
        {
            return;
        }
 
        var details = new List<string>();
 
        if (FormatDuration(request.StartTimestamp, parameters.Timestamp) is { Length: > 0 } duration)
        {
            details.Add(duration);
        }
 
        if (parameters.Canceled == true)
        {
            details.Add("canceled");
        }
 
        if (!string.IsNullOrEmpty(parameters.BlockedReason))
        {
            details.Add($"blocked={parameters.BlockedReason}");
        }
 
        WriteLog(LogLevel.Warning, $"[network.{request.ResourceType}] {request.Method} {request.Url} failed: {parameters.ErrorText ?? "Request failed"}{FormatDetails(details)}");
    }
 
    private void LogCompletedRequest(BrowserNetworkRequestState request, double? completedTimestamp, double? encodedDataLength, string? redirectUrl)
    {
        var details = new List<string>();
 
        if (FormatDuration(request.StartTimestamp, completedTimestamp) is { Length: > 0 } duration)
        {
            details.Add(duration);
        }
 
        if (encodedDataLength is > 0)
        {
            details.Add($"{Math.Round(encodedDataLength.Value, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)} B");
        }
 
        if (request.FromDiskCache == true)
        {
            details.Add("disk-cache");
        }
 
        if (request.FromServiceWorker == true)
        {
            details.Add("service-worker");
        }
 
        if (!string.IsNullOrEmpty(redirectUrl))
        {
            details.Add($"redirect to {redirectUrl}");
        }
 
        var statusText = request.StatusCode is int statusCode
            ? string.IsNullOrEmpty(request.StatusText)
                ? $" -> {statusCode}"
                : $" -> {statusCode} {request.StatusText}"
            : redirectUrl is null
                ? " completed"
                : " -> redirect";
 
        WriteLog(LogLevel.Information, $"[network.{request.ResourceType}] {request.Method} {request.Url}{statusText}{FormatDetails(details)}");
    }
 
    private static void UpdateResponse(BrowserNetworkRequestState request, BrowserLogsResponse response)
    {
        request.Url = response.Url ?? request.Url;
        request.StatusCode = response.Status;
        request.StatusText = response.StatusText;
        request.FromDiskCache = response.FromDiskCache;
        request.FromServiceWorker = response.FromServiceWorker;
    }
 
    private void WriteLog(LogLevel logLevel, string message)
    {
        var sessionMessage = $"[{_sessionId}] {message}";
 
        switch (logLevel)
        {
            case LogLevel.Error:
            case LogLevel.Critical:
                _resourceLogger.LogError("{Message}", sessionMessage);
                break;
            case LogLevel.Warning:
                _resourceLogger.LogWarning("{Message}", sessionMessage);
                break;
            case LogLevel.Debug:
            case LogLevel.Trace:
                _resourceLogger.LogDebug("{Message}", sessionMessage);
                break;
            default:
                _resourceLogger.LogInformation("{Message}", sessionMessage);
                break;
        }
    }
 
    private static string NormalizeResourceType(string? resourceType) =>
        string.IsNullOrEmpty(resourceType)
            ? "request"
            : resourceType.ToLowerInvariant();
 
    private static string? FormatDuration(double? startTimestamp, double? endTimestamp)
    {
        if (startTimestamp is null || endTimestamp is null || endTimestamp < startTimestamp)
        {
            return null;
        }
 
        var durationMs = Math.Round((endTimestamp.Value - startTimestamp.Value) * 1000, MidpointRounding.AwayFromZero);
        return $"{durationMs.ToString(CultureInfo.InvariantCulture)} ms";
    }
 
    private static string FormatDetails(IReadOnlyList<string> details) =>
        details.Count > 0
            ? $" ({string.Join(", ", details)})"
            : string.Empty;
 
    private static LogLevel MapConsoleLevel(string level) => level switch
    {
        "error" or "assert" => LogLevel.Error,
        "warning" or "warn" => LogLevel.Warning,
        "debug" => LogLevel.Debug,
        _ => LogLevel.Information
    };
 
    private static LogLevel MapLogEntryLevel(string level) => level switch
    {
        "error" => LogLevel.Error,
        "warning" => LogLevel.Warning,
        "verbose" => LogLevel.Debug,
        _ => LogLevel.Information
    };
 
    private static string FormatRemoteObject(BrowserLogsCdpProtocolRemoteObject remoteObject)
    {
        // Console arguments can arrive either as pre-rendered descriptions or as structured values that need stable
        // formatting for logs and tests.
        if (remoteObject.Value is BrowserLogsCdpProtocolValue value)
        {
            return value switch
            {
                BrowserLogsCdpProtocolStringValue stringValue => stringValue.Value,
                BrowserLogsCdpProtocolNullValue => "null",
                BrowserLogsCdpProtocolBooleanValue booleanValue => booleanValue.Value ? bool.TrueString : bool.FalseString,
                BrowserLogsCdpProtocolNumberValue numberValue => numberValue.RawValue,
                _ => FormatStructuredValue(value)
            };
        }
 
        if (!string.IsNullOrEmpty(remoteObject.UnserializableValue))
        {
            return remoteObject.UnserializableValue;
        }
 
        return remoteObject.Description ?? string.Empty;
    }
 
    private static string FormatStructuredValue(BrowserLogsCdpProtocolValue value)
    {
        var buffer = new ArrayBufferWriter<byte>();
        using var writer = new Utf8JsonWriter(buffer, s_structuredValueWriterOptions);
        WriteStructuredValue(writer, value);
        writer.Flush();
        return Encoding.UTF8.GetString(buffer.WrittenSpan);
    }
 
    private static void WriteStructuredValue(Utf8JsonWriter writer, BrowserLogsCdpProtocolValue value)
    {
        switch (value)
        {
            case BrowserLogsCdpProtocolArrayValue arrayValue:
                writer.WriteStartArray();
                foreach (var item in arrayValue.Items)
                {
                    WriteStructuredValue(writer, item);
                }
 
                writer.WriteEndArray();
                break;
            case BrowserLogsCdpProtocolBooleanValue booleanValue:
                writer.WriteBooleanValue(booleanValue.Value);
                break;
            case BrowserLogsCdpProtocolNullValue:
                writer.WriteNullValue();
                break;
            case BrowserLogsCdpProtocolNumberValue numberValue:
                writer.WriteRawValue(numberValue.RawValue, skipInputValidation: false);
                break;
            case BrowserLogsCdpProtocolObjectValue objectValue:
                writer.WriteStartObject();
                foreach (var (propertyName, propertyValue) in objectValue.Properties)
                {
                    writer.WritePropertyName(propertyName);
                    WriteStructuredValue(writer, propertyValue);
                }
 
                writer.WriteEndObject();
                break;
            case BrowserLogsCdpProtocolStringValue stringValue:
                writer.WriteStringValue(stringValue.Value);
                break;
        }
    }
 
    private static string GetLocationSuffix(BrowserLogsSourceLocation details)
    {
        var url = details.Url;
        if (string.IsNullOrEmpty(url))
        {
            return string.Empty;
        }
 
        var lineNumber = details.LineNumber + 1;
        var columnNumber = details.ColumnNumber + 1;
 
        if (lineNumber > 0 && columnNumber > 0)
        {
            return $" ({url}:{lineNumber}:{columnNumber})";
        }
 
        return $" ({url})";
    }
 
    private sealed class BrowserNetworkRequestState
    {
        public bool? FromDiskCache { get; set; }
 
        public bool? FromServiceWorker { get; set; }
 
        public required string Method { get; set; }
 
        public required string ResourceType { get; set; }
 
        public double? StartTimestamp { get; set; }
 
        public int? StatusCode { get; set; }
 
        public string? StatusText { get; set; }
 
        public required string Url { get; set; }
    }
}