// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using Microsoft.Build.Experimental.BuildCheck.Infrastructure.EditorConfig; using Microsoft.Build.Framework; using Microsoft.Build.Framework.Telemetry; using Microsoft.Build.Shared; #nullable disable namespace Microsoft.Build.Logging { /// /// Represents the parsed parameters for a BinaryLogger. /// public sealed class BinaryLoggerParameters { /// /// Gets the log file path. Returns null if not specified or if the path contains wildcards. /// public string LogFilePath { get; internal set; } /// /// Gets the project imports collection mode. /// public BinaryLogger.ProjectImportsCollectionMode ProjectImportsCollectionMode { get; internal set; } = BinaryLogger.ProjectImportsCollectionMode.Embed; /// /// Gets whether the ProjectImports parameter was explicitly specified in the parameters string. /// internal bool HasProjectImportsParameter { get; set; } /// /// Gets whether to omit initial info from the log. /// public bool OmitInitialInfo { get; internal set; } } /// /// A logger that serializes all incoming BuildEventArgs in a compressed binary file (*.binlog). The file /// can later be played back and piped into other loggers (file, console, etc) to reconstruct the log contents /// as if a real build was happening. Additionally, this format can be read by tools for /// analysis or visualization. Since the file format preserves structure, tools don't have to parse /// text logs that erase a lot of useful information. /// /// The logger is public so that it can be instantiated from MSBuild.exe via command-line switch. public sealed class BinaryLogger : ILogger, IFileOutputLogger { // version 2: // - new BuildEventContext.EvaluationId // - new record kinds: ProjectEvaluationStarted, ProjectEvaluationFinished // version 3: // - new ProjectImportedEventArgs.ImportIgnored // version 4: // - new TargetSkippedEventArgs // - new TargetStartedEventArgs.BuildReason // version 5: // - new EvaluationFinished.ProfilerResult // version 6: // - Ids and parent ids for the evaluation locations // version 7: // - Include ProjectStartedEventArgs.GlobalProperties // version 8: // - This was used in a now-reverted change but is the same as 9. // version 9: // - new record kinds: EnvironmentVariableRead, PropertyReassignment, UninitializedPropertyRead // version 10: // - new record kinds: // * String - deduplicate strings by hashing and write a string record before it's used // * NameValueList - deduplicate arrays of name-value pairs such as properties, items and metadata // in a separate record and refer to those records from regular records // where a list used to be written in-place // version 11: // - new record kind: TaskParameterEventArgs // version 12: // - add GlobalProperties, Properties and Items on ProjectEvaluationFinished // version 13: // - don't log Message where it can be recovered // - log arguments for LazyFormattedBuildEventArgs // - TargetSkippedEventArgs: added OriginallySucceeded, Condition, EvaluatedCondition // version 14: // - TargetSkippedEventArgs: added SkipReason, OriginalBuildEventContext // version 15: // - new record kind: ResponseFileUsedEventArgs // version 16: // - AssemblyLoadBuildEventArgs // version 17: // - Added extended data for types implementing IExtendedBuildEventArgs // version 18: // - Making ProjectStartedEventArgs, ProjectEvaluationFinishedEventArgs, AssemblyLoadBuildEventArgs equal // between de/serialization roundtrips. // - Adding serialized events lengths - to support forward compatible reading // version 19: // - GeneratedFileUsedEventArgs exposed for brief period of time (so let's continue with 20) // version 20: // - TaskStartedEventArgs: Added TaskAssemblyLocation property // version 21: // - TaskParameterEventArgs: Added ParameterName and PropertyName properties // version 22: // - extend EnvironmentVariableRead with location where environment variable was used. // version 23: // - new record kinds: BuildCheckMessageEvent, BuildCheckWarningEvent, BuildCheckErrorEvent, // BuildCheckTracingEvent, BuildCheckAcquisitionEvent, BuildSubmissionStartedEvent // version 24: // - new record kind: BuildCanceledEventArgs // version 25: // - add extra information to PropertyInitialValueSetEventArgs and PropertyReassignmentEventArgs and change message formatting logic. // version 26: // - new record kind: LoggersRegisteredEventArgs (reports registered loggers and their output file paths) // version 27: // - new record kind: MSBuildServerLifecycleEventArgs (reports how a build related to the MSBuild Server node) // MAKE SURE YOU KEEP BuildEventArgsWriter AND StructuredLogViewer.BuildEventArgsWriter IN SYNC WITH THE CHANGES ABOVE. // Both components must stay in sync to avoid issues with logging or event handling in the products. // This should be never changed. // The minimum version of the binary log reader that can read log of above version. internal const int ForwardCompatibilityMinimalVersion = 18; // The current version of the binary log representation. // Changes with each update of the binary log format. internal const int FileFormatVersion = 27; // The minimum version of the binary log reader that can read log of above version. // This should be changed only when the binary log format is changed in a way that would prevent it from being // read by older readers. (changing of the individual BuildEventArgs or adding new is fine - as reader can // skip them if they are not known to it. Example of change requiring the increment would be the introduction of strings deduplication) internal const int MinimumReaderVersion = 18; // Parameter name constants private const string LogFileParameterPrefix = "LogFile="; private const string BinlogFileExtension = ".binlog"; private const string OmitInitialInfoParameter = "OmitInitialInfo"; private const string ProjectImportsNoneParameter = "ProjectImports=None"; private const string ProjectImportsEmbedParameter = "ProjectImports=Embed"; private const string ProjectImportsZipFileParameter = "ProjectImports=ZipFile"; private Stream stream; private BinaryWriter binaryWriter; private BuildEventArgsWriter eventArgsWriter; private ProjectImportsCollector projectImportsCollector; private bool _initialTargetOutputLogging; private bool _initialLogImports; private string _initialIsBinaryLoggerEnabled; /// /// Describes whether to collect the project files (including imported project files) used during the build. /// If the project files are collected they can be embedded in the log file or as a separate zip archive. /// public enum ProjectImportsCollectionMode { /// /// Don't collect any files during the build. /// None, /// /// Embed all project files directly in the log file. /// Embed, /// /// Create an external .ProjectImports.zip archive for the project files. /// ZipFile, } /// /// Parses the parameters string for a BinaryLogger. /// /// The parameters string to parse (e.g., "LogFile=msbuild.binlog;ProjectImports=None"). /// A object containing the parsed parameters. /// Thrown when the parameters string contains invalid parameters. /// /// This method parses the semicolon-delimited parameters string used by the BinaryLogger. /// Supported parameters include: /// - LogFile=<path> or just <path> (must end with .binlog): specifies the output file path /// - ProjectImports=None|Embed|ZipFile: controls project imports collection /// - OmitInitialInfo: omits initial build information /// /// Wildcards ({}) in the LogFile path are NOT expanded by this method. The returned LogFilePath /// will be null for wildcard patterns, and callers should handle expansion separately if needed. /// public static BinaryLoggerParameters ParseParameters(string parametersString) { if (parametersString == null) { throw new LoggerException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("InvalidBinaryLoggerParameters", "")); } var result = new BinaryLoggerParameters(); var parameters = parametersString.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries); foreach (var parameter in parameters) { if (TryParseProjectImports(parameter, result)) { continue; } if (string.Equals(parameter, OmitInitialInfoParameter, StringComparison.OrdinalIgnoreCase)) { result.OmitInitialInfo = true; continue; } if (TryParsePathParameter(parameter, out string filePath)) { result.LogFilePath = filePath; continue; } throw new LoggerException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("InvalidBinaryLoggerParameters", parameter)); } return result; } /// /// Attempts to parse a ProjectImports parameter. /// /// The parameter to parse. /// The BinaryLoggerParameters object to update. /// True if the parameter was a ProjectImports parameter; otherwise, false. private static bool TryParseProjectImports(string parameter, BinaryLoggerParameters result) { return TrySetProjectImportsMode(parameter, ProjectImportsNoneParameter, ProjectImportsCollectionMode.None, result) || TrySetProjectImportsMode(parameter, ProjectImportsEmbedParameter, ProjectImportsCollectionMode.Embed, result) || TrySetProjectImportsMode(parameter, ProjectImportsZipFileParameter, ProjectImportsCollectionMode.ZipFile, result); } /// /// Attempts to match and set a ProjectImports mode. /// /// The parameter to check. /// The expected parameter string. /// The mode to set if matched. /// The BinaryLoggerParameters object to update. /// True if the parameter matched; otherwise, false. private static bool TrySetProjectImportsMode(string parameter, string expectedParameter, ProjectImportsCollectionMode mode, BinaryLoggerParameters result) { if (string.Equals(parameter, expectedParameter, StringComparison.OrdinalIgnoreCase)) { result.ProjectImportsCollectionMode = mode; result.HasProjectImportsParameter = true; return true; } return false; } /// /// Attempts to parse a file path parameter from a BinaryLogger parameter string. /// /// The parameter to parse. /// The parsed file path, or null if the parameter contains wildcards. /// True if the parameter is a valid file path parameter; otherwise, false. /// /// This method recognizes file paths in the following formats: /// - "LogFile=<path>" /// - "<path>" (must end with .binlog) /// /// If the path contains wildcards ({}), the method returns true but sets filePath to null, /// as wildcard expansion requires runtime context. /// private static bool TryParsePathParameter(string parameter, out string filePath) { bool hasPathPrefix = parameter.StartsWith(LogFileParameterPrefix, StringComparison.OrdinalIgnoreCase); if (hasPathPrefix) { parameter = parameter.Substring(LogFileParameterPrefix.Length); } parameter = parameter.Trim('"'); bool isWildcard = parameter.Contains("{}"); bool hasProperExtension = parameter.EndsWith(BinlogFileExtension, StringComparison.OrdinalIgnoreCase); filePath = parameter; if (isWildcard) { // For wildcards, we return true to indicate this is a valid path parameter, // but set filePath to null since we can't expand it without instance context filePath = null; return true; } return hasProperExtension; } /// /// Gets or sets whether to capture and embed project and target source files used during the build. /// public ProjectImportsCollectionMode CollectProjectImports { get; set; } = ProjectImportsCollectionMode.Embed; internal string FilePath { get; private set; } /// IReadOnlyList IFileOutputLogger.OutputFilePaths => AdditionalFilePaths is null || AdditionalFilePaths.Count == 0 ? [FilePath] : [FilePath, .. AdditionalFilePaths]; /// /// Gets or sets additional output file paths. When set, the binlog will be copied to all these paths /// after the build completes. The primary FilePath will be used as the temporary write location. /// /// /// This property is intended for internal use by MSBuild command-line processing. /// It should not be set by external code or logger implementations. /// Use multiple logger instances with different Parameters instead. /// public IReadOnlyList AdditionalFilePaths { get; init; } /// Gets or sets the verbosity level. /// /// The binary logger Verbosity is always maximum (Diagnostic). It tries to capture as much /// information as possible. /// public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Diagnostic; /// /// Gets or sets the parameters. The only supported parameter is the output log file path (for example, "msbuild.binlog"). /// public string Parameters { get; set; } /// /// Optional expander of wildcard(s) within the LogFile path parameter of a binlog . /// Wildcards can be used in the LogFile parameter in a form for curly brackets ('{}', '{[param]}'). /// Currently, the only supported wildcard is '{}', the optional parameters within the curly brackets /// are not currently supported, however the string parameter to the func /// is reserved for this purpose. /// internal Func PathParameterExpander { private get; set; } = ExpandPathParameter; /// /// Initializes the logger by subscribing to events of the specified event source and embedded content source. /// public void Initialize(IEventSource eventSource) { _initialTargetOutputLogging = Traits.Instance.EnableTargetOutputLogging; _initialLogImports = Traits.Instance.EscapeHatches.LogProjectImports; _initialIsBinaryLoggerEnabled = Environment.GetEnvironmentVariable("MSBUILDBINARYLOGGERENABLED"); Environment.SetEnvironmentVariable("MSBUILDTARGETOUTPUTLOGGING", "true"); Environment.SetEnvironmentVariable("MSBUILDLOGIMPORTS", "1"); Environment.SetEnvironmentVariable("MSBUILDBINARYLOGGERENABLED", bool.TrueString); Traits.Instance.EscapeHatches.LogProjectImports = true; Traits.Instance.EnableTargetOutputLogging = true; bool logPropertiesAndItemsAfterEvaluation = Traits.Instance.EscapeHatches.LogPropertiesAndItemsAfterEvaluation ?? true; ProcessParameters(out bool omitInitialInfo); var replayEventSource = eventSource as IBinaryLogReplaySource; try { string logDirectory = null; try { logDirectory = Path.GetDirectoryName(FilePath); } catch (Exception) { // Directory creation is best-effort; if finding its path fails don't create the directory // and possibly let the FileStream constructor below report the failure } if (logDirectory != null) { Directory.CreateDirectory(logDirectory); } stream = new FileStream(FilePath, FileMode.Create); if (CollectProjectImports != ProjectImportsCollectionMode.None && replayEventSource == null) { projectImportsCollector = new ProjectImportsCollector(FilePath, CollectProjectImports == ProjectImportsCollectionMode.ZipFile); projectImportsCollector.FileIOExceptionEvent += EventSource_AnyEventRaised; } if (eventSource is IEventSource3 eventSource3) { eventSource3.IncludeEvaluationMetaprojects(); } if (logPropertiesAndItemsAfterEvaluation && eventSource is IEventSource4 eventSource4) { eventSource4.IncludeEvaluationPropertiesAndItems(); } } catch (Exception e) { string errorCode; string helpKeyword; string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword(out errorCode, out helpKeyword, "InvalidFileLoggerFile", FilePath, e.Message); throw new LoggerException(message, e, errorCode, helpKeyword); } stream = new GZipStream(stream, CompressionLevel.Optimal); // wrapping the GZipStream in a buffered stream significantly improves performance // and the max throughput is reached with a 32K buffer. See details here: // https://github.com/dotnet/runtime/issues/39233#issuecomment-745598847 stream = new BufferedStream(stream, bufferSize: 32768); binaryWriter = new BinaryWriter(stream); eventArgsWriter = new BuildEventArgsWriter(binaryWriter); if (projectImportsCollector != null) { eventArgsWriter.EmbedFile += EventArgsWriter_EmbedFile; } if (replayEventSource != null) { if (CollectProjectImports == ProjectImportsCollectionMode.Embed) { replayEventSource.EmbeddedContentRead += args => eventArgsWriter.WriteBlob(args.ContentKind, args.ContentStream); } else if (CollectProjectImports == ProjectImportsCollectionMode.ZipFile) { replayEventSource.EmbeddedContentRead += args => ProjectImportsCollector.FlushBlobToFile(FilePath, args.ContentStream); } // If raw events are provided - let's try to use the advantage. // But other subscribers can later on subscribe to structured events - // for this reason we do only subscribe delayed. replayEventSource.DeferredInitialize( // For raw events we cannot write the initial info - as we cannot write // at the same time as raw events are being written - this would break the deduplicated strings store. // But we need to write the version info - but since we read/write raw - let's not change the version info. () => { binaryWriter.Write(replayEventSource.FileFormatVersion); binaryWriter.Write(replayEventSource.MinimumReaderVersion); replayEventSource.RawLogRecordReceived += RawEvents_LogDataSliceReceived; // Replay separated strings here as well (and do not deduplicate! It would skew string indexes) replayEventSource.StringReadDone += strArg => eventArgsWriter.WriteStringRecord(strArg.StringToBeUsed); }, SubscribeToStructuredEvents); } else { SubscribeToStructuredEvents(); } KnownTelemetry.LoggingConfigurationTelemetry.BinaryLogger = true; void SubscribeToStructuredEvents() { // Write the version info - the latest version is written only for structured events replaying // as raw events do not change structure - hence the version is the same as the one they were written with. binaryWriter.Write(FileFormatVersion); binaryWriter.Write(MinimumReaderVersion); if (!omitInitialInfo) { LogInitialInfo(); } eventSource.AnyEventRaised += EventSource_AnyEventRaised; } } private void EventArgsWriter_EmbedFile(string filePath) { if (projectImportsCollector != null) { projectImportsCollector.AddFile(filePath); } } private void LogInitialInfo() { LogMessage("BinLogFilePath=" + FilePath); LogMessage("CurrentUICulture=" + System.Globalization.CultureInfo.CurrentUICulture.Name); } private void LogMessage(string text) { var args = new BuildMessageEventArgs(text, helpKeyword: null, senderName: "BinaryLogger", MessageImportance.Normal); args.BuildEventContext = BuildEventContext.Invalid; Write(args); } /// /// Closes the underlying file stream. /// public void Shutdown() { Environment.SetEnvironmentVariable("MSBUILDTARGETOUTPUTLOGGING", _initialTargetOutputLogging ? "true" : null); Environment.SetEnvironmentVariable("MSBUILDLOGIMPORTS", _initialLogImports ? "1" : null); Environment.SetEnvironmentVariable("MSBUILDBINARYLOGGERENABLED", _initialIsBinaryLoggerEnabled); Traits.Instance.EscapeHatches.LogProjectImports = _initialLogImports; Traits.Instance.EnableTargetOutputLogging = _initialTargetOutputLogging; if (projectImportsCollector != null) { // Write the build check editorconfig file paths to the log foreach (var filePath in EditorConfigParser.EditorConfigFilePaths) { projectImportsCollector.AddFile(filePath); } EditorConfigParser.ClearEditorConfigFilePaths(); // Write the Directory.Parse.config file paths to the log foreach (var filePath in Evaluation.ParserIgnoreConfiguration.BinlogEmbedPaths) { projectImportsCollector.AddFile(filePath); } Evaluation.ParserIgnoreConfiguration.ClearBinlogEmbedPaths(); projectImportsCollector.Close(); if (CollectProjectImports == ProjectImportsCollectionMode.Embed) { projectImportsCollector.ProcessResult( streamToEmbed => eventArgsWriter.WriteBlob(BinaryLogRecordKind.ProjectImportArchive, streamToEmbed), LogMessage); projectImportsCollector.DeleteArchive(); } projectImportsCollector.FileIOExceptionEvent -= EventSource_AnyEventRaised; projectImportsCollector = null; } // Log additional file paths before closing stream (so they're recorded in the binlog) if (AdditionalFilePaths != null && AdditionalFilePaths.Count > 0 && stream != null) { foreach (var additionalPath in AdditionalFilePaths) { LogMessage("BinLogCopyDestination=" + additionalPath); } } if (stream != null) { // It's hard to determine whether we're at the end of decoding GZipStream // so add an explicit 0 at the end to signify end of file stream.WriteByte((byte)BinaryLogRecordKind.EndOfFile); stream.Flush(); stream.Dispose(); stream = null; } // Copy the binlog file to additional destinations if specified if (AdditionalFilePaths != null && AdditionalFilePaths.Count > 0) { foreach (var additionalPath in AdditionalFilePaths) { try { string directory = Path.GetDirectoryName(additionalPath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } File.Copy(FilePath, additionalPath, overwrite: true); } catch (Exception ex) { // Log the error but don't fail the build // Note: We can't use LogMessage here since the stream is already closed string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword( out _, out _, "ErrorCopyingBinaryLog", FilePath, additionalPath, ex.Message); Console.Error.WriteLine(message); } } } } private void RawEvents_LogDataSliceReceived(BinaryLogRecordKind recordKind, Stream stream) { eventArgsWriter.WriteBlob(recordKind, stream); } private void EventSource_AnyEventRaised(object sender, BuildEventArgs e) { Write(e); } private void Write(BuildEventArgs e) { if (stream != null) { if (projectImportsCollector != null) { CollectImports(e); } if (DoNotWriteToBinlog(e)) { return; } // TODO: think about queuing to avoid contention lock (eventArgsWriter) { eventArgsWriter.Write(e); } } } private static bool DoNotWriteToBinlog(BuildEventArgs e) { return e is GeneratedFileUsedEventArgs; } private void CollectImports(BuildEventArgs e) { if (e is ProjectImportedEventArgs importArgs && importArgs.ImportedProjectFile != null) { projectImportsCollector.AddFile(importArgs.ImportedProjectFile); } else if (e is ProjectStartedEventArgs projectArgs) { projectImportsCollector.AddFile(projectArgs.ProjectFile); } else if (e is MetaprojectGeneratedEventArgs { metaprojectXml: { } } metaprojectArgs) { projectImportsCollector.AddFileFromMemory(metaprojectArgs.ProjectFile, metaprojectArgs.metaprojectXml); } else if (e is ResponseFileUsedEventArgs responseFileArgs && responseFileArgs.ResponseFilePath != null) { projectImportsCollector.AddFile(responseFileArgs.ResponseFilePath); } else if (e is GeneratedFileUsedEventArgs generatedFileUsedEventArgs && generatedFileUsedEventArgs.FilePath != null) { string fullPath = Path.GetFullPath(generatedFileUsedEventArgs.FilePath); projectImportsCollector.AddFileFromMemory(fullPath, generatedFileUsedEventArgs.Content); } } /// /// Processes the parameters given to the logger from MSBuild. /// /// /// private void ProcessParameters(out bool omitInitialInfo) { var parsedParams = ParseParameters(Parameters); omitInitialInfo = parsedParams.OmitInitialInfo; // Only set CollectProjectImports if it was explicitly specified in parameters if (parsedParams.HasProjectImportsParameter) { CollectProjectImports = parsedParams.ProjectImportsCollectionMode; } // Handle the file path - expand wildcards if needed if (parsedParams.LogFilePath == null) { // Either no path was specified, or it contained wildcards // Check if any parameter was a wildcard path var parameters = Parameters.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries); foreach (var parameter in parameters) { if (TryInterpretPathParameter(parameter, out string filePath)) { FilePath = filePath; break; } } } else { FilePath = parsedParams.LogFilePath; } if (FilePath == null) { FilePath = "msbuild.binlog"; } KnownTelemetry.LoggingConfigurationTelemetry.BinaryLoggerUsedDefaultName = FilePath == "msbuild.binlog"; try { FilePath = Path.GetFullPath(FilePath); } catch (Exception e) { string errorCode; string helpKeyword; string message = ResourceUtilities.FormatResourceStringStripCodeAndKeyword(out errorCode, out helpKeyword, "InvalidFileLoggerFile", FilePath, e.Message); throw new LoggerException(message, e, errorCode, helpKeyword); } } private bool TryInterpretPathParameter(string parameter, out string filePath) { return TryInterpretPathParameterCore(parameter, GetUniqueStamp, out filePath); } private string GetUniqueStamp() => (PathParameterExpander ?? ExpandPathParameter)(string.Empty); private static string ExpandPathParameter(string parameters) => $"{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss")}--{EnvironmentUtilities.CurrentProcessId}--{StringUtils.GenerateRandomString(6)}"; /// /// Extracts the file path from binary logger parameters string. /// This is a helper method for processing multiple binlog parameters. /// /// The parameters string (e.g., "output.binlog" or "output.binlog;ProjectImports=None") /// The resolved file path, or "msbuild.binlog" if no path is specified public static string ExtractFilePathFromParameters(string parameters) { const string DefaultBinlogFileName = "msbuild" + BinlogFileExtension; if (string.IsNullOrEmpty(parameters)) { return Path.GetFullPath(DefaultBinlogFileName); } var paramParts = parameters.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries); string filePath = null; foreach (var parameter in paramParts) { if (TryInterpretPathParameterStatic(parameter, out string extractedPath)) { filePath = extractedPath; break; } } if (filePath == null) { filePath = DefaultBinlogFileName; } try { return Path.GetFullPath(filePath); } catch { // If path resolution fails, return the original path return filePath; } } /// /// Attempts to interpret a parameter string as a file path. /// /// The parameter to interpret (e.g., "LogFile=output.binlog" or "output.binlog") /// The extracted file path if the parameter is a path, otherwise the original parameter /// True if the parameter is a valid file path (ends with .binlog or contains wildcards), false otherwise private static bool TryInterpretPathParameterStatic(string parameter, out string filePath) { return TryInterpretPathParameterCore(parameter, () => ExpandPathParameter(string.Empty), out filePath); } /// /// Core logic for interpreting a parameter string as a file path. /// /// The parameter to interpret /// Function to expand wildcard placeholders /// The extracted file path /// True if the parameter is a valid file path private static bool TryInterpretPathParameterCore(string parameter, Func wildcardExpander, out string filePath) { bool hasPathPrefix = parameter.StartsWith(LogFileParameterPrefix, StringComparison.OrdinalIgnoreCase); if (hasPathPrefix) { parameter = parameter.Substring(LogFileParameterPrefix.Length); } parameter = parameter.Trim('"'); bool isWildcard = parameter.Contains("{}"); bool hasProperExtension = parameter.EndsWith(BinlogFileExtension, StringComparison.OrdinalIgnoreCase); filePath = parameter; if (!isWildcard) { return hasProperExtension; } filePath = parameter.Replace("{}", wildcardExpander(), StringComparison.Ordinal); if (!hasProperExtension) { filePath += BinlogFileExtension; } return true; } /// /// Extracts the non-file-path parameters from binary logger parameters string. /// This is used to compare configurations between multiple binlog parameters. /// /// The parameters string (e.g., "output.binlog;ProjectImports=None") /// A normalized string of non-path parameters, or empty string if only path parameters public static string ExtractNonPathParameters(string parameters) { if (string.IsNullOrEmpty(parameters)) { return string.Empty; } var paramParts = parameters.Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries); var nonPathParams = new List(); foreach (var parameter in paramParts) { // Skip file path parameters if (TryInterpretPathParameterStatic(parameter, out _)) { continue; } // This is a configuration parameter (like ProjectImports=None, OmitInitialInfo, etc.) nonPathParams.Add(parameter); } // Sort for consistent comparison nonPathParams.Sort(StringComparer.OrdinalIgnoreCase); return string.Join(";", nonPathParams); } /// /// Result of processing multiple binary logger parameter sets. /// public readonly struct ProcessedBinaryLoggerParameters { /// /// List of distinct parameter sets that need separate logger instances. /// public IReadOnlyList DistinctParameterSets { get; } /// /// If true, all parameter sets have identical configurations (only file paths differ), /// so a single logger can be used with file copying for additional paths. /// public bool AllConfigurationsIdentical { get; } /// /// Additional file paths to copy the binlog to (only valid when AllConfigurationsIdentical is true). /// public IReadOnlyList AdditionalFilePaths { get; } /// /// List of duplicate file paths that were filtered out. /// public IReadOnlyList DuplicateFilePaths { get; } /// /// Initializes a new instance of the struct. /// /// List of distinct parameter sets that need separate logger instances. /// Whether all parameter sets have identical configurations. /// Additional file paths to copy the binlog to. /// List of duplicate file paths that were filtered out. public ProcessedBinaryLoggerParameters( IReadOnlyList distinctParameterSets, bool allConfigurationsIdentical, IReadOnlyList additionalFilePaths, IReadOnlyList duplicateFilePaths) { DistinctParameterSets = distinctParameterSets; AllConfigurationsIdentical = allConfigurationsIdentical; AdditionalFilePaths = additionalFilePaths; DuplicateFilePaths = duplicateFilePaths; } } /// /// Processes multiple binary logger parameter sets and returns distinct paths and configuration info. /// /// Array of parameter strings from command line /// Processed result with distinct parameter sets and configuration info public static ProcessedBinaryLoggerParameters ProcessParameters(string[] binaryLoggerParameters) { var distinctParameterSets = new List(); var additionalFilePaths = new List(); var duplicateFilePaths = new List(); bool allConfigurationsIdentical = true; if (binaryLoggerParameters == null || binaryLoggerParameters.Length == 0) { return new ProcessedBinaryLoggerParameters(distinctParameterSets, allConfigurationsIdentical, additionalFilePaths, duplicateFilePaths); } if (binaryLoggerParameters.Length == 1) { distinctParameterSets.Add(binaryLoggerParameters[0]); return new ProcessedBinaryLoggerParameters(distinctParameterSets, allConfigurationsIdentical, additionalFilePaths, duplicateFilePaths); } string primaryArguments = binaryLoggerParameters[0]; string primaryNonPathParams = ExtractNonPathParameters(primaryArguments); var distinctFilePaths = new HashSet(StringComparer.OrdinalIgnoreCase); var extractedFilePaths = new List(); // Check if all parameter sets have the same non-path configuration for (int i = 0; i < binaryLoggerParameters.Length; i++) { string currentParams = binaryLoggerParameters[i]; string currentNonPathParams = ExtractNonPathParameters(currentParams); string currentFilePath = ExtractFilePathFromParameters(currentParams); // Check if this is a duplicate file path if (distinctFilePaths.Add(currentFilePath)) { if (!string.Equals(primaryNonPathParams, currentNonPathParams, StringComparison.OrdinalIgnoreCase)) { allConfigurationsIdentical = false; } distinctParameterSets.Add(currentParams); extractedFilePaths.Add(currentFilePath); } else { // Track duplicate paths for logging duplicateFilePaths.Add(currentFilePath); } } // If all configurations are identical, compute additional file paths for copying // Use the pre-extracted paths to avoid redundant ExtractFilePathFromParameters calls if (allConfigurationsIdentical && distinctParameterSets.Count > 1) { for (int i = 1; i < extractedFilePaths.Count; i++) { additionalFilePaths.Add(extractedFilePaths[i]); } } return new ProcessedBinaryLoggerParameters(distinctParameterSets, allConfigurationsIdentical, additionalFilePaths, duplicateFilePaths); } } }