// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Shared.ConsoleLogs;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Commands;
/// <summary>
/// JSON output format for a log line.
/// </summary>
// `aspire logs --format json` and `aspire logs --format json --follow` use this shape;
// keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed class LogLineJson
{
public required string ResourceName { get; init; }
public string? Timestamp { get; init; }
public required string Content { get; init; }
public required bool IsError { get; init; }
}
/// <summary>
/// Wrapper for logs snapshot output.
/// </summary>
// `aspire logs --format json` uses this wrapper; keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed class LogsOutput
{
public required LogLineJson[] Logs { get; init; }
}
[JsonSerializable(typeof(LogLineJson))]
[JsonSerializable(typeof(LogsOutput))]
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal sealed partial class LogsCommandJsonContext : JsonSerializerContext
{
// Compact NDJSON for streaming (--follow)
private static LogsCommandJsonContext? s_ndjson;
public static LogsCommandJsonContext Ndjson => s_ndjson ??= new LogsCommandJsonContext(
new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
});
// Pretty-printed for snapshots
private static LogsCommandJsonContext? s_snapshot;
public static LogsCommandJsonContext Snapshot => s_snapshot ??= new LogsCommandJsonContext(
new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
});
}
internal sealed class LogsCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.Monitoring;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ILogger<LogsCommand> _logger;
private readonly ILogger<ResourceSnapshotWatcher> _resourceSnapshotWatcherLogger;
private static readonly Argument<string?> s_resourceArgument = new("resource")
{
Description = LogsCommandStrings.ResourceArgumentDescription,
Arity = ArgumentArity.ZeroOrOne
};
private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
private static readonly Option<bool> s_followOption = new("--follow", "-f")
{
Description = LogsCommandStrings.FollowOptionDescription
};
private static readonly Option<OutputFormat> s_formatOption = new("--format")
{
Description = LogsCommandStrings.JsonOptionDescription
};
private static readonly Option<int?> s_tailOption = new("--tail", "-n")
{
Description = LogsCommandStrings.TailOptionDescription
};
private static readonly Option<bool> s_timestampsOption = new("--timestamps", "-t")
{
Description = LogsCommandStrings.TimestampsOptionDescription
};
private static readonly Option<bool> s_includeHiddenOption = new("--include-hidden")
{
Description = LogsCommandStrings.IncludeHiddenOptionDescription
};
private static readonly Option<string?> s_searchOption = new("--search")
{
Description = LogsCommandStrings.SearchOptionDescription
};
private readonly ResourceColorMap _resourceColorMap;
public LogsCommand(
AppHostConnectionResolver connectionResolver,
ICliHostEnvironment hostEnvironment,
ResourceColorMap resourceColorMap,
ILogger<LogsCommand> logger,
ILogger<ResourceSnapshotWatcher> resourceSnapshotWatcherLogger,
CommonCommandServices services)
: base("logs", LogsCommandStrings.Description, services)
{
_resourceColorMap = resourceColorMap;
_hostEnvironment = hostEnvironment;
_logger = logger;
_resourceSnapshotWatcherLogger = resourceSnapshotWatcherLogger;
_connectionResolver = connectionResolver;
Arguments.Add(s_resourceArgument);
Options.Add(s_appHostOption);
Options.Add(s_followOption);
Options.Add(s_formatOption);
Options.Add(s_tailOption);
Options.Add(s_timestampsOption);
Options.Add(s_includeHiddenOption);
Options.Add(s_searchOption);
}
protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
using var activity = Telemetry.StartDiagnosticActivity(Name);
var resourceName = parseResult.GetValue(s_resourceArgument);
var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
var follow = parseResult.GetValue(s_followOption);
var format = parseResult.GetValue(s_formatOption);
var tail = parseResult.GetValue(s_tailOption);
var timestamps = parseResult.GetValue(s_timestampsOption);
var includeHidden = parseResult.GetValue(s_includeHiddenOption);
var search = parseResult.GetValue(s_searchOption);
// Validate --tail value
if (tail.HasValue && tail.Value < 1)
{
return CommandResult.Failure(CliExitCodes.InvalidCommand, LogsCommandStrings.TailMustBePositive);
}
var result = await _connectionResolver.ResolveConnectionAsync(
passedAppHostProjectFile,
SharedCommandStrings.ScanningForRunningAppHosts,
string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, LogsCommandStrings.SelectAppHostAction),
SharedCommandStrings.AppHostNotRunning,
cancellationToken);
if (!result.Success)
{
return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsInformation(result, InteractionService));
}
var connection = result.Connection!;
var effectiveIncludeHidden = includeHidden || resourceName is not null;
using var resourceWatcher = new ResourceSnapshotWatcher(connection, _resourceSnapshotWatcherLogger, effectiveIncludeHidden);
await resourceWatcher.WaitForInitialLoadAsync(cancellationToken).ConfigureAwait(false);
// Pre-resolve colors for all resource names so that assignment is
// deterministic regardless of which resources are displayed.
var allSnapshots = resourceWatcher.GetAllResources();
_resourceColorMap.ResolveAll(allSnapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, allSnapshots)));
// Validate resource name exists (match by Name or DisplayName since users may pass either)
if (resourceName is not null)
{
if (!ResourceSnapshotMapper.WhereMatchesResourceName(resourceWatcher.GetAllResources(), resourceName).Any())
{
return CommandResult.Failure(CliExitCodes.InvalidCommand, string.Format(CultureInfo.CurrentCulture, LogsCommandStrings.ResourceNotFound, resourceName));
}
}
else
{
if (!resourceWatcher.GetResources().Any())
{
InteractionService.DisplayMessage(KnownEmojis.Information, LogsCommandStrings.NoResourcesFound);
return CommandResult.Success();
}
}
if (follow)
{
try
{
return CommandResult.FromExitCode(await ExecuteWatchAsync(connection, resourceWatcher, resourceName, format, tail, timestamps, search, cancellationToken));
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken || cancellationToken.IsCancellationRequested)
{
return CommandResult.Success();
}
catch (Exception ex) when (AppHostFollowDisconnectHelpers.IsExpectedDisconnect(ex))
{
if (cancellationToken.IsCancellationRequested)
{
return CommandResult.Success();
}
// Stopping or restarting the AppHost can tear down the JSON-RPC stream while
// logs --follow is active. Treat the lost stream as a normal end of stream
// rather than surfacing it as an unexpected CLI failure. Emit the status
// message on stderr so JSON output on stdout remains parseable.
AppHostFollowDisconnectHelpers.WriteStatusMessage(InteractionService, connection);
return CommandResult.Success();
}
}
else
{
return CommandResult.FromExitCode(await ExecuteGetAsync(connection, resourceWatcher, resourceName, format, tail, timestamps, search, cancellationToken));
}
}
private async Task<int> ExecuteGetAsync(
IAppHostAuxiliaryBackchannel connection,
ResourceSnapshotWatcher resourceWatcher,
string? resourceName,
OutputFormat format,
int? tail,
bool timestamps,
string? search,
CancellationToken cancellationToken)
{
// Collect all logs, parsing into LogEntry with resolved resource names sorted by timestamp
var entries = await InteractionService.ShowStatusAsync(
LogsCommandStrings.GettingLogs,
async () => await CollectLogsAsync(connection, resourceWatcher, resourceName, tail, search, cancellationToken).ConfigureAwait(false));
// Keep the client-side search and tail passes even when a v2 AppHost already applied
// them. Older AppHosts fall back to the legacy log stream, and this also preserves the
// CLI's parsed-log search semantics for any edge cases the server-side pre-filter misses.
if (!string.IsNullOrEmpty(search))
{
entries = entries.Where(e => MatchesSearch(e, search)).ToList();
}
if (tail.HasValue && entries.Count > tail.Value)
{
entries = entries.Skip(entries.Count - tail.Value).ToList();
}
// Output the logs
if (format == OutputFormat.Json)
{
// Wrapped JSON for snapshot - single JSON object compatible with jq
var logsOutput = new LogsOutput
{
Logs = entries.Select(entry => new LogLineJson
{
ResourceName = entry.ResourcePrefix ?? string.Empty,
Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null,
Content = entry.Content ?? entry.RawContent ?? string.Empty,
IsError = entry.Type == LogEntryType.Error
}).ToArray()
};
var json = JsonSerializer.Serialize(logsOutput, LogsCommandJsonContext.Snapshot.LogsOutput);
// Structured output always goes to stdout.
InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
}
else
{
if (entries.Count == 0)
{
InteractionService.DisplayMessage(KnownEmojis.Information, LogsCommandStrings.NoLogsFound);
}
else
{
foreach (var entry in entries)
{
OutputLogLine(entry, format, timestamps);
}
}
}
return CliExitCodes.Success;
}
private async Task<int> ExecuteWatchAsync(
IAppHostAuxiliaryBackchannel connection,
ResourceSnapshotWatcher resourceWatcher,
string? resourceName,
OutputFormat format,
int? tail,
bool timestamps,
string? search,
CancellationToken cancellationToken)
{
var logParser = new LogParser(ConsoleColor.Black);
// If tail is specified, show last N lines first before streaming
if (tail.HasValue)
{
var entries = await InteractionService.ShowStatusAsync(
LogsCommandStrings.GettingLogs,
async () => await CollectLogsAsync(connection, resourceWatcher, resourceName, tail, search, cancellationToken).ConfigureAwait(false));
// Apply full-text search filter before tail so tail count reflects matching entries
if (!string.IsNullOrEmpty(search))
{
entries = entries.Where(e => MatchesSearch(e, search)).ToList();
}
// Output last N lines
var tailedEntries = entries.Count > tail.Value
? entries.Skip(entries.Count - tail.Value)
: entries;
foreach (var entry in tailedEntries)
{
OutputLogLine(entry, format, timestamps);
}
}
// Now stream new logs
var followRequest = new GetConsoleLogsRequest
{
ResourceName = resourceName,
Follow = true,
Search = search,
IncludeHidden = resourceName is not null || resourceWatcher.IncludeHidden
};
await foreach (var logLine in GetConsoleLogLinesAsync(connection, followRequest, cancellationToken).ConfigureAwait(false))
{
// When streaming all resources, skip logs from hidden resources.
// We filter by exclusion so that new resources appearing after the
// initial snapshot are included by default.
if (resourceName is null && !resourceWatcher.IncludeHidden)
{
var resource = resourceWatcher.GetResource(logLine.ResourceName);
if (resource is not null && ResourceSnapshotMapper.IsHiddenResource(resource))
{
continue;
}
}
var entry = ParseLogLine(logLine, logParser, resourceWatcher.GetAllResources());
// Apply full-text search filter on streamed log content
if (!string.IsNullOrEmpty(search) && !MatchesSearch(entry, search))
{
continue;
}
OutputLogLine(entry, format, timestamps);
}
return CliExitCodes.Success;
}
/// <summary>
/// Collects all logs for a resource (or all resources if resourceName is null), parsing each
/// into a <see cref="LogEntry"/> with the resolved resource name set on <see cref="LogEntry.ResourcePrefix"/>
/// and returning entries sorted by timestamp.
/// </summary>
private static async Task<IList<LogEntry>> CollectLogsAsync(
IAppHostAuxiliaryBackchannel connection,
ResourceSnapshotWatcher resourceWatcher,
string? resourceName,
int? tail,
string? search,
CancellationToken cancellationToken)
{
var logParser = new LogParser(ConsoleColor.Black);
var logEntries = new LogEntries(int.MaxValue) { BaseLineNumber = 1 };
// Snapshot the resource list once for the non-follow path since it doesn't change.
var allSnapshots = resourceWatcher.GetAllResources().ToList();
// For named resources, V2 AppHosts use Search/Tail to avoid sending non-matching
// logs over JSON-RPC. The client still applies the same filters after parsing for
// all-resource compatibility and to keep final output semantics centralized here.
var request = new GetConsoleLogsRequest
{
ResourceName = resourceName,
Follow = false,
Search = search,
Tail = tail,
IncludeHidden = resourceName is not null || resourceWatcher.IncludeHidden
};
await foreach (var logLine in GetConsoleLogLinesAsync(connection, request, cancellationToken).ConfigureAwait(false))
{
// When streaming all resources, skip logs from hidden resources
if (resourceName is null && !resourceWatcher.IncludeHidden)
{
var resource = resourceWatcher.GetResource(logLine.ResourceName);
if (resource is not null && ResourceSnapshotMapper.IsHiddenResource(resource))
{
continue;
}
}
logEntries.InsertSorted(ParseLogLine(logLine, logParser, allSnapshots));
}
return logEntries.GetEntries();
}
private static async IAsyncEnumerable<ResourceLogLine> GetConsoleLogLinesAsync(
IAppHostAuxiliaryBackchannel connection,
GetConsoleLogsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// The batch RPC is capability-gated by the connection. Older AppHosts fall back through
// the line-streaming/legacy RPC paths, while newer AppHosts can reduce JSON-RPC overhead
// by sending many log lines per stream item.
await foreach (var batch in connection.GetConsoleLogBatchesAsync(request, cancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch.Lines)
{
yield return logLine;
}
}
}
/// <summary>
/// Parses a <see cref="ResourceLogLine"/> into a <see cref="LogEntry"/> with the resolved resource name
/// set on <see cref="LogEntry.ResourcePrefix"/>.
/// </summary>
private static LogEntry ParseLogLine(ResourceLogLine logLine, LogParser logParser, IEnumerable<ResourceSnapshot> snapshots)
{
var resolvedName = ResolveResourceName(logLine.ResourceName, snapshots);
return logParser.CreateLogEntry(logLine.Content, logLine.IsError, resolvedName);
}
private void OutputLogLine(LogEntry entry, OutputFormat format, bool timestamps)
{
var displayName = entry.ResourcePrefix ?? string.Empty;
var content = entry.Content ?? entry.RawContent ?? string.Empty;
var displayContent = _hostEnvironment.SupportsAnsi ? content : AnsiParser.StripControlSequences(content);
var timestampPrefix = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) + " " : string.Empty;
if (format == OutputFormat.Json)
{
// NDJSON for streaming - compact, one object per line
var logLineJson = new LogLineJson
{
ResourceName = displayName,
Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null,
Content = content,
IsError = entry.Type == LogEntryType.Error
};
var output = JsonSerializer.Serialize(logLineJson, LogsCommandJsonContext.Ndjson.LogLineJson);
// Structured output always goes to stdout.
InteractionService.DisplayRawText(output, ConsoleOutput.Standard);
}
else
{
// Colorized output: assign a consistent color to each resource
var color = _resourceColorMap.GetColor(displayName);
var escapedContent = displayContent.EscapeMarkup();
var dimTimestamp = timestampPrefix.Length > 0 ? $"[dim]{timestampPrefix.EscapeMarkup()}[/]" : string.Empty;
InteractionService.DisplayMarkupLine($"{dimTimestamp}[{color}][[{displayName.EscapeMarkup()}]][/] {escapedContent}");
}
}
private static string FormatTimestamp(DateTime timestamp)
{
return timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture);
}
private static bool MatchesSearch(LogEntry entry, string search)
{
var fragments = SearchTextParser.ParseFragments(search);
if (fragments.Length == 0)
{
return true;
}
var content = entry.RawContent ?? entry.Content ?? string.Empty;
var prefix = entry.ResourcePrefix ?? string.Empty;
var stripped = AnsiParser.StripControlSequences(content);
// Console logs have no structured attributes, so all search text is treated as
// free-text fragments matched against the log content and resource name.
return SearchTextParser.MatchesAllFragments(fragments, (content, prefix, stripped), static (state, fragment) =>
state.content.Contains(fragment, StringComparisons.FullTextSearch) ||
state.prefix.Contains(fragment, StringComparisons.FullTextSearch) ||
state.stripped.Contains(fragment, StringComparisons.FullTextSearch));
}
private static string ResolveResourceName(string resourceName, IEnumerable<ResourceSnapshot> snapshots)
{
var snapshot = snapshots.FirstOrDefault(s => string.Equals(s.Name, resourceName, StringComparisons.ResourceName));
if (snapshot is not null)
{
return ResourceSnapshotMapper.GetResourceName(snapshot, snapshots);
}
return resourceName;
}
}