Skip to content

push improved MSBuildLogger to a separate project in case we want to spin it off #49751

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\Cli\Microsoft.DotNet.Cli.Utils\Microsoft.DotNet.Cli.Utils.csproj"
Condition="'$(TargetFramework)' != 'net472'">
<ProjectReference Include="..\..\Cli\Microsoft.DotNet.Cli.Utils\Microsoft.DotNet.Cli.Utils.csproj" Condition="'$(TargetFramework)' != 'net472'">
<!-- This is referenced by the core CLI as well so it doesn't need to be redistributed
in the containers task folder. -->
<IncludeAssets>build</IncludeAssets>
<Private>false</Private>
</ProjectReference>
<ProjectReference Include="..\..\Microsoft.Extensions.Logging.MSBuild\Microsoft.Extensions.Logging.MSBuild.csproj" Condition="'$(TargetFramework)' != 'net472'" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using System.Text.Json.Nodes;
using Microsoft.Build.Framework;
using Microsoft.Extensions.Logging;
using Microsoft.NET.Build.Containers.Logging;
using Microsoft.Extensions.Logging.MSBuild;
using Microsoft.NET.Build.Containers.Resources;
using ILogger = Microsoft.Extensions.Logging.ILogger;
using Task = System.Threading.Tasks.Task;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

using Microsoft.Build.Framework;
using Microsoft.Extensions.Logging;
using Microsoft.NET.Build.Containers.Logging;
using Microsoft.Extensions.Logging.MSBuild;
using Microsoft.NET.Build.Containers.Resources;
using ILogger = Microsoft.Extensions.Logging.ILogger;

Expand Down
7 changes: 1 addition & 6 deletions src/Containers/packaging/package.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@
SetTargetFramework="TargetFramework=$(TargetFramework)"
OutputItemType="ContainerLibraryOutput"/>

<ProjectReference Include="../../Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj"
SetTargetFramework="TargetFramework=$(ResolverTargetFramework)"
OutputItemType="DotNetCliUtilsLibraryOutput"/>

<ProjectReference Include="../Microsoft.NET.Build.Containers/Microsoft.NET.Build.Containers.csproj"
SetTargetFramework="TargetFramework=$(VSCompatTargetFramework)"
OutputItemType="ContainerLibraryOutputNet472" Condition="'$(DotNetBuildSourceOnly)' != 'true'" />
Expand Down Expand Up @@ -86,15 +82,14 @@
$([MSBuild]::ValueOrDefault('%(_AllNetContainerTaskDependencies.NuGetPackageId)', '').Contains('Microsoft.Extensions'))
) and
%(_AllNetContainerTaskDependencies.NuGetIsFrameworkReference) != true" />

<NecessaryNetContainerTaskDependencies Include="@(_AllNetContainerTaskDependencies)" Condition="'%(_AllNetContainerTaskDependencies.ReferenceSourceTarget)' == 'ProjectReference'" />
<!-- root folder -->
<Content Include="README.md" Pack="true" PackagePath="" />

<!-- tasks folder -->
<!-- net7.0 tasks -->
<!-- dependencies -->
<Content Include="@(NecessaryNetContainerTaskDependencies)" Pack="true" PackagePath="tasks/$(TargetFramework)/" />
<Content Include="@(DotNetCliUtilsLibraryOutput)" Pack="true" PackagePath="tasks/$(TargetFramework)/" />

<!-- runtime deps json -->
<Content Include="$(ArtifactsDir)bin/Microsoft.NET.Build.Containers/$(Configuration)/$(TargetFramework)/Microsoft.NET.Build.Containers.deps.json" Pack="true" PackagePath="tasks/$(TargetFramework)" />
Expand Down
266 changes: 266 additions & 0 deletions src/Microsoft.Extensions.Logging.MSBuild/MSBuildLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.NET.StringTools;

namespace Microsoft.Extensions.Logging.MSBuild;

/// <summary>
/// Implements an ILogger that passes the logs to the wrapped TaskLoggingHelper.
/// </summary>
/// <remarks>
/// This logger is designed to be used with MSBuild tasks, allowing logs to be written in a way that integrates with the MSBuild logging system.
/// It looks for specific property names in the state/scope parts of the message and maps them to the parameters of the MSBuild LogX methods.
/// Those specific keys are:
/// <list type="bullet">
/// <item><term>Subcategory</term></item>
/// <item><term>Code</term></item>
/// <item><term>HelpKeyword</term></item>
/// <item><term>File</term></item>
/// <item><term>LineNumber</term></item>
/// <item><term>ColumnNumber</term></item>
/// <item><term>EndLineNumber</term></item>
/// <item><term>EndColumnNumber</term></item>
/// <item><term>{OriginalFormat}</term><description>(usually provided by the underlying logging framework)</description></item>
/// </list>
///
/// So if you add these to the scope (e.g. via <code lang="csharp">_logger.BeginScope(new Dictionary&lt;string, object&gt;{ ... }))</code> or on the message format itself,
/// they will be extracted and used to format the message correctly for MSBuild.
/// </remarks>
public sealed class MSBuildLogger : ILogger
{
private static readonly IDisposable Scope = new DummyDisposable();

private readonly TaskLoggingHelper _loggingHelper;
private readonly string _category;
private IExternalScopeProvider? _scopeProvider;

public MSBuildLogger(string category, TaskLoggingHelper loggingHelperToWrap, IExternalScopeProvider? scopeProvider = null)
{
_category = category;
_loggingHelper = loggingHelperToWrap;
_scopeProvider = scopeProvider;
}

IDisposable ILogger.BeginScope<TState>(TState state) => _scopeProvider?.Push(state) ?? Scope;

public bool IsEnabled(LogLevel logLevel) =>
logLevel switch
{
LogLevel.Trace => _loggingHelper.LogsMessagesOfImportance(MessageImportance.Low),
LogLevel.Debug => _loggingHelper.LogsMessagesOfImportance(MessageImportance.Normal),
LogLevel.Information => _loggingHelper.LogsMessagesOfImportance(MessageImportance.High),
LogLevel.Warning or LogLevel.Error or LogLevel.Critical => true,
LogLevel.None => false,
_ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null)
};

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var message = FormatMessage(_category, state, exception, formatter, _scopeProvider);
switch (logLevel)
{
case LogLevel.Trace:
_loggingHelper.LogMessage(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, MessageImportance.Low, message.message);
break;
case LogLevel.Debug:
_loggingHelper.LogMessage(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, MessageImportance.Normal, message.message);
break;
case LogLevel.Information:
_loggingHelper.LogMessage(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, MessageImportance.High, message.message);
break;
case LogLevel.Warning:
_loggingHelper.LogWarning(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, message.message);
break;
case LogLevel.Error:
case LogLevel.Critical:
_loggingHelper.LogError(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, message.message);
break;
case LogLevel.None:
break;
default:
break;
}
}

private static MSBuildMessageParameters FormatMessage<TState>(string category, TState state, Exception? exception, Func<TState, Exception?, string> formatter, IExternalScopeProvider? scopeProvider)
{
MSBuildMessageParameters message = default;
using var builder = new SpanBasedStringBuilder();
var categoryBlock = string.Concat("[".AsSpan(), category.AsSpan(), "] ".AsSpan());
builder.Append(categoryBlock);
var formatted = formatter(state, exception);
builder.Append(formatted);

if (scopeProvider is not null)
{
// state will be a FormattedLogValues instance
// scope will be our dictionary thing we need to probe into
scopeProvider.ForEachScope((scope, state) =>
{
var stateItems = (state as IReadOnlyList<KeyValuePair<string, object?>>)!;
string originalFormat = null!;

foreach (var kvp in stateItems)
{
switch (kvp.Key)
{
case "{OriginalFormat}":
// If the key is {OriginalFormat}, we will use it to set the originalFormat variable.
// This is used to avoid appending the same key again in the message.
if (kvp.Value is string format)
{
originalFormat = format;
}
continue;
case "Subcategory":
message.subcategory = kvp.Value as string;
continue;
case "Code":
message.code = kvp.Value as string;
continue;
case "HelpKeyword":
message.helpKeyword = kvp.Value as string;
continue;
case "File":
message.file = kvp.Value as string;
continue;
case "LineNumber":
if (kvp.Value is int lineNumber)
message.lineNumber = lineNumber;
continue;
case "ColumnNumber":
if (kvp.Value is int columnNumber)
message.columnNumber = columnNumber;
continue;
case "EndLineNumber":
if (kvp.Value is int endLineNumber)
message.endLineNumber = endLineNumber;
continue;
case "EndColumnNumber":
if (kvp.Value is int endColumnNumber)
message.endColumnNumber = endColumnNumber;
continue;
default:
var wrappedKey = "{" + kvp.Key + "}";
if (originalFormat.Contains(wrappedKey))
Copy link
Preview

Copilot AI Jul 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null reference exception when originalFormat is null. The variable is declared as 'null!' but can remain null if no {OriginalFormat} key is found, causing a null reference when calling Contains().

Suggested change
if (originalFormat.Contains(wrappedKey))
if (originalFormat != null && originalFormat.Contains(wrappedKey))

Copilot uses AI. Check for mistakes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

originalFormat is also null if "{OriginalFormat}" is a key in the list but the foreach (var kvp in stateItems) loop has not reached it yet. There is no documented requirement that "{OriginalFormat}" be in the first element of the list.

{
// If the key is part of the format string of the original format, we don't need to append it again.
continue;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the message template includes formatting like {CreationDate:O}, this won't match.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree - I should possibly look at interpreting this via CompositeFormat.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CompositeFormat.Parse method does not support named parameter placeholders like log message templates have. The CompositeFormat class does not have public members for examining the structure of the format string. I don't think CompositeFormat is useful here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping it would be slightly more capable. In the past I've used MessageTemplates for this kind of thing, but there would be source-build concerns with using an external library. Might have to punt for v1 of this - at least if it stays in the SDK repo directly.


// Otherwise, append the key and value to the message.
// if MSbuild had a property bag concept on the message APIs,
// we could use that instead of appending to the message.

builder.Append($" {kvp.Key}={kvp.Value}");
continue;
}
}

if (scope is IDictionary<string, object> dict)
{
foreach (var kvp in dict)
{
switch (kvp.Key)
{
// map all of the keys we decide are special and map to MSbuild message concepts
case "{OriginalFormat}":
continue;
case "Subcategory":
message.subcategory = kvp.Value as string;
continue;
case "Code":
message.code = kvp.Value as string;
continue;
case "HelpKeyword":
message.helpKeyword = kvp.Value as string;
continue;
case "File":
message.file = kvp.Value as string;
continue;
case "LineNumber":
if (kvp.Value is int lineNumber)
message.lineNumber = lineNumber;
continue;
case "ColumnNumber":
if (kvp.Value is int columnNumber)
message.columnNumber = columnNumber;
continue;
case "EndLineNumber":
if (kvp.Value is int endLineNumber)
message.endLineNumber = endLineNumber;
continue;
case "EndColumnNumber":
if (kvp.Value is int endColumnNumber)
message.endColumnNumber = endColumnNumber;
continue;
default:
var wrappedKey = "{" + kvp.Key + "}";
if (originalFormat.Contains(wrappedKey))
Copy link
Preview

Copilot AI Jul 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null reference exception when originalFormat is null. This is the same issue as line 140 - originalFormat can be null when no {OriginalFormat} key is found in the state items.

Suggested change
if (originalFormat.Contains(wrappedKey))
if (originalFormat != null && originalFormat.Contains(wrappedKey))

Copilot uses AI. Check for mistakes.

{
// If the key is part of the format string of the original format, we don't need to append it again.
continue;
}

// Otherwise, append the key and value to the message.
// if MSbuild had a property bag concept on the message APIs,
// we could use that instead of appending to the message.

builder.Append($" {kvp.Key}={kvp.Value}");
continue;
}
}
}
else if (scope is string s)
{
builder.Append($" {s}");
}


}, state);
}

message.message = builder.ToString();
return message;
}


/// <summary>
/// a struct that maps to the parameters of the MSBuild LogX methods. We'll extract this from M.E.ILogger state/scope information so that we can be maximally compatible with the MSBuild logging system.
/// </summary>
/// <param name="subcategory"></param>
/// <param name="code"></param>
/// <param name="helpKeyword"></param>
/// <param name="file"></param>
/// <param name="lineNumber"></param>
/// <param name="columnNumber"></param>
/// <param name="endLineNumber"></param>
/// <param name="endColumnNumber"></param>
/// <param name="message"></param>
private record struct MSBuildMessageParameters(string? subcategory,
string? code,
string? helpKeyword,
string? file,
int? lineNumber,
int? columnNumber,
int? endLineNumber,
int? endColumnNumber,
string message);

/// <summary>
/// A simple disposable to describe scopes with <see cref="ILogger.BeginScope"/>.
/// </summary>
private sealed class DummyDisposable : IDisposable
{
public void Dispose() { }
}

internal void SetScopeProvider(IExternalScopeProvider scopeProvider)
{
_scopeProvider = scopeProvider;
}
}
Loading
Loading