From bc673fd5df6c0676f6cce45446a10b6f87b424da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 11 Jun 2026 15:55:00 +0200 Subject: [PATCH 1/3] Add --diagnostics option for capturing shell diagnostic logs Implements issue #122. Adds a --diagnostics [path] CLI option that writes a timestamped diagnostic log file capturing session metadata, command execution + timing, OK/FAIL results, errors, and connection events. - New Core/DiagnosticLog.cs: thread-safe file writer with session header and tagged entries (CONNECT/CMD/RESULT/ERROR) - ShellInterpreter: EnableDiagnostics(path), per-command timing + result/error logging in ExecuteCommandAsync, connect event in Connect, disposal in Dispose - Program.cs: --diagnostics [path] option (optional value, like --mcp); defaults to a timestamped file in the config directory - help-Diagnostics, diagnostics-enabled, diagnostics-error-create strings in en.ftl - README, docs/navigation.md, and Runtime/DiagnosticLogTests.cs --- .../Runtime/DiagnosticLogTests.cs | 165 ++++++++++++++++ .../DiagnosticLog.cs | 185 ++++++++++++++++++ .../ShellInterpreter.cs | 67 ++++++- CosmosDBShell/Program.cs | 33 +++- CosmosDBShell/lang/en.ftl | 3 + README.md | 1 + docs/navigation.md | 7 + 7 files changed, 453 insertions(+), 8 deletions(-) create mode 100644 CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs create mode 100644 CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs diff --git a/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs b/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs new file mode 100644 index 0000000..d1999b6 --- /dev/null +++ b/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs @@ -0,0 +1,165 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Microsoft.Azure.Cosmos; + +using Xunit; + +namespace CosmosShell.Tests.Runtime; + +public class DiagnosticLogTests : IDisposable +{ + private readonly string path; + + public DiagnosticLogTests() + { + this.path = Path.Combine(Path.GetTempPath(), $"cosmosdbshell-diag-{Guid.NewGuid():N}.log"); + } + + public void Dispose() + { + if (File.Exists(this.path)) + { + File.Delete(this.path); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public void Create_WritesSessionMetadataHeader() + { + using (DiagnosticLog.Create(this.path)) + { + } + + var lines = File.ReadAllLines(this.path); + + Assert.Equal("# CosmosDB Shell Diagnostic Log", lines[0]); + Assert.StartsWith("# Started: ", lines[1]); + Assert.StartsWith("# Machine: ", lines[2]); + Assert.StartsWith("# OS: ", lines[3]); + Assert.StartsWith("# Runtime: ", lines[4]); + Assert.Equal(new string('-', 80), lines[5]); + } + + [Fact] + public void LogConnect_WritesEndpointAndMode() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogConnect(new Uri("https://myaccount.documents.azure.com:443/"), ConnectionMode.Direct); + } + + var line = LastEntry(); + Assert.Matches(@"^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[CONNECT \] https://myaccount\.documents\.azure\.com:443/ \(mode=Direct\)$", line); + } + + [Fact] + public void LogCommand_WritesCommandText() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogCommand("dir"); + } + + var line = LastEntry(); + Assert.Matches(@"^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[CMD \] dir$", line); + } + + [Fact] + public void LogResult_Success_WritesOkWithElapsed() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogResult(succeeded: true, elapsedMilliseconds: 333.25, command: "dir"); + } + + var line = LastEntry(); + Assert.Matches(@"^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[RESULT \] \[OK\] 333\.[23]ms \| dir$", line); + } + + [Fact] + public void LogResult_Failure_WritesFail() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogResult(succeeded: false, elapsedMilliseconds: 2.1, command: "cat nonexistent"); + } + + var line = LastEntry(); + Assert.Contains("[RESULT ] [FAIL] 2.1ms | cat nonexistent", line); + } + + [Fact] + public void LogError_WritesExceptionTypeAndMessage() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogError("cat nonexistent", new InvalidOperationException("Item not found")); + } + + var line = LastEntry(); + Assert.Contains("[ERROR ] cat nonexistent -> InvalidOperationException: Item not found", line); + } + + [Fact] + public void LogCommand_FlattensMultiLineCommand() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogCommand("line one\r\nline two"); + } + + var line = LastEntry(); + Assert.EndsWith("[CMD ] line one line two", line); + } + + private string LastEntry() + { + return File.ReadAllLines(this.path).Last(l => l.StartsWith('[')); + } +} + +public class DiagnosticLogInterpreterTests : IDisposable +{ + private readonly string path; + + public DiagnosticLogInterpreterTests() + { + this.path = Path.Combine(Path.GetTempPath(), $"cosmosdbshell-diag-{Guid.NewGuid():N}.log"); + } + + public void Dispose() + { + if (File.Exists(this.path)) + { + File.Delete(this.path); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task ExecuteCommandAsync_WhenDiagnosticsEnabled_WritesCommandAndResult() + { + var interpreter = new ShellInterpreter(); + interpreter.EnableDiagnostics(this.path); + + await interpreter.ExecuteCommandAsync("$x = 1", CancellationToken.None); + interpreter.Dispose(); + + var entries = File.ReadAllLines(this.path).Where(l => l.StartsWith('[')).ToArray(); + Assert.Contains(entries, l => l.Contains("[CMD ] $x = 1")); + Assert.Contains(entries, l => l.Contains("[RESULT ] [OK]") && l.Contains("$x = 1")); + } +} + diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs new file mode 100644 index 0000000..7ebec94 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs @@ -0,0 +1,185 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using System.Globalization; +using Microsoft.Azure.Cosmos; + +/// +/// Writes timestamped diagnostic entries to a log file so users can capture shell +/// activity (command execution, timing, errors, and connection events) for +/// troubleshooting, performance analysis, and auditing. Enabled via the +/// --diagnostics CLI option. All writes are serialized and IO failures are +/// swallowed so diagnostics never disrupt the interactive session. +/// +internal sealed class DiagnosticLog : IDisposable +{ + private const int TagWidth = 8; + + private readonly object gate = new(); + + private StreamWriter? writer; + + private bool disposed; + + private DiagnosticLog(string path, StreamWriter writer) + { + this.Path = path; + this.writer = writer; + } + + /// + /// Gets the resolved path of the diagnostic log file. + /// + public string Path { get; } + + /// + /// Creates a diagnostic log at and writes the session + /// metadata header. + /// + /// The fully-qualified path of the log file to create. + /// The initialized . + public static DiagnosticLog Create(string path) + { + var directory = System.IO.Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var writer = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + AutoFlush = true, + }; + + var log = new DiagnosticLog(path, writer); + log.WriteHeader(); + return log; + } + + /// + /// Records a successful connection to a Cosmos DB account. + /// + /// The account endpoint that was connected to. + /// The connection mode negotiated for the client. + public void LogConnect(Uri endpoint, ConnectionMode mode) + { + var endpointText = endpoint.GetComponents( + UriComponents.AbsoluteUri | UriComponents.StrongPort, + UriFormat.UriEscaped); + this.WriteLine("CONNECT", $"{endpointText} (mode={mode})"); + } + + /// + /// Records that a command is about to be executed. + /// + /// The command text. + public void LogCommand(string command) + { + this.WriteLine("CMD", Flatten(command)); + } + + /// + /// Records the outcome and elapsed time of a command. + /// + /// true when the command completed without error. + /// The wall-clock execution time in milliseconds. + /// The command text. + public void LogResult(bool succeeded, double elapsedMilliseconds, string command) + { + var status = succeeded ? "[OK]" : "[FAIL]"; + var elapsed = elapsedMilliseconds.ToString("0.0", CultureInfo.InvariantCulture); + this.WriteLine("RESULT", $"{status} {elapsed}ms | {Flatten(command)}"); + } + + /// + /// Records the exception raised by a failed command. + /// + /// The command text. + /// The exception that was raised. + public void LogError(string command, Exception exception) + { + this.WriteLine("ERROR", $"{Flatten(command)} -> {exception.GetType().Name}: {Flatten(exception.Message)}"); + } + + /// + public void Dispose() + { + lock (this.gate) + { + if (this.disposed) + { + return; + } + + this.disposed = true; + try + { + this.writer?.Dispose(); + } + catch (IOException) + { + } + finally + { + this.writer = null; + } + } + } + + private static string Flatten(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ').Trim(); + } + + private void WriteHeader() + { + lock (this.gate) + { + if (this.writer is null) + { + return; + } + + try + { + this.writer.WriteLine("# CosmosDB Shell Diagnostic Log"); + this.writer.WriteLine($"# Started: {DateTimeOffset.Now:O}"); + this.writer.WriteLine($"# Machine: {Environment.MachineName}"); + this.writer.WriteLine($"# OS: {Environment.OSVersion.VersionString}"); + this.writer.WriteLine($"# Runtime: {Environment.Version}"); + this.writer.WriteLine(new string('-', 80)); + } + catch (IOException) + { + } + } + } + + private void WriteLine(string tag, string message) + { + lock (this.gate) + { + if (this.writer is null) + { + return; + } + + var timestamp = DateTime.Now.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + try + { + this.writer.WriteLine($"[{timestamp}] [{tag.PadRight(TagWidth)}] {message}"); + } + catch (IOException) + { + } + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 4dedde9..00b2d79 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -161,6 +161,8 @@ internal static char CSVSeparator internal Program.CosmosShellOptions? Options { get; set; } + internal DiagnosticLog? Diagnostics { get; private set; } + internal int? McpPort { get; set; } internal Queue VariableContainers { get; } = new(); @@ -275,6 +277,11 @@ public async Task ExecuteCommandAsync(string command, Cancellation var savedErrOut = this.ErrOutRedirect; var savedAppendErr = this.AppendErrRedirection; + var diagnostics = this.Diagnostics; + var stopwatch = diagnostics is null ? null : System.Diagnostics.Stopwatch.StartNew(); + diagnostics?.LogCommand(command); + CommandState? result = null; + try { try @@ -283,33 +290,39 @@ public async Task ExecuteCommandAsync(string command, Cancellation } catch (OperationCanceledException) when (token.IsCancellationRequested) { - return new CommandState(); + result = new CommandState(); + return result; } catch (TaskCanceledException e) { var shellException = new ShellException(CommandException.GetDisplayMessage(e), e); this.ReportExecutionError(shellException, command); - return new ErrorCommandState(shellException); + result = new ErrorCommandState(shellException); + return result; } catch (Exception e) { this.ReportExecutionError(e, command); var inner = e is PositionalException pe ? (pe.InnerException ?? pe) : e; - return new ErrorCommandState(inner); + result = new ErrorCommandState(inner); + return result; } if (token.IsCancellationRequested) { - return state; + result = state; + return result; } if (state is ParserErrorCommandState parserErrorState) { this.ReportParserErrors(parserErrorState.Errors, command); - return state; + result = state; + return result; } - return this.PrintState(state); + result = this.PrintState(state); + return result; } finally { @@ -317,6 +330,18 @@ public async Task ExecuteCommandAsync(string command, Cancellation this.AppendOutRedirection = savedAppendOut; this.ErrOutRedirect = savedErrOut; this.AppendErrRedirection = savedAppendErr; + + if (diagnostics is not null && stopwatch is not null) + { + stopwatch.Stop(); + var succeeded = !(result?.IsError ?? true); + if (!succeeded && result is ErrorCommandState errorState) + { + diagnostics.LogError(command, errorState.Exception); + } + + diagnostics.LogResult(succeeded, stopwatch.Elapsed.TotalMilliseconds, command); + } } } @@ -1106,6 +1131,35 @@ internal void Connect(CosmosClient client, ArmCosmosContext? armContext = null) this.State = new ConnectedState(client, armContext); CosmosCompleteCommand.ClearDatabases(); CosmosCompleteCommand.ClearContainers(); + this.Diagnostics?.LogConnect(client.Endpoint, client.ClientOptions.ConnectionMode); + } + + /// + /// Enables diagnostic logging for the session, writing entries to + /// when supplied, or to a timestamped file in the + /// shell configuration directory otherwise. + /// + /// An optional custom log file path. + internal void EnableDiagnostics(string? path) + { + if (this.Diagnostics is not null) + { + return; + } + + var resolvedPath = string.IsNullOrWhiteSpace(path) + ? Path.Combine(this.cfgPath, $"diagnostics-{DateTime.Now:yyyyMMdd-HHmmss}.log") + : Path.GetFullPath(path); + + try + { + this.Diagnostics = DiagnosticLog.Create(resolvedPath); + WriteLine(MessageService.GetArgsString("diagnostics-enabled", "path", this.Diagnostics.Path)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + WriteLine(MessageService.GetArgsString("diagnostics-error-create", "path", resolvedPath, "message", ex.Message)); + } } private void AttachArmContext(CosmosClient client, ArmCosmosContext armContext) @@ -1298,6 +1352,7 @@ protected virtual void Dispose(bool disposing) currentTokenSource?.Dispose(); this.editorCancelTokenSource?.Dispose(); this.State?.Dispose(); + this.Diagnostics?.Dispose(); } this.disposedValue = true; diff --git a/CosmosDBShell/Program.cs b/CosmosDBShell/Program.cs index 5ed9eab..ea630bb 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -108,6 +108,16 @@ public static async Task Main(string[] args) o.McpPort = mcpValue ?? DefaultMcpPort; } + // --diagnostics supports an optional value: when present without a path, + // logging is written to a timestamped file in the config directory. + var diagnosticsResult = parseResult.FindResultFor(optionMap.Diagnostics); + if (diagnosticsResult is not null) + { + o.EnableDiagnostics = true; + var diagnosticsValue = parseResult.GetValueForOption(optionMap.Diagnostics); + o.DiagnosticsPath = string.IsNullOrWhiteSpace(diagnosticsValue) ? null : diagnosticsValue; + } + if (o.StartLspServer) { // Already handled above, but keep for completeness @@ -149,6 +159,13 @@ public static async Task Main(string[] args) ShellInterpreter.Instance.Options = o; + // Enable diagnostic logging before connecting so the startup --connect + // event is captured in the log. + if (o.EnableDiagnostics) + { + ShellInterpreter.Instance.EnableDiagnostics(o.DiagnosticsPath); + } + if (o.ConnectionString != null) { using var connectTokenSource = ShellInterpreter.UserCancellationTokenSource; @@ -450,6 +467,10 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() }; var verbose = new Option("--verbose", MessageService.GetString("help-Verbose")); var theme = new Option("--theme", MessageService.GetString("help-Theme")); + var diagnostics = new Option("--diagnostics", MessageService.GetString("help-Diagnostics")) + { + Arity = ArgumentArity.ZeroOrOne, + }; var root = new RootCommand("Cosmos DB Shell") { @@ -471,6 +492,7 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() lspStdio, verbose, theme, + diagnostics, }; var map = new OptionMap( @@ -491,7 +513,8 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() startLspServer, lspStdio, verbose, - theme); + theme, + diagnostics); return (root, map); } @@ -517,6 +540,7 @@ private static string BuildHelpText() [map.ConnectResourceGroup] = "", [map.McpPort] = "[]", [map.Theme] = "", + [map.Diagnostics] = "[]", }; var rows = new List<(string Label, string? Description)>(); @@ -620,7 +644,8 @@ private sealed record OptionMap( Option StartLspServer, Option LspStdio, Option Verbose, - Option Theme); + Option Theme, + Option Diagnostics); /// /// Maps the most common System.CommandLine parse error messages @@ -698,5 +723,9 @@ public class CosmosShellOptions public bool Verbose { get; set; } public string? Theme { get; set; } + + public bool EnableDiagnostics { get; set; } + + public string? DiagnosticsPath { get; set; } } } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index f1bc7e5..00af2a3 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -622,7 +622,10 @@ help-EnableLspServer = Enable Language Server Protocol (LSP) server for editor i help-McpPort = Enable MCP HTTP server. Optionally specify a port with --mcp ; default is 6128. help-Verbose = Print full exception details instead of only the message. help-Theme = Color theme profile to apply at startup. Falls back to the COSMOSDB_SHELL_THEME environment variable. +help-Diagnostics = Write timestamped diagnostic logs to a file. Optionally specify a path with --diagnostics ; defaults to a timestamped file in the shell configuration directory. mcp-error-invalid-port = Error: --mcp port must be greater than 0. +diagnostics-enabled = Writing diagnostic log to { $path }. +diagnostics-error-create = Error: could not create diagnostic log at '{ $path }': { $message } warning-unknown-theme = Unknown theme '{ $name }'. Available themes: { $themes }. Falling back to default. diff --git a/README.md b/README.md index 0937509..e6fd900 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | `--connect-subscription ` | Azure subscription ID for ARM database and container operations | | `--connect-resource-group ` | Azure resource group name for ARM database and container operations | | `--mcp [port]` | Enable MCP server on the given port, or `6128` by default | +| `--diagnostics [path]` | Write timestamped diagnostic logs to a file, or to a timestamped file in the config directory by default | | `--verbose` | Print full exception details | | `--color-system ` | Colors: 0=off, 1=standard, 2=truecolor (alias: `--cs`) | | `--theme ` | Color theme profile to apply at startup (`default`, `light`, `dark`, `monochrome`). Falls back to `COSMOSDB_SHELL_THEME`. | diff --git a/docs/navigation.md b/docs/navigation.md index de33b3d..aca7714 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -269,6 +269,7 @@ Start the shell with options to customize behavior: | `--connect-subscription ` | Azure subscription ID for ARM database and container operations at startup | | `--connect-resource-group ` | Azure resource group name for ARM database and container operations at startup | | `--mcp [port]` | Enable MCP (Model Context Protocol) server on the given port, or `6128` by default | +| `--diagnostics [path]` | Write timestamped diagnostic logs (commands, timing, errors, connection events) to a file, or to a timestamped file in the config directory by default | | `--color-system ` | Color scheme: 0=off, 1=standard, 2=truecolor (alias: `--cs`) | | `--clear-history` | Clear command history on start | | `--help` | Show usage information | @@ -300,4 +301,10 @@ cosmosdbshell --mcp # Start with MCP server enabled on a custom port cosmosdbshell --mcp 5050 + +# Capture a diagnostic log to the default location in the config directory +cosmosdbshell --diagnostics + +# Capture a diagnostic log to a custom file +cosmosdbshell --diagnostics mylog.log ``` From cb58ef11d6a49b6e3490619eb440c28bc02cfbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 12 Jun 2026 11:29:37 +0200 Subject: [PATCH 2/3] Redact AccountKey and log canceled commands distinctly in diagnostics Addresses Copilot review feedback on PR #127: - Redact AccountKey= values before writing command text to the diagnostic log to avoid leaking connection-string secrets to disk. - Log canceled commands as [CANCELLED] instead of [OK] so the diagnostic/audit log is not misleading. --- .../Runtime/DiagnosticLogTests.cs | 46 +++++++++++++++++++ .../DiagnosticLog.cs | 19 +++++++- .../ShellInterpreter.cs | 15 ++++-- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs b/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs index d1999b6..e59dcab 100644 --- a/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs +++ b/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs @@ -123,6 +123,33 @@ public void LogCommand_FlattensMultiLineCommand() Assert.EndsWith("[CMD ] line one line two", line); } + [Fact] + public void LogCommand_RedactsAccountKey() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogCommand("connect \"AccountEndpoint=https://acc.documents.azure.com:443/;AccountKey=SuperSecretKey123==;\""); + } + + var line = LastEntry(); + Assert.DoesNotContain("SuperSecretKey123", line); + Assert.Contains("AccountKey=***", line); + Assert.Contains("AccountEndpoint=https://acc.documents.azure.com:443/", line); + } + + [Fact] + public void LogCancelled_WritesCancelledStatus() + { + using (var log = DiagnosticLog.Create(this.path)) + { + log.LogResult(succeeded: true, elapsedMilliseconds: 5.0, command: "noop"); + log.LogCancelled(elapsedMilliseconds: 12.5, command: "long-running"); + } + + var line = LastEntry(); + Assert.Matches(@"^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[RESULT \] \[CANCELLED\] 12\.5ms \| long-running$", line); + } + private string LastEntry() { return File.ReadAllLines(this.path).Last(l => l.StartsWith('[')); @@ -161,5 +188,24 @@ public async Task ExecuteCommandAsync_WhenDiagnosticsEnabled_WritesCommandAndRes Assert.Contains(entries, l => l.Contains("[CMD ] $x = 1")); Assert.Contains(entries, l => l.Contains("[RESULT ] [OK]") && l.Contains("$x = 1")); } + + [Fact] + public async Task ExecuteCommandAsync_WhenCanceled_DoesNotLogResultAsOk() + { + var interpreter = new ShellInterpreter(); + interpreter.EnableDiagnostics(this.path); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await interpreter.ExecuteCommandAsync("$x = 1", cts.Token); + interpreter.Dispose(); + + var entries = File.ReadAllLines(this.path).Where(l => l.StartsWith('[')).ToArray(); + var resultEntries = entries.Where(l => l.Contains("[RESULT ]")).ToArray(); + Assert.NotEmpty(resultEntries); + Assert.All(resultEntries, l => Assert.DoesNotContain("[OK]", l)); + Assert.Contains(resultEntries, l => l.Contains("[CANCELLED]")); + } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs index 7ebec94..56dbd09 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DiagnosticLog.cs @@ -5,6 +5,7 @@ namespace Azure.Data.Cosmos.Shell.Core; using System.Globalization; +using System.Text.RegularExpressions; using Microsoft.Azure.Cosmos; /// @@ -18,6 +19,10 @@ internal sealed class DiagnosticLog : IDisposable { private const int TagWidth = 8; + private static readonly Regex AccountKeyPattern = new( + "(?i)(AccountKey\\s*=\\s*)[^;\\s\"']+", + RegexOptions.Compiled); + private readonly object gate = new(); private StreamWriter? writer; @@ -94,6 +99,17 @@ public void LogResult(bool succeeded, double elapsedMilliseconds, string command this.WriteLine("RESULT", $"{status} {elapsed}ms | {Flatten(command)}"); } + /// + /// Records that a command was canceled before it completed. + /// + /// The wall-clock execution time in milliseconds. + /// The command text. + public void LogCancelled(double elapsedMilliseconds, string command) + { + var elapsed = elapsedMilliseconds.ToString("0.0", CultureInfo.InvariantCulture); + this.WriteLine("RESULT", $"[CANCELLED] {elapsed}ms | {Flatten(command)}"); + } + /// /// Records the exception raised by a failed command. /// @@ -136,7 +152,8 @@ private static string Flatten(string value) return string.Empty; } - return value.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ').Trim(); + var redacted = AccountKeyPattern.Replace(value, "$1***"); + return redacted.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ').Trim(); } private void WriteHeader() diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 00b2d79..7b3b84c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -334,13 +334,20 @@ public async Task ExecuteCommandAsync(string command, Cancellation if (diagnostics is not null && stopwatch is not null) { stopwatch.Stop(); - var succeeded = !(result?.IsError ?? true); - if (!succeeded && result is ErrorCommandState errorState) + if (token.IsCancellationRequested) { - diagnostics.LogError(command, errorState.Exception); + diagnostics.LogCancelled(stopwatch.Elapsed.TotalMilliseconds, command); } + else + { + var succeeded = !(result?.IsError ?? true); + if (!succeeded && result is ErrorCommandState errorState) + { + diagnostics.LogError(command, errorState.Exception); + } - diagnostics.LogResult(succeeded, stopwatch.Elapsed.TotalMilliseconds, command); + diagnostics.LogResult(succeeded, stopwatch.Elapsed.TotalMilliseconds, command); + } } } } From 1d57aff4d4635298fb02e14c4f6f034ae3cdebe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 16 Jun 2026 12:30:32 +0200 Subject: [PATCH 3/3] Use CancelAsync in diagnostics cancellation test (VSTHRD103) --- CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs b/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs index e59dcab..b5c37c5 100644 --- a/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs +++ b/CosmosDBShell.Tests/Runtime/DiagnosticLogTests.cs @@ -196,7 +196,7 @@ public async Task ExecuteCommandAsync_WhenCanceled_DoesNotLogResultAsOk() interpreter.EnableDiagnostics(this.path); using var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); await interpreter.ExecuteCommandAsync("$x = 1", cts.Token); interpreter.Dispose();