Skip to content
9 changes: 9 additions & 0 deletions DevProxy.Abstractions/Proxy/IProxyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public enum ReleaseType
Beta
}

public enum LogFor
{
[EnumMember(Value = "human")]
Human,
[EnumMember(Value = "machine")]
Machine
}

public interface IProxyConfiguration
{
int ApiPort { get; set; }
Expand All @@ -29,6 +37,7 @@ public interface IProxyConfiguration
IEnumerable<MockRequestHeader>? FilterByHeaders { get; }
bool InstallCert { get; set; }
string? IPAddress { get; set; }
LogFor LogFor { get; }
LogLevel LogLevel { get; }
ReleaseType NewVersionNotification { get; }
bool NoFirstRun { get; set; }
Expand Down
20 changes: 20 additions & 0 deletions DevProxy/Commands/DevProxyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ sealed class DevProxyCommand : RootCommand
internal const string TimeoutOptionName = "--timeout";
internal const string DiscoverOptionName = "--discover";
internal const string EnvOptionName = "--env";
internal const string LogForOptionName = "--log-for";

private static readonly string[] globalOptions = ["--version"];
private static readonly string[] helpOptions = ["--help", "-h", "/h", "-?", "/?"];
Expand Down Expand Up @@ -353,6 +354,24 @@ private void ConfigureCommand()
}
});

var logForOption = new Option<LogFor?>(LogForOptionName)
{
Description = $"Target audience for log output. Allowed values: {string.Join(", ", Enum.GetNames<LogFor>())}",
HelpName = "log-for",
Recursive = true
};
logForOption.Validators.Add(input =>
{
if (input.Tokens.Count == 0)
{
return;
}
if (!Enum.TryParse<LogFor>(input.Tokens[0].Value, true, out _))
{
input.AddError($"{input.Tokens[0].Value} is not a valid log-for value. Allowed values are: {string.Join(", ", Enum.GetNames<LogFor>())}");
}
});

var options = new List<Option>
{
apiPortOption,
Expand All @@ -362,6 +381,7 @@ private void ConfigureCommand()
envOption,
installCertOption,
ipAddressOption,
logForOption,
logLevelOption,
noFirstRunOption,
portOption,
Expand Down
22 changes: 22 additions & 0 deletions DevProxy/Commands/DevProxyConfigOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using DevProxy.Abstractions.Proxy;
using DevProxy.Abstractions.Utils;
using System.CommandLine;
using System.CommandLine.Parsing;
Expand All @@ -20,6 +21,7 @@ public string? ConfigFile
public int? ApiPort => _parseResult?.GetValueOrDefault<int?>(DevProxyCommand.ApiPortOptionName);
public bool Discover => _parseResult?.GetValueOrDefault<bool?>(DevProxyCommand.DiscoverOptionName) ?? false;
public string? IPAddress => _parseResult?.GetValueOrDefault<string?>(DevProxyCommand.IpAddressOptionName);
public LogFor? LogFor => _parseResult?.GetValueOrDefault<LogFor?>(DevProxyCommand.LogForOptionName);
public LogLevel? LogLevel => _parseResult?.GetValueOrDefault<LogLevel?>(DevProxyCommand.LogLevelOptionName);

public List<string>? UrlsToWatch
Expand Down Expand Up @@ -114,6 +116,25 @@ public DevProxyConfigOptions()
}
}
};

var logForOption = new Option<LogFor?>(DevProxyCommand.LogForOptionName)
{
CustomParser = result =>
{
if (!result.Tokens.Any())
{
return null;
}

if (Enum.TryParse<LogFor>(result.Tokens[0].Value, true, out var logFor))
{
return logFor;
}

return null;
}
};

var apiPortOption = new Option<int?>(DevProxyCommand.ApiPortOptionName);

var discoverOption = new Option<bool>(DevProxyCommand.DiscoverOptionName, "--discover")
Expand All @@ -127,6 +148,7 @@ public DevProxyConfigOptions()
ipAddressOption,
configFileOption,
urlsToWatchOption,
logForOption,
logLevelOption,
discoverOption
};
Expand Down
36 changes: 29 additions & 7 deletions DevProxy/Extensions/ILoggingBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using DevProxy.Abstractions.Proxy;
using DevProxy.Commands;
using DevProxy.Logging;

Expand All @@ -26,6 +27,10 @@ public static ILoggingBuilder ConfigureDevProxyLogging(
var configuredLogLevel = options.LogLevel ??
configuration.GetValue("logLevel", LogLevel.Information);

// Determine the log target audience (human or machine)
var configuredLogFor = options.LogFor ??
configuration.GetValue("logFor", LogFor.Human);

// For stdio command, log to file instead of console to avoid interfering with proxied streams
if (DevProxyCommand.IsStdioCommand)
{
Expand All @@ -39,6 +44,14 @@ public static ILoggingBuilder ConfigureDevProxyLogging(
return builder;
}

var showSkipMessages = configuration.GetValue("showSkipMessages", true);
var showTimestamps = configuration.GetValue("showTimestamps", true);

// Select the appropriate formatter based on logFor setting
var formatterName = configuredLogFor == LogFor.Machine
? MachineConsoleFormatter.FormatterName
: ProxyConsoleFormatter.DefaultCategoryName;

_ = builder
.AddFilter("Microsoft.Hosting.*", LogLevel.Error)
.AddFilter("Microsoft.AspNetCore.*", LogLevel.Error)
Expand All @@ -48,17 +61,26 @@ public static ILoggingBuilder ConfigureDevProxyLogging(
.AddFilter("DevProxy.Plugins.*", level =>
level >= configuredLogLevel &&
!DevProxyCommand.HasGlobalOptions)
.AddConsole(options =>
.AddConsole(consoleOptions =>
{
consoleOptions.FormatterName = formatterName;
consoleOptions.LogToStandardErrorThreshold = LogLevel.Warning;
}
)
.AddConsoleFormatter<ProxyConsoleFormatter, ProxyConsoleFormatterOptions>(formatterOptions =>
{
options.FormatterName = ProxyConsoleFormatter.DefaultCategoryName;
options.LogToStandardErrorThreshold = LogLevel.Warning;
formatterOptions.IncludeScopes = true;
formatterOptions.LogFor = configuredLogFor;
formatterOptions.ShowSkipMessages = showSkipMessages;
formatterOptions.ShowTimestamps = showTimestamps;
}
)
.AddConsoleFormatter<ProxyConsoleFormatter, ProxyConsoleFormatterOptions>(options =>
.AddConsoleFormatter<MachineConsoleFormatter, ProxyConsoleFormatterOptions>(formatterOptions =>
{
options.IncludeScopes = true;
options.ShowSkipMessages = configuration.GetValue("showSkipMessages", true);
options.ShowTimestamps = configuration.GetValue("showTimestamps", true);
formatterOptions.IncludeScopes = true;
formatterOptions.LogFor = configuredLogFor;
formatterOptions.ShowSkipMessages = showSkipMessages;
formatterOptions.ShowTimestamps = showTimestamps;
}
)
.AddRequestLogger()
Expand Down
193 changes: 193 additions & 0 deletions DevProxy/Logging/MachineConsoleFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using DevProxy.Abstractions.Proxy;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Console;
using Microsoft.Extensions.Options;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace DevProxy.Logging;

sealed class MachineConsoleFormatter : ConsoleFormatter
{
public const string FormatterName = "devproxy-machine";

private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

// Mapping from MessageType to semantic type strings for machine output
private static readonly Dictionary<MessageType, string> _messageTypeStrings = new()
{
[MessageType.InterceptedRequest] = "request",
[MessageType.InterceptedResponse] = "response",
[MessageType.PassedThrough] = "passthrough",
[MessageType.Chaos] = "chaos",
[MessageType.Warning] = "warning",
[MessageType.Mocked] = "mock",
[MessageType.Failed] = "error",
[MessageType.Tip] = "tip",
[MessageType.Skipped] = "skip",
[MessageType.Processed] = "processed",
[MessageType.Timestamp] = "timestamp",
[MessageType.FinishedProcessingRequest] = "finished",
[MessageType.Normal] = "info"
};

// Mapping from LogLevel to semantic level strings for machine output
private static readonly Dictionary<LogLevel, string> _logLevelStrings = new()
{
[LogLevel.Trace] = "trace",
[LogLevel.Debug] = "debug",
[LogLevel.Information] = "info",
[LogLevel.Warning] = "warning",
[LogLevel.Error] = "error",
[LogLevel.Critical] = "critical"
};

private readonly ProxyConsoleFormatterOptions _options;
private readonly HashSet<MessageType> _filteredMessageTypes;

public MachineConsoleFormatter(IOptions<ProxyConsoleFormatterOptions> options) : base(FormatterName)
{
Console.OutputEncoding = Encoding.UTF8;
_options = options.Value;

_filteredMessageTypes = [MessageType.InterceptedResponse, MessageType.FinishedProcessingRequest];
if (!_options.ShowSkipMessages)
{
_ = _filteredMessageTypes.Add(MessageType.Skipped);
}
if (!_options.ShowTimestamps)
{
_ = _filteredMessageTypes.Add(MessageType.Timestamp);
}
}

public override void Write<TState>(in LogEntry<TState> logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter)
{
if (logEntry.State is RequestLog requestLog)
{
WriteRequestLog(requestLog, logEntry.Category, scopeProvider, textWriter);
}
else
{
WriteRegularLogMessage(logEntry, scopeProvider, textWriter);
}
}

private void WriteRequestLog(RequestLog requestLog, string category, IExternalScopeProvider? scopeProvider, TextWriter textWriter)
{
var messageType = requestLog.MessageType;

if (_filteredMessageTypes.Contains(messageType))
{
return;
}

var requestId = GetRequestIdScope(scopeProvider);
var pluginName = category == ProxyConsoleFormatter.DefaultCategoryName ? null : category;

// Extract short plugin name from full category
if (pluginName is not null)
{
pluginName = pluginName[(pluginName.LastIndexOf('.') + 1)..];
}

var logObject = new MachineRequestLogEntry
{
Type = GetMessageTypeString(messageType),
Message = requestLog.Message,
Method = requestLog.Method,
Url = requestLog.Url,
Plugin = pluginName,
RequestId = requestId?.ToString(CultureInfo.InvariantCulture),
Timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};

var json = JsonSerializer.Serialize(logObject, _jsonOptions);
textWriter.WriteLine(json);
}

private static void WriteRegularLogMessage<TState>(in LogEntry<TState> logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter)
{
var message = logEntry.Formatter(logEntry.State, logEntry.Exception);
if (string.IsNullOrEmpty(message))
{
return;
}

var requestId = GetRequestIdScope(scopeProvider);
var category = logEntry.Category;

// Extract short category name
if (category is not null)
{
category = category[(category.LastIndexOf('.') + 1)..];
}

var logObject = new MachineLogEntry
{
Type = "log",
Level = GetLogLevelString(logEntry.LogLevel),
Message = message,
Category = category,
RequestId = requestId?.ToString(CultureInfo.InvariantCulture),
Timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
Exception = logEntry.Exception?.ToString()
};

var json = JsonSerializer.Serialize(logObject, _jsonOptions);
textWriter.WriteLine(json);
}

private static string GetMessageTypeString(MessageType messageType) =>
_messageTypeStrings.TryGetValue(messageType, out var str) ? str : "unknown";

private static string GetLogLevelString(LogLevel logLevel) =>
_logLevelStrings.TryGetValue(logLevel, out var str) ? str : "unknown";

private static int? GetRequestIdScope(IExternalScopeProvider? scopeProvider)
{
int? requestId = null;
scopeProvider?.ForEachScope((scope, _) =>
{
if (scope is Dictionary<string, object> dictionary &&
dictionary.TryGetValue(nameof(requestId), out var req))
{
requestId = (int)req;
}
}, "");
return requestId;
}

// JSON serialization models for machine output
private sealed class MachineRequestLogEntry
{
public string? Type { get; set; }
public string? Message { get; set; }
public string? Method { get; set; }
public string? Url { get; set; }
public string? Plugin { get; set; }
public string? RequestId { get; set; }
public string? Timestamp { get; set; }
}

private sealed class MachineLogEntry
{
public string? Type { get; set; }
public string? Level { get; set; }
public string? Message { get; set; }
public string? Category { get; set; }
public string? RequestId { get; set; }
public string? Timestamp { get; set; }
public string? Exception { get; set; }
}
}
3 changes: 3 additions & 0 deletions DevProxy/Logging/ProxyConsoleFormatterOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using DevProxy.Abstractions.Proxy;
using Microsoft.Extensions.Logging.Console;

namespace DevProxy.Logging;

sealed class ProxyConsoleFormatterOptions : ConsoleFormatterOptions
{
public LogFor LogFor { get; set; } = LogFor.Human;

public bool ShowSkipMessages { get; set; } = true;

public bool ShowTimestamps { get; set; } = true;
Expand Down
2 changes: 2 additions & 0 deletions DevProxy/Proxy/ProxyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public string ConfigFile
public string? IPAddress { get; set; } = "127.0.0.1";
public bool InstallCert { get; set; } = true;
[JsonConverter(typeof(JsonStringEnumConverter))]
public LogFor LogFor { get; set; } = LogFor.Human;
[JsonConverter(typeof(JsonStringEnumConverter))]
public LogLevel LogLevel { get; set; } = LogLevel.Information;
public bool NoFirstRun { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
Expand Down
Loading
Loading