From 4c4d5e6511ec6a079a6ef19d7e065089a6d5ff66 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:25:17 -0700 Subject: [PATCH 01/17] Chunking telemetry. --- .../VirtualClient.Common/ProcessDetails.cs | 31 +++++++ .../VirtualClientLoggingExtensions.cs | 89 +++++++++++++++++-- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs b/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs index f113afeb39..78f512a8d7 100644 --- a/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs +++ b/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; namespace VirtualClient.Common { @@ -78,5 +79,35 @@ public TimeSpan? ElapsedTime /// Working Directory. /// public string WorkingDirectory { get; set; } + + /// + /// Returns a clone of the current instance. + /// + /// + /// A clone of the current instance. + /// + public virtual ProcessDetails Clone() + { + ProcessDetails clonedDetails = new ProcessDetails + { + Id = this.Id, + CommandLine = this.CommandLine, + ExitTime = this.ExitTime, + ExitCode = this.ExitCode, + StandardOutput = this.StandardOutput, + StandardError = this.StandardError, + StartTime = this.StartTime, + ToolName = this.ToolName, + WorkingDirectory = this.WorkingDirectory + }; + + // Create a new list to avoid sharing the same collection reference + if (this.Results?.Any() == true) + { + clonedDetails.Results = new List(this.Results); + } + + return clonedDetails; + } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index f85180f850..9f6b973d82 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -52,14 +52,18 @@ public static class VirtualClientLoggingExtensions /// The name to use for the log file when writing to the file system. Default = component 'Scenario' parameter value. /// True if any log files generated should be prefixed with timestamps. Default = true. /// True to request the file be uploaded when a content store is defined. + /// + /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. + /// When false, truncates the standard output/error at maxChars (existing behavior). + /// public static Task LogProcessDetailsAsync( - this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, IEnumerable results = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true) + this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, IEnumerable results = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool enableOutputSplit = false) { component.ThrowIfNull(nameof(component)); process.ThrowIfNull(nameof(process)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); - return LogProcessDetailsAsync(component, process.ToProcessDetails(toolName, results), telemetryContext, logToTelemetry, logToFile, logToTelemetryMaxChars, logFileName, timestamped, upload); + return LogProcessDetailsAsync(component, process.ToProcessDetails(toolName, results), telemetryContext, logToTelemetry, logToFile, logToTelemetryMaxChars, logFileName, timestamped, upload, enableOutputSplit); } /// @@ -84,7 +88,11 @@ public static Task LogProcessDetailsAsync( /// The name to use for the log file when writing to the file system. Default = component 'Scenario' parameter value. /// True if any log files generated should be prefixed with timestamps. Default = true. /// True to request the file be uploaded when a content store is defined. - public static async Task LogProcessDetailsAsync(this VirtualClientComponent component, ProcessDetails processDetails, EventContext telemetryContext, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true) + /// + /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. + /// When false, truncates the standard output/error at maxChars (existing behavior). + /// + public static async Task LogProcessDetailsAsync(this VirtualClientComponent component, ProcessDetails processDetails, EventContext telemetryContext, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool enableOutputSplit = false) { component.ThrowIfNull(nameof(component)); processDetails.ThrowIfNull(nameof(processDetails)); @@ -98,7 +106,7 @@ public static async Task LogProcessDetailsAsync(this VirtualClientComponent comp { try { - component.Logger?.LogProcessDetails(processDetails, component.TypeName, telemetryContext, logToTelemetryMaxChars); + component.Logger?.LogProcessDetails(processDetails, component.TypeName, telemetryContext, logToTelemetryMaxChars, enableOutputSplit: enableOutputSplit); } catch (Exception exc) { @@ -150,13 +158,19 @@ public static async Task LogProcessDetailsAsync(this VirtualClientComponent comp /// without risking data loss during upload because the message exceeds thresholds. Default = 125,000 chars. In relativity /// there are about 3000 characters in an average single-spaced page of text. /// - internal static void LogProcessDetails(this ILogger logger, ProcessDetails processDetails, string componentType, EventContext telemetryContext, int logToTelemetryMaxChars = 125000) + /// + /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. + /// When false, truncates the standard output/error at maxChars (existing behavior). + /// + internal static void LogProcessDetails(this ILogger logger, ProcessDetails processDetails, string componentType, EventContext telemetryContext, int logToTelemetryMaxChars = 125000, bool enableOutputSplit = false) { logger.ThrowIfNull(nameof(logger)); componentType.ThrowIfNullOrWhiteSpace(nameof(componentType)); processDetails.ThrowIfNull(nameof(processDetails)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); + telemetryContext.AddContext(nameof(enableOutputSplit), enableOutputSplit); + try { // Obscure sensitive data in the command line @@ -174,10 +188,45 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce !string.IsNullOrWhiteSpace(processDetails.ToolName) ? $"{componentType}.{processDetails.ToolName}" : componentType, string.Empty); - logger.LogMessage( - $"{eventNamePrefix}.ProcessDetails", - LogLevel.Information, - telemetryContext.Clone().AddProcessDetails(processDetails, maxChars: logToTelemetryMaxChars)); + if (enableOutputSplit) + { + // Handle splitting standard output and error if enabled and necessary + List standardOutputChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardOutput, logToTelemetryMaxChars); + List standardErrorChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardError, logToTelemetryMaxChars); + + for (int i = 0; i < standardOutputChunks.Count; i++) + { + ProcessDetails chunkedProcess = processDetails.Clone(); + chunkedProcess.StandardOutput = standardOutputChunks[i]; + chunkedProcess.StandardError = null; // Only include standard error in one of the events (to avoid duplication). + EventContext context = telemetryContext.Clone() + .AddContext("standardOutputChunkPart", i + 1) + .AddProcessDetails(chunkedProcess, maxChars: logToTelemetryMaxChars); + + logger.LogMessage($"{eventNamePrefix}.ProcessDetails", LogLevel.Information, context); + + } + + for (int j = 0; j < standardErrorChunks.Count; j++) + { + ProcessDetails chunkedProcess = processDetails.Clone(); + chunkedProcess.StandardOutput = null; // Only include standard output in one of the events (to avoid duplication). + chunkedProcess.StandardError = standardErrorChunks[j]; + + EventContext context = telemetryContext.Clone() + .AddContext("standardErrorChunkPart", j + 1) + .AddProcessDetails(chunkedProcess, maxChars: logToTelemetryMaxChars); + + logger.LogMessage($"{eventNamePrefix}.ProcessDetails", LogLevel.Information, context); + } + } + else + { + logger.LogMessage( + $"{eventNamePrefix}.ProcessDetails", + LogLevel.Information, + telemetryContext.Clone().AddProcessDetails(processDetails, maxChars: logToTelemetryMaxChars)); + } if (processDetails.Results?.Any() == true) { @@ -186,6 +235,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce LogLevel.Information, telemetryContext.Clone().AddProcessResults(processDetails, maxChars: logToTelemetryMaxChars)); } + } catch { @@ -406,5 +456,26 @@ private static string GetSafeFileName(string fileName, bool timestamped = true) return effectiveLogFileName.ToLowerInvariant(); } + + /// + /// Splits a given string into a list of substrings, each with a maximum specified length. + /// Useful for processing or transmitting large strings in manageable chunks. + /// + /// The original string to be split. If null, it will be treated as an empty string. + /// The maximum length of each chunk. Defaults to 125,000 characters. + /// A list of substrings, each with a length up to the specified chunk size. + private static List SplitString(string inputString, int chunkSize = 125000) + { + string finalString = inputString ?? string.Empty; + + var result = new List(); + for (int i = 0; i < finalString.Length; i += chunkSize) + { + int length = Math.Min(chunkSize, finalString.Length - i); + result.Add(finalString.Substring(i, length)); + } + + return result; + } } } From 698a7c7a5a1f281839a7c89b5bbec1a9a8a17a93 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:13:48 -0700 Subject: [PATCH 02/17] ensure no splitting when it doesn't exceed maxChar --- .../VirtualClient.Core/VirtualClientLoggingExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index 9f6b973d82..cd63d406ef 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -188,7 +188,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce !string.IsNullOrWhiteSpace(processDetails.ToolName) ? $"{componentType}.{processDetails.ToolName}" : componentType, string.Empty); - if (enableOutputSplit) + if (enableOutputSplit && (processDetails.StandardOutput.Length + processDetails.StandardError.Length > logToTelemetryMaxChars)) { // Handle splitting standard output and error if enabled and necessary List standardOutputChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardOutput, logToTelemetryMaxChars); From 10e92ef243b1ead2b5ec0f9cb667abe1bffad201 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:19:50 -0700 Subject: [PATCH 03/17] Process Details Clone --- .../ProcessExtensionsTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs index cc97fa5f61..dd73ac9896 100644 --- a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs @@ -66,5 +66,35 @@ public void WaitForResponseAsyncExtensionThrowsIfTheExpectedResponseIsNotReceive Assert.ThrowsAsync( () => this.mockProcess.WaitForResponseAsync("NeverGonnaShow", CancellationToken.None, timeout: TimeSpan.Zero)); } + + [Test] + public void ProcessDetailsCloneCreatesANewInstanceWithTheSameValues() + { + ProcessDetails process1 = new ProcessDetails + { + Id = -1, + CommandLine = Guid.NewGuid().ToString(), + ExitTime = DateTime.UtcNow, + ExitCode = -2, + StandardOutput = Guid.NewGuid().ToString(), + StandardError = Guid.NewGuid().ToString(), + StartTime = DateTime.MinValue, + ToolName = Guid.NewGuid().ToString(), + WorkingDirectory = Guid.NewGuid().ToString() + }; + + ProcessDetails process2 = process1.Clone(); + + Assert.AreNotEqual(process1, process2); + Assert.AreEqual(process1.Id, process2.Id); + Assert.AreEqual(process1.CommandLine, process2.CommandLine); + Assert.AreEqual(process1.ExitTime, process2.ExitTime); + Assert.AreEqual(process1.ExitCode, process2.ExitCode); + Assert.AreEqual(process1.StandardOutput, process2.StandardOutput); + Assert.AreEqual(process1.StandardError, process2.StandardError); + Assert.AreEqual(process1.StartTime, process2.StartTime); + Assert.AreEqual(process1.ToolName, process2.ToolName); + Assert.AreEqual(process1.WorkingDirectory, process2.WorkingDirectory); + } } } From 43b74f3648351252fc908373621a7175c306f41e Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:15:08 -0700 Subject: [PATCH 04/17] Adding test cases. --- .../VirtualClientLoggingExtensionsTests.cs | 324 +++++++++++++++++- .../VirtualClientLoggingExtensions.cs | 5 +- 2 files changed, 320 insertions(+), 9 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs index 336ffa2ee1..113ec4a06c 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs @@ -1016,7 +1016,7 @@ public async Task LogProcessDetailsAsyncExtensionEmitsTheExpectedProcessInformat StandardOutput = expectedStandardOutput != null ? new Common.ConcurrentBuffer(new StringBuilder(expectedStandardOutput)) : null, StandardError = expectedStandardError != null ? new Common.ConcurrentBuffer(new StringBuilder(expectedStandardError)) : null }; - + string expectedResults = "Any results output by the process."; bool expectedProcessDetailsCaptured = false; bool expectedProcessResultsCaptured = false; @@ -1171,7 +1171,7 @@ public async Task LogProcessDetailsExtensionEmitsTheExpectedProcessInformationAs { Assert.AreEqual(LogLevel.Information, level, $"Log level not matched"); Assert.IsInstanceOf(state); - + if (eventInfo.Name == $"{nameof(TestExecutor)}.{expectedToolset}.ProcessDetails") { @@ -1518,7 +1518,7 @@ public async Task LogProcessDetailsExtensionWritesTheExpectedProcessInformationT expectedLogFileWritten = true; }); - + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), expectedToolset, logToTelemetry: false, logToFile: true) .ConfigureAwait(false); @@ -1578,7 +1578,7 @@ public async Task LogProcessDetailsToFileSystemAsyncExtensionWritesTheExpectedPr expectedLogFileWritten = true; }); - + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), results: new List { expectedResults }, logToTelemetry: false, logToFile: true) .ConfigureAwait(false); @@ -1590,7 +1590,7 @@ public async Task LogProcessDetailsExtensionCreatesTheLogDirectoryIfItDoesNotExi { InMemoryProcess process = new InMemoryProcess(); TestExecutor component = new TestExecutor(this.mockFixture); - + string expectedLogPath = this.mockFixture.GetLogsPath(nameof(TestExecutor).ToLowerInvariant()); await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: false, logToFile: true) @@ -2101,7 +2101,7 @@ public void LogSystemEventExtensionLogsTheExpectedEvents(LogLevel expectedEventL }; this.mockLogger.Object.LogSystemEvent( - expectedEventType, + expectedEventType, expectedEventSource, expectedEventId, expectedEventLevel, @@ -2318,6 +2318,318 @@ public void ObscureSecretsExtensionDoesNotModifyDataInTheOriginalParameters() } } + [Test] + public async Task LogProcessDetailsDoesNotSplitOutputWhenEnableOutputSplitIsFalse() + { + int maxCharlimit = 125000; + + // Scenario: + // When enableOutputSplit is false, output should NOT be split even if it exceeds maxChars. + // The truncation behavior should apply instead. + + string largeOutput = new string('A', maxCharlimit * 2); + string largeError = new string('B', maxCharlimit * 2); + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe", + Arguments = "--any=arguments" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(largeOutput)), + StandardError = new Common.ConcurrentBuffer(new StringBuilder(largeError)) + }; + + this.mockLogger + .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Callback>((level, eventId, state, exc, formatter) => + { + if (eventId.Name.Contains("ProcessDetails")) + { + // Verify no chunk context is added + Assert.IsFalse(state.Properties.ContainsKey("standardOutputChunkPart")); + Assert.IsFalse(state.Properties.ContainsKey("standardErrorChunkPart")); + } + }); + + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: false).ConfigureAwait(false); + + // Should only log ONE event (no splitting) + this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); + } + + [Test] + public async Task LogProcessDetailsDoesNotSplitWhenCombinedOutputIsBelowMaxChars() + { + // Scenario: + // When enableOutputSplit is true BUT combined output is below maxChars, no splitting should occur. + + int maxCharlimit = 125000; + string smallOutput = new string('A', maxCharlimit /3 ); + string smallError = new string('B', maxCharlimit / 5); + // Total = 100,000 which is below 125,000 + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe", + Arguments = "--any=arguments" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(smallOutput)), + StandardError = new Common.ConcurrentBuffer(new StringBuilder(smallError)) + }; + + this.mockLogger + .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Callback>((level, eventId, state, exc, formatter) => + { + if (eventId.Name.Contains("ProcessDetails")) + { + // Verify no chunk context is added + Assert.IsFalse(state.Properties.ContainsKey("standardOutputChunkPart")); + Assert.IsFalse(state.Properties.ContainsKey("standardErrorChunkPart")); + } + }); + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: false).ConfigureAwait(false); + + // Should only log ONE event (no splitting needed) + this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); + } + + [Test] + public async Task LogProcessDetailsSplitsOutputOnlyWhenBothConditionsAreMet() + { + // Scenario: + // Splitting should occur ONLY when enableOutputSplit=true AND combined output > maxChars + + int maxCharlimit = 125000; + string largeOutput = new string('A', maxCharlimit * 2 ); // Requires 2 chunks at 125K each + string largeError = new string('B', maxCharlimit * 5); // Requires 5 chunk + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe", + Arguments = "--any=arguments" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(largeOutput)), + StandardError = new Common.ConcurrentBuffer(new StringBuilder(largeError)) + }; + + int standardOutputEventCount = 0; + int standardErrorEventCount = 0; + + this.mockLogger + .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Callback>((level, eventId, state, exc, formatter) => + { + if (eventId.Name.Contains("ProcessDetails") && state is EventContext context) + { + if (context.Properties.ContainsKey("standardOutputChunkPart")) + { + standardOutputEventCount++; + } + else if (context.Properties.ContainsKey("standardErrorChunkPart")) + { + standardErrorEventCount++; + } + } + }); + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + + // Verify splitting occurred + Assert.AreEqual(2, standardOutputEventCount, "Should create 2 events for standard output chunks"); + Assert.AreEqual(5, standardErrorEventCount, "Should create 1 event for standard error chunk"); + + this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Exactly(7)); + } + + [Test] + public async Task LogProcessDetailsSplitCapturesAllDataWithoutTruncation() + { + // Scenario: + // When splitting occurs, verify ALL data is captured without any truncation. + + int maxCharlimit = 125000; + string expectedOutput = new string('X', maxCharlimit * 3); + string expectedError = new string('Y', maxCharlimit * 9); + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe", + Arguments = "--any=arguments" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(expectedOutput)), + StandardError = new Common.ConcurrentBuffer(new StringBuilder(expectedError)) + }; + + StringBuilder capturedStandardOutput = new StringBuilder(); + StringBuilder capturedStandardError = new StringBuilder(); + + this.mockLogger + .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Callback>((level, eventId, state, exc, formatter) => + { + if (eventId.Name.Contains("ProcessDetails") && state is EventContext context) + { + if (context.Properties.TryGetValue("process", out object processContext)) + { + var processObj = JObject.FromObject(processContext); + string stdOut = processObj["standardOutput"]?.ToString(); + string stdErr = processObj["standardError"]?.ToString(); + + if (!string.IsNullOrEmpty(stdOut)) + { + capturedStandardOutput.Append(stdOut); + } + + if (!string.IsNullOrEmpty(stdErr)) + { + capturedStandardError.Append(stdErr); + } + } + } + }); + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + + // Verify NO truncation occurred + Assert.AreEqual(expectedOutput.Length, capturedStandardOutput.Length, "All standard output should be captured"); + Assert.AreEqual(expectedError.Length, capturedStandardError.Length, "All standard error should be captured"); + Assert.AreEqual(expectedOutput, capturedStandardOutput.ToString(), "Standard output content should match exactly"); + Assert.AreEqual(expectedError, capturedStandardError.ToString(), "Standard error content should match exactly"); + this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Exactly(12)); + } + + [Test] + public async Task LogProcessDetailsSplitIncludesChunkPartNumberInContext() + { + // Scenario: + // Verify that split events include chunk part numbers for tracking. + + int maxCharlimit = 125000; + string largeOutput = new string('A', 250000); // Will create 2 chunks + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(largeOutput)) + }; + + List chunkParts = new List(); + + this.mockLogger + .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Callback>((level, eventId, state, exc, formatter) => + { + if (state is EventContext context && context.Properties.ContainsKey("standardOutputChunkPart")) + { + chunkParts.Add(Convert.ToInt32(context.Properties["standardOutputChunkPart"])); + } + }); + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + + // Verify chunk parts are numbered sequentially starting from 1 + Assert.AreEqual(2, chunkParts.Count, "Should have 2 chunk parts"); + Assert.AreEqual(1, chunkParts[0], "First chunk should be numbered 1"); + Assert.AreEqual(2, chunkParts[1], "Second chunk should be numbered 2"); + this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Exactly(2)); + } + + [Test] + public async Task LogProcessDetailsSplitStandardOutputAndErrorSeparately() + { + // Scenario: + // When standard output and standard error equals maxChars exactly, split output and error separately. + int maxCharlimit = 125000; + string output = new string('A', maxCharlimit); // Exactly at the limit + string error = new string('B', maxCharlimit); // Exactly at the limit + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(output)), + StandardError = new Common.ConcurrentBuffer(new StringBuilder(output)) + }; + + this.mockLogger.Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)); + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + + this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Exactly(2)); + } + + [Test] + public async Task LogProcessDetailsSplitHandlesBoundaryConditionOneCharOverMaxChars() + { + // Scenario: + // When combined output is just 1 char over maxChars, splitting should occur. + int maxCharlimit = 125000; + string output = new string('A', maxCharlimit + 1); // One character over the limit + + InMemoryProcess process = new InMemoryProcess + { + ExitCode = 0, + StartInfo = new ProcessStartInfo + { + FileName = "AnyCommand.exe" + }, + StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(output)) + }; + + int eventCount = 0; + + this.mockLogger + .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Callback>((level, eventId, state, exc, formatter) => + { + if (eventId.Name.Contains("ProcessDetails")) + { + eventCount++; + } + }); + + TestExecutor component = new TestExecutor(this.mockFixture); + component.Logger = this.mockLogger.Object; + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + + // Should split when even 1 char over the limit + Assert.AreEqual(2, eventCount, "Should split when output exceeds maxChars by even 1 character"); + } + private static Tuple GetAccessTokenPair() { // Note: diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index cd63d406ef..f52aa27ba7 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -168,9 +168,8 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce componentType.ThrowIfNullOrWhiteSpace(nameof(componentType)); processDetails.ThrowIfNull(nameof(processDetails)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); - telemetryContext.AddContext(nameof(enableOutputSplit), enableOutputSplit); - + try { // Obscure sensitive data in the command line @@ -189,7 +188,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce string.Empty); if (enableOutputSplit && (processDetails.StandardOutput.Length + processDetails.StandardError.Length > logToTelemetryMaxChars)) - { + { // Handle splitting standard output and error if enabled and necessary List standardOutputChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardOutput, logToTelemetryMaxChars); List standardErrorChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardError, logToTelemetryMaxChars); From e6be3ca5bebdb799c942df53a8a5fd997ea4d960 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:45:46 -0700 Subject: [PATCH 05/17] Cleaning up --- VERSION | 2 +- .../VirtualClientLoggingExtensionsTests.cs | 28 +++++++++---------- .../VirtualClientLoggingExtensions.cs | 18 ++++++------ 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/VERSION b/VERSION index 1fbecce930..e3baf30b73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.34 \ No newline at end of file +2.1.35 \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs index 113ec4a06c..65a6d257d3 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs @@ -2319,7 +2319,7 @@ public void ObscureSecretsExtensionDoesNotModifyDataInTheOriginalParameters() } [Test] - public async Task LogProcessDetailsDoesNotSplitOutputWhenEnableOutputSplitIsFalse() + public async Task LogProcessDetailsDoesNotSplitTelemetryOutputWhenEnableOutputSplitIsFalse() { int maxCharlimit = 125000; @@ -2357,14 +2357,14 @@ public async Task LogProcessDetailsDoesNotSplitOutputWhenEnableOutputSplitIsFals TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: false).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: false).ConfigureAwait(false); // Should only log ONE event (no splitting) this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); } [Test] - public async Task LogProcessDetailsDoesNotSplitWhenCombinedOutputIsBelowMaxChars() + public async Task LogProcessDetailsDoesNotSplitTelemetryWhenCombinedOutputIsBelowMaxChars() { // Scenario: // When enableOutputSplit is true BUT combined output is below maxChars, no splitting should occur. @@ -2400,14 +2400,14 @@ public async Task LogProcessDetailsDoesNotSplitWhenCombinedOutputIsBelowMaxChars TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: false).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: false).ConfigureAwait(false); // Should only log ONE event (no splitting needed) this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); } [Test] - public async Task LogProcessDetailsSplitsOutputOnlyWhenBothConditionsAreMet() + public async Task LogProcessDetailsSplitsTelemetryOutputOnlyWhenBothConditionsAreMet() { // Scenario: // Splitting should occur ONLY when enableOutputSplit=true AND combined output > maxChars @@ -2450,7 +2450,7 @@ public async Task LogProcessDetailsSplitsOutputOnlyWhenBothConditionsAreMet() TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); // Verify splitting occurred Assert.AreEqual(2, standardOutputEventCount, "Should create 2 events for standard output chunks"); @@ -2460,7 +2460,7 @@ public async Task LogProcessDetailsSplitsOutputOnlyWhenBothConditionsAreMet() } [Test] - public async Task LogProcessDetailsSplitCapturesAllDataWithoutTruncation() + public async Task LogProcessDetailsSplitTelemetryCapturesAllDataWithoutTruncation() { // Scenario: // When splitting occurs, verify ALL data is captured without any truncation. @@ -2511,7 +2511,7 @@ public async Task LogProcessDetailsSplitCapturesAllDataWithoutTruncation() TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); // Verify NO truncation occurred Assert.AreEqual(expectedOutput.Length, capturedStandardOutput.Length, "All standard output should be captured"); @@ -2522,7 +2522,7 @@ public async Task LogProcessDetailsSplitCapturesAllDataWithoutTruncation() } [Test] - public async Task LogProcessDetailsSplitIncludesChunkPartNumberInContext() + public async Task LogProcessDetailsSplitTelemetryIncludesChunkPartNumberInContext() { // Scenario: // Verify that split events include chunk part numbers for tracking. @@ -2554,7 +2554,7 @@ public async Task LogProcessDetailsSplitIncludesChunkPartNumberInContext() TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); // Verify chunk parts are numbered sequentially starting from 1 Assert.AreEqual(2, chunkParts.Count, "Should have 2 chunk parts"); @@ -2564,7 +2564,7 @@ public async Task LogProcessDetailsSplitIncludesChunkPartNumberInContext() } [Test] - public async Task LogProcessDetailsSplitStandardOutputAndErrorSeparately() + public async Task LogProcessDetailsSplitsStandardOutputAndErrorSeparately() { // Scenario: // When standard output and standard error equals maxChars exactly, split output and error separately. @@ -2587,13 +2587,13 @@ public async Task LogProcessDetailsSplitStandardOutputAndErrorSeparately() TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Exactly(2)); } [Test] - public async Task LogProcessDetailsSplitHandlesBoundaryConditionOneCharOverMaxChars() + public async Task LogProcessDetailsSplitTelemetryHandlesBoundaryConditionOneCharOverMaxChars() { // Scenario: // When combined output is just 1 char over maxChars, splitting should occur. @@ -2624,7 +2624,7 @@ public async Task LogProcessDetailsSplitHandlesBoundaryConditionOneCharOverMaxCh TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, enableOutputSplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); // Should split when even 1 char over the limit Assert.AreEqual(2, eventCount, "Should split when output exceeds maxChars by even 1 character"); diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index f52aa27ba7..2fd6f043ba 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -52,18 +52,18 @@ public static class VirtualClientLoggingExtensions /// The name to use for the log file when writing to the file system. Default = component 'Scenario' parameter value. /// True if any log files generated should be prefixed with timestamps. Default = true. /// True to request the file be uploaded when a content store is defined. - /// - /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. + /// + /// When true, splits the standard output and standard error into multiple telemetry events if they exceed maxChars. /// When false, truncates the standard output/error at maxChars (existing behavior). /// public static Task LogProcessDetailsAsync( - this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, IEnumerable results = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool enableOutputSplit = false) + this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, IEnumerable results = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool telemetrySplit = false) { component.ThrowIfNull(nameof(component)); process.ThrowIfNull(nameof(process)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); - return LogProcessDetailsAsync(component, process.ToProcessDetails(toolName, results), telemetryContext, logToTelemetry, logToFile, logToTelemetryMaxChars, logFileName, timestamped, upload, enableOutputSplit); + return LogProcessDetailsAsync(component, process.ToProcessDetails(toolName, results), telemetryContext, logToTelemetry, logToFile, logToTelemetryMaxChars, logFileName, timestamped, upload, telemetrySplit); } /// @@ -106,7 +106,7 @@ public static async Task LogProcessDetailsAsync(this VirtualClientComponent comp { try { - component.Logger?.LogProcessDetails(processDetails, component.TypeName, telemetryContext, logToTelemetryMaxChars, enableOutputSplit: enableOutputSplit); + component.Logger?.LogProcessDetails(processDetails, component.TypeName, telemetryContext, logToTelemetryMaxChars, telemetrySplit: enableOutputSplit); } catch (Exception exc) { @@ -158,17 +158,17 @@ public static async Task LogProcessDetailsAsync(this VirtualClientComponent comp /// without risking data loss during upload because the message exceeds thresholds. Default = 125,000 chars. In relativity /// there are about 3000 characters in an average single-spaced page of text. /// - /// + /// /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. /// When false, truncates the standard output/error at maxChars (existing behavior). /// - internal static void LogProcessDetails(this ILogger logger, ProcessDetails processDetails, string componentType, EventContext telemetryContext, int logToTelemetryMaxChars = 125000, bool enableOutputSplit = false) + internal static void LogProcessDetails(this ILogger logger, ProcessDetails processDetails, string componentType, EventContext telemetryContext, int logToTelemetryMaxChars = 125000, bool telemetrySplit = false) { logger.ThrowIfNull(nameof(logger)); componentType.ThrowIfNullOrWhiteSpace(nameof(componentType)); processDetails.ThrowIfNull(nameof(processDetails)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); - telemetryContext.AddContext(nameof(enableOutputSplit), enableOutputSplit); + telemetryContext.AddContext(nameof(telemetrySplit), telemetrySplit); try { @@ -187,7 +187,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce !string.IsNullOrWhiteSpace(processDetails.ToolName) ? $"{componentType}.{processDetails.ToolName}" : componentType, string.Empty); - if (enableOutputSplit && (processDetails.StandardOutput.Length + processDetails.StandardError.Length > logToTelemetryMaxChars)) + if (telemetrySplit && (processDetails.StandardOutput.Length + processDetails.StandardError.Length > logToTelemetryMaxChars)) { // Handle splitting standard output and error if enabled and necessary List standardOutputChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardOutput, logToTelemetryMaxChars); From 1c36d9f003e4add4723b0ea4f948f15ea30e9066 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Wed, 22 Oct 2025 22:35:36 -0700 Subject: [PATCH 06/17] foo --- .../VirtualClient.Monitors/ExecuteCommandMonitor.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs b/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs index 4680120ba0..9aec1a5832 100644 --- a/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs +++ b/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs @@ -115,6 +115,17 @@ public string MonitorEventType } } + /// + /// Parameter defines Telemetry Splitting (true/false). Default = false. + /// + public bool TelemetrySplit + { + get + { + return this.Parameters.GetValue(nameof(this.TelemetrySplit), false); + } + } + /// /// A policy that defines how the component will retry when it experiences transient issues. /// @@ -252,7 +263,7 @@ protected async Task ExecuteCommandAsync(EventContext telemetryContext, Cancella if (!cancellationToken.IsCancellationRequested) { - await this.LogProcessDetailsAsync(process, telemetryContext, toolName: this.LogFolderName, logFileName: this.LogFileName); + await this.LogProcessDetailsAsync(process, telemetryContext, toolName: this.LogFolderName, logFileName: this.LogFileName, telemetrySplit: this.TelemetrySplit); process.ThrowIfMonitorFailed(); this.CaptureEventInformation(process, telemetryContext); } From 7a0a94ffce951c0b1638cf582632197d9f1bebc2 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:30:39 -0800 Subject: [PATCH 07/17] fix process details --- src/VirtualClient/VirtualClient.Common/ProcessDetails.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs b/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs index dea54a5d1f..c63985ca8a 100644 --- a/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs +++ b/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs @@ -104,7 +104,7 @@ public virtual ProcessDetails Clone() // Create a new list to avoid sharing the same collection reference if (this.Results?.Any() == true) { - clonedDetails.Results = new List(this.Results); + clonedDetails.Results = new List>(this.Results); } return clonedDetails; From f8c75d51ab15f17e061ef76ae27c746da6c8715e Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:37:59 -0800 Subject: [PATCH 08/17] merge fix --- .../VirtualClientLoggingExtensions.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index 122db09be8..dc2b506f05 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -46,14 +46,14 @@ public static class VirtualClientLoggingExtensions /// The name to use for the log file when writing to the file system. Default = component 'Scenario' parameter value. /// True if any log files generated should be prefixed with timestamps. Default = true. /// True to request the file be uploaded when a content store is defined. - /// - /// One or more sets of results generated by the process. Each key represents an identifier for the file (e.g. the file path or name) - /// and the value should be the content. - /// /// /// When true, splits the standard output and standard error into multiple telemetry events if they exceed maxChars. /// When false, truncates the standard output/error at maxChars (existing behavior). /// + /// + /// One or more sets of results generated by the process. Each key represents an identifier for the file (e.g. the file path or name) + /// and the value should be the content. + /// public static Task LogProcessDetailsAsync( this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool telemetrySplit = false, params KeyValuePair[] results) { @@ -227,10 +227,13 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce if (processDetails.Results?.Any() == true) { - logger.LogMessage( - $"{eventNamePrefix}.ProcessResults", - LogLevel.Information, - telemetryContext.Clone().AddProcessResults(processDetails, maxChars: logToTelemetryMaxChars)); + foreach (var result in processDetails.Results) + { + logger.LogMessage( + $"{eventNamePrefix}.ProcessResults", + LogLevel.Information, + telemetryContext.Clone().AddProcessResults(processDetails, result, maxChars: logToTelemetryMaxChars)); + } } } catch From 98b35be6ce1054dfcfa1a87a6c70a381f9804aaf Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:14:41 -0800 Subject: [PATCH 09/17] upversion to 2.1.48 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c70f42b234..3faeb7bb98 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.47 \ No newline at end of file +2.1.48 \ No newline at end of file From f24fd20c340d38903f8ee7f926e04ee2b1c10d8b Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:55:16 -0800 Subject: [PATCH 10/17] making split telemetry default behavior. --- .../VirtualClientLoggingExtensionsTests.cs | 14 ++++----- .../VirtualClientLoggingExtensions.cs | 29 +++++-------------- .../ExecuteCommandMonitor.cs | 17 ++--------- 3 files changed, 18 insertions(+), 42 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs index dfd7c9aac1..bcbfc659bc 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs @@ -2358,7 +2358,7 @@ public async Task LogProcessDetailsDoesNotSplitTelemetryOutputWhenEnableOutputSp TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: false).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); // Should only log ONE event (no splitting) this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); @@ -2401,7 +2401,7 @@ public async Task LogProcessDetailsDoesNotSplitTelemetryWhenCombinedOutputIsBelo TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: false).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); // Should only log ONE event (no splitting needed) this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); @@ -2451,7 +2451,7 @@ public async Task LogProcessDetailsSplitsTelemetryOutputOnlyWhenBothConditionsAr TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); // Verify splitting occurred Assert.AreEqual(2, standardOutputEventCount, "Should create 2 events for standard output chunks"); @@ -2512,7 +2512,7 @@ public async Task LogProcessDetailsSplitTelemetryCapturesAllDataWithoutTruncatio TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); // Verify NO truncation occurred Assert.AreEqual(expectedOutput.Length, capturedStandardOutput.Length, "All standard output should be captured"); @@ -2555,7 +2555,7 @@ public async Task LogProcessDetailsSplitTelemetryIncludesChunkPartNumberInContex TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); // Verify chunk parts are numbered sequentially starting from 1 Assert.AreEqual(2, chunkParts.Count, "Should have 2 chunk parts"); @@ -2588,7 +2588,7 @@ public async Task LogProcessDetailsSplitsStandardOutputAndErrorSeparately() TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Exactly(2)); } @@ -2625,7 +2625,7 @@ public async Task LogProcessDetailsSplitTelemetryHandlesBoundaryConditionOneChar TestExecutor component = new TestExecutor(this.mockFixture); component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit, telemetrySplit: true).ConfigureAwait(false); + await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); // Should split when even 1 char over the limit Assert.AreEqual(2, eventCount, "Should split when output exceeds maxChars by even 1 character"); diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index dc2b506f05..838e456974 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -46,22 +46,18 @@ public static class VirtualClientLoggingExtensions /// The name to use for the log file when writing to the file system. Default = component 'Scenario' parameter value. /// True if any log files generated should be prefixed with timestamps. Default = true. /// True to request the file be uploaded when a content store is defined. - /// - /// When true, splits the standard output and standard error into multiple telemetry events if they exceed maxChars. - /// When false, truncates the standard output/error at maxChars (existing behavior). - /// /// /// One or more sets of results generated by the process. Each key represents an identifier for the file (e.g. the file path or name) /// and the value should be the content. /// public static Task LogProcessDetailsAsync( - this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool telemetrySplit = false, params KeyValuePair[] results) + this VirtualClientComponent component, IProcessProxy process, EventContext telemetryContext, string toolName = null, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, params KeyValuePair[] results) { component.ThrowIfNull(nameof(component)); process.ThrowIfNull(nameof(process)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); - return LogProcessDetailsAsync(component, process.ToProcessDetails(toolName, results), telemetryContext, logToTelemetry, logToFile, logToTelemetryMaxChars, logFileName, timestamped, upload, telemetrySplit); + return LogProcessDetailsAsync(component, process.ToProcessDetails(toolName, results), telemetryContext, logToTelemetry, logToFile, logToTelemetryMaxChars, logFileName, timestamped, upload); } /// @@ -86,11 +82,7 @@ public static Task LogProcessDetailsAsync( /// The name to use for the log file when writing to the file system. Default = component 'Scenario' parameter value. /// True if any log files generated should be prefixed with timestamps. Default = true. /// True to request the file be uploaded when a content store is defined. - /// - /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. - /// When false, truncates the standard output/error at maxChars (existing behavior). - /// - public static async Task LogProcessDetailsAsync(this VirtualClientComponent component, ProcessDetails processDetails, EventContext telemetryContext, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true, bool enableOutputSplit = false) + public static async Task LogProcessDetailsAsync(this VirtualClientComponent component, ProcessDetails processDetails, EventContext telemetryContext, bool logToTelemetry = true, bool logToFile = true, int logToTelemetryMaxChars = 125000, string logFileName = null, bool timestamped = true, bool upload = true) { component.ThrowIfNull(nameof(component)); processDetails.ThrowIfNull(nameof(processDetails)); @@ -104,7 +96,7 @@ public static async Task LogProcessDetailsAsync(this VirtualClientComponent comp { try { - component.Logger?.LogProcessDetails(processDetails, component.TypeName, telemetryContext, logToTelemetryMaxChars, telemetrySplit: enableOutputSplit); + component.Logger?.LogProcessDetails(processDetails, component.TypeName, telemetryContext, logToTelemetryMaxChars); } catch (Exception exc) { @@ -156,17 +148,12 @@ public static async Task LogProcessDetailsAsync(this VirtualClientComponent comp /// without risking data loss during upload because the message exceeds thresholds. Default = 125,000 chars. In relativity /// there are about 3000 characters in an average single-spaced page of text. /// - /// - /// When true, splits the standard output and error into multiple telemetry events if they exceed maxChars. - /// When false, truncates the standard output/error at maxChars (existing behavior). - /// - internal static void LogProcessDetails(this ILogger logger, ProcessDetails processDetails, string componentType, EventContext telemetryContext, int logToTelemetryMaxChars = 125000, bool telemetrySplit = false) + internal static void LogProcessDetails(this ILogger logger, ProcessDetails processDetails, string componentType, EventContext telemetryContext, int logToTelemetryMaxChars = 125000) { logger.ThrowIfNull(nameof(logger)); componentType.ThrowIfNullOrWhiteSpace(nameof(componentType)); processDetails.ThrowIfNull(nameof(processDetails)); telemetryContext.ThrowIfNull(nameof(telemetryContext)); - telemetryContext.AddContext(nameof(telemetrySplit), telemetrySplit); try { @@ -185,7 +172,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce !string.IsNullOrWhiteSpace(processDetails.ToolName) ? $"{componentType}.{processDetails.ToolName}" : componentType, string.Empty); - if (telemetrySplit && (processDetails.StandardOutput.Length + processDetails.StandardError.Length > logToTelemetryMaxChars)) + if (processDetails.StandardOutput.Length + processDetails.StandardError.Length > logToTelemetryMaxChars) { // Handle splitting standard output and error if enabled and necessary List standardOutputChunks = VirtualClientLoggingExtensions.SplitString(processDetails.StandardOutput, logToTelemetryMaxChars); @@ -197,7 +184,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce chunkedProcess.StandardOutput = standardOutputChunks[i]; chunkedProcess.StandardError = null; // Only include standard error in one of the events (to avoid duplication). EventContext context = telemetryContext.Clone() - .AddContext("standardOutputChunkPart", i + 1) + .AddContext("standardOutputPart", i + 1) .AddProcessDetails(chunkedProcess, maxChars: logToTelemetryMaxChars); logger.LogMessage($"{eventNamePrefix}.ProcessDetails", LogLevel.Information, context); @@ -211,7 +198,7 @@ internal static void LogProcessDetails(this ILogger logger, ProcessDetails proce chunkedProcess.StandardError = standardErrorChunks[j]; EventContext context = telemetryContext.Clone() - .AddContext("standardErrorChunkPart", j + 1) + .AddContext("standardErrorPart", j + 1) .AddProcessDetails(chunkedProcess, maxChars: logToTelemetryMaxChars); logger.LogMessage($"{eventNamePrefix}.ProcessDetails", LogLevel.Information, context); diff --git a/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs b/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs index 3ce0e98968..8b150f662c 100644 --- a/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs +++ b/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs @@ -118,17 +118,6 @@ public string MonitorEventType } } - /// - /// Parameter defines Telemetry Splitting (true/false). Default = false. - /// - public bool TelemetrySplit - { - get - { - return this.Parameters.GetValue(nameof(this.TelemetrySplit), false); - } - } - /// /// A policy that defines how the component will retry when it experiences transient issues. /// @@ -267,7 +256,7 @@ protected async Task ExecuteCommandAsync(EventContext telemetryContext, Cancella if (!cancellationToken.IsCancellationRequested) { - await this.LogProcessDetailsAsync(process, telemetryContext, toolName: this.LogFolderName, logFileName: this.LogFileName, telemetrySplit: this.TelemetrySplit, timestamped: this.LogTimestamped); + await this.LogProcessDetailsAsync(process, telemetryContext, toolName: this.LogFolderName, logFileName: this.LogFileName, timestamped: this.LogTimestamped); process.ThrowIfMonitorFailed(); this.CaptureEventInformation(process, telemetryContext); } @@ -365,7 +354,7 @@ private void CaptureEventInformation(IProcessProxy process, EventContext telemet standardError = $"{standardError.Substring(0, MaxOutputLength)}..."; } - string eventType = !string.IsNullOrWhiteSpace(this.MonitorEventType) + string eventType = !string.IsNullOrWhiteSpace(this.MonitorEventType) ? this.MonitorEventType : "system.monitor"; @@ -419,4 +408,4 @@ private IEnumerable GetCommandsToExecute() return commandsToExecute; } } -} +} \ No newline at end of file From 52191c002834c2c1627ebee0c36ae6b58b090426 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:48:42 -0800 Subject: [PATCH 11/17] fixing ut --- .../VirtualClientLoggingExtensionsTests.cs | 59 +++---------------- 1 file changed, 7 insertions(+), 52 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs index bcbfc659bc..aad596a6d3 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs @@ -2319,51 +2319,6 @@ public void ObscureSecretsExtensionDoesNotModifyDataInTheOriginalParameters() } } - [Test] - public async Task LogProcessDetailsDoesNotSplitTelemetryOutputWhenEnableOutputSplitIsFalse() - { - int maxCharlimit = 125000; - - // Scenario: - // When enableOutputSplit is false, output should NOT be split even if it exceeds maxChars. - // The truncation behavior should apply instead. - - string largeOutput = new string('A', maxCharlimit * 2); - string largeError = new string('B', maxCharlimit * 2); - - InMemoryProcess process = new InMemoryProcess - { - ExitCode = 0, - StartInfo = new ProcessStartInfo - { - FileName = "AnyCommand.exe", - Arguments = "--any=arguments" - }, - StandardOutput = new Common.ConcurrentBuffer(new StringBuilder(largeOutput)), - StandardError = new Common.ConcurrentBuffer(new StringBuilder(largeError)) - }; - - this.mockLogger - .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) - .Callback>((level, eventId, state, exc, formatter) => - { - if (eventId.Name.Contains("ProcessDetails")) - { - // Verify no chunk context is added - Assert.IsFalse(state.Properties.ContainsKey("standardOutputChunkPart")); - Assert.IsFalse(state.Properties.ContainsKey("standardErrorChunkPart")); - } - }); - - - TestExecutor component = new TestExecutor(this.mockFixture); - component.Logger = this.mockLogger.Object; - await component.LogProcessDetailsAsync(process, new EventContext(Guid.NewGuid()), logToTelemetry: true, logToTelemetryMaxChars: maxCharlimit).ConfigureAwait(false); - - // Should only log ONE event (no splitting) - this.mockLogger.Verify(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null), Times.Once); - } - [Test] public async Task LogProcessDetailsDoesNotSplitTelemetryWhenCombinedOutputIsBelowMaxChars() { @@ -2394,8 +2349,8 @@ public async Task LogProcessDetailsDoesNotSplitTelemetryWhenCombinedOutputIsBelo if (eventId.Name.Contains("ProcessDetails")) { // Verify no chunk context is added - Assert.IsFalse(state.Properties.ContainsKey("standardOutputChunkPart")); - Assert.IsFalse(state.Properties.ContainsKey("standardErrorChunkPart")); + Assert.IsFalse(state.Properties.ContainsKey("standardOutputPart")); + Assert.IsFalse(state.Properties.ContainsKey("standardErrorPart")); } }); @@ -2408,7 +2363,7 @@ public async Task LogProcessDetailsDoesNotSplitTelemetryWhenCombinedOutputIsBelo } [Test] - public async Task LogProcessDetailsSplitsTelemetryOutputOnlyWhenBothConditionsAreMet() + public async Task LogProcessDetailsSplitTelemetryWhenCombinedOutputIsBelowMaxChars() { // Scenario: // Splitting should occur ONLY when enableOutputSplit=true AND combined output > maxChars @@ -2438,11 +2393,11 @@ public async Task LogProcessDetailsSplitsTelemetryOutputOnlyWhenBothConditionsAr { if (eventId.Name.Contains("ProcessDetails") && state is EventContext context) { - if (context.Properties.ContainsKey("standardOutputChunkPart")) + if (context.Properties.ContainsKey("standardOutputPart")) { standardOutputEventCount++; } - else if (context.Properties.ContainsKey("standardErrorChunkPart")) + else if (context.Properties.ContainsKey("standardErrorPart")) { standardErrorEventCount++; } @@ -2547,9 +2502,9 @@ public async Task LogProcessDetailsSplitTelemetryIncludesChunkPartNumberInContex .Setup(logger => logger.Log(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) .Callback>((level, eventId, state, exc, formatter) => { - if (state is EventContext context && context.Properties.ContainsKey("standardOutputChunkPart")) + if (state is EventContext context && context.Properties.ContainsKey("standardOutputPart")) { - chunkParts.Add(Convert.ToInt32(context.Properties["standardOutputChunkPart"])); + chunkParts.Add(Convert.ToInt32(context.Properties["standardOutputPart"])); } }); From 9f4275e7499c90b57365cd54a701f8cf59d8094b Mon Sep 17 00:00:00 2001 From: Nirjan <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:54:16 -0800 Subject: [PATCH 12/17] Revert changes in ExecuteCommandMonitor.cs Signed-off-by: Nirjan <165215502+nchapagain001@users.noreply.github.com> --- .../VirtualClient.Monitors/ExecuteCommandMonitor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs b/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs index 8b150f662c..4b2c3fd0d3 100644 --- a/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs +++ b/src/VirtualClient/VirtualClient.Monitors/ExecuteCommandMonitor.cs @@ -408,4 +408,4 @@ private IEnumerable GetCommandsToExecute() return commandsToExecute; } } -} \ No newline at end of file +} From 655e350d815a9fcab877f076f4a21af78cfb5c51 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:57:05 -0800 Subject: [PATCH 13/17] Fixing ut name --- .../VirtualClientLoggingExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs index aad596a6d3..896b6b6871 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs @@ -2363,7 +2363,7 @@ public async Task LogProcessDetailsDoesNotSplitTelemetryWhenCombinedOutputIsBelo } [Test] - public async Task LogProcessDetailsSplitTelemetryWhenCombinedOutputIsBelowMaxChars() + public async Task LogProcessDetailsSplitTelemetryWhenCombinedOutputIsAboveMaxChars() { // Scenario: // Splitting should occur ONLY when enableOutputSplit=true AND combined output > maxChars From 654531999695b760c7ffe2b3aff759d5b505eddd Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:59:51 -0800 Subject: [PATCH 14/17] UT Fix --- .../VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs index dd73ac9896..1772021344 100644 --- a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs @@ -5,6 +5,7 @@ namespace VirtualClient.Common { using System; using System.IO; + using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -86,6 +87,8 @@ public void ProcessDetailsCloneCreatesANewInstanceWithTheSameValues() ProcessDetails process2 = process1.Clone(); Assert.AreNotEqual(process1, process2); + Assert.AreNotEqual(process1.Results, process2.Results); + Assert.AreEqual(process1.Id, process2.Id); Assert.AreEqual(process1.CommandLine, process2.CommandLine); Assert.AreEqual(process1.ExitTime, process2.ExitTime); @@ -95,6 +98,7 @@ public void ProcessDetailsCloneCreatesANewInstanceWithTheSameValues() Assert.AreEqual(process1.StartTime, process2.StartTime); Assert.AreEqual(process1.ToolName, process2.ToolName); Assert.AreEqual(process1.WorkingDirectory, process2.WorkingDirectory); + Assert.AreEqual(process1.Results.Count(), process2.Results.Count()); } } } From 9a683c9fbd155412f4e521153db7aed82a398c7f Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:38:05 -0800 Subject: [PATCH 15/17] UT fix --- .../ProcessExtensionsTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs index 1772021344..63ae9bd2e8 100644 --- a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs @@ -4,6 +4,7 @@ namespace VirtualClient.Common { using System; + using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -81,7 +82,12 @@ public void ProcessDetailsCloneCreatesANewInstanceWithTheSameValues() StandardError = Guid.NewGuid().ToString(), StartTime = DateTime.MinValue, ToolName = Guid.NewGuid().ToString(), - WorkingDirectory = Guid.NewGuid().ToString() + WorkingDirectory = Guid.NewGuid().ToString(), + Results = new[] + { + new KeyValuePair(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()), + new KeyValuePair(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()) + } }; ProcessDetails process2 = process1.Clone(); From ebfd94f5aa93f3cff3363102b6fc8085e6fdd536 Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:00:31 -0800 Subject: [PATCH 16/17] Fixing clone --- .../VirtualClient.Common/ProcessDetails.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs b/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs index c63985ca8a..d27fbd20f1 100644 --- a/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs +++ b/src/VirtualClient/VirtualClient.Common/ProcessDetails.cs @@ -98,13 +98,14 @@ public virtual ProcessDetails Clone() StandardError = this.StandardError, StartTime = this.StartTime, ToolName = this.ToolName, - WorkingDirectory = this.WorkingDirectory + WorkingDirectory = this.WorkingDirectory, + Results = new List>() }; - // Create a new list to avoid sharing the same collection reference - if (this.Results?.Any() == true) - { - clonedDetails.Results = new List>(this.Results); + // Always create a new collection instance when Results is non-null. + if (this.Results != null) + { + clonedDetails.Results = this.Results.ToList(); } return clonedDetails; From 53ff38817cd9d35eddd575b138bc733b0408662d Mon Sep 17 00:00:00 2001 From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:13:30 -0800 Subject: [PATCH 17/17] Assert.AreNotEqual(process1.Results, process2.Results) are going to be equal. --- .../VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs index 63ae9bd2e8..24717e64c3 100644 --- a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessExtensionsTests.cs @@ -93,8 +93,7 @@ public void ProcessDetailsCloneCreatesANewInstanceWithTheSameValues() ProcessDetails process2 = process1.Clone(); Assert.AreNotEqual(process1, process2); - Assert.AreNotEqual(process1.Results, process2.Results); - + Assert.AreEqual(process1.Id, process2.Id); Assert.AreEqual(process1.CommandLine, process2.CommandLine); Assert.AreEqual(process1.ExitTime, process2.ExitTime);