Skip to content

Commit 8185a7f

Browse files
authored
Improve verbose and debug logging level messaging in web cmdlets (PowerShell#25510)
1 parent 0380034 commit 8185a7f

File tree

5 files changed

+258
-68
lines changed

5 files changed

+258
-68
lines changed

src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/ContentHelper.Common.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
using System.Net.Http;
1010
using System.Net.Http.Headers;
1111
using System.Text;
12-
12+
using Humanizer;
1313
using Microsoft.Win32;
1414

1515
namespace Microsoft.PowerShell.Commands
@@ -21,8 +21,15 @@ internal static class ContentHelper
2121
// ContentType may not exist in response header. Return null if not.
2222
internal static string? GetContentType(HttpResponseMessage response) => response.Content.Headers.ContentType?.MediaType;
2323

24+
internal static string? GetContentType(HttpRequestMessage request) => request.Content?.Headers.ContentType?.MediaType;
25+
2426
internal static Encoding GetDefaultEncoding() => Encoding.UTF8;
2527

28+
internal static string GetFriendlyContentLength(long? length) =>
29+
length.HasValue
30+
? $"{length.Value.Bytes().Humanize()} ({length.Value:#,0} bytes)"
31+
: "unknown size";
32+
2633
internal static StringBuilder GetRawContentHeader(HttpResponseMessage response)
2734
{
2835
StringBuilder raw = new();
@@ -133,10 +140,13 @@ internal static bool IsXml([NotNullWhen(true)] string? contentType)
133140
|| contentType.Equals("application/xml-external-parsed-entity", StringComparison.OrdinalIgnoreCase)
134141
|| contentType.Equals("application/xml-dtd", StringComparison.OrdinalIgnoreCase)
135142
|| contentType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase);
136-
143+
137144
return isXml;
138145
}
139146

147+
internal static bool IsTextBasedContentType([NotNullWhen(true)] string? contentType)
148+
=> IsText(contentType) || IsJson(contentType) || IsXml(contentType);
149+
140150
#endregion Internal Methods
141151
}
142152
}

src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/InvokeRestMethodCommand.Common.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,22 @@ internal override void ProcessResponse(HttpResponseMessage response)
9999
string? characterSet = WebResponseHelper.GetCharacterSet(response);
100100
string str = StreamHelper.DecodeStream(responseStream, characterSet, out Encoding encoding, perReadTimeout, _cancelToken.Token);
101101

102-
string encodingVerboseName;
102+
string friendlyName = "unknown";
103+
string encodingWebName = "unknown";
104+
string encodingPage = encoding.CodePage == -1 ? "unknown" : encoding.CodePage.ToString();
103105
try
104106
{
105-
encodingVerboseName = encoding.HeaderName;
107+
// NOTE: These are getter methods that may possibly throw a NotSupportedException exception,
108+
// hence the try/catch
109+
encodingWebName = encoding.WebName;
110+
friendlyName = encoding.EncodingName;
106111
}
107112
catch
108113
{
109-
encodingVerboseName = string.Empty;
110114
}
111115

112-
// NOTE: Tests use this verbose output to verify the encoding.
113-
WriteVerbose($"Content encoding: {encodingVerboseName}");
116+
// NOTE: Tests use this debug output to verify the encoding.
117+
WriteDebug($"WebResponse content encoding: {encodingWebName} ({friendlyName}) CodePage: {encodingPage}");
114118

115119
// Determine the response type
116120
RestReturnType returnType = CheckReturnType(response);
@@ -140,7 +144,7 @@ internal override void ProcessResponse(HttpResponseMessage response)
140144

141145
responseStream.Position = 0;
142146
}
143-
147+
144148
if (ShouldSaveToOutFile)
145149
{
146150
string outFilePath = WebResponseHelper.GetOutFilePath(response, _qualifiedOutFile);

src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs

Lines changed: 217 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ public abstract class WebRequestPSCmdlet : PSCmdlet, IDisposable
9393
{
9494
#region Fields
9595

96+
/// <summary>
97+
/// Used to prefix the headers in debug and verbose messaging.
98+
/// </summary>
99+
internal const string DebugHeaderPrefix = "--- ";
100+
96101
/// <summary>
97102
/// Cancellation token source.
98103
/// </summary>
@@ -1280,40 +1285,27 @@ internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestM
12801285
_cancelToken = new CancellationTokenSource();
12811286
try
12821287
{
1283-
long requestContentLength = request.Content is null ? 0 : request.Content.Headers.ContentLength.Value;
1284-
1285-
string reqVerboseMsg = string.Format(
1286-
CultureInfo.CurrentCulture,
1287-
WebCmdletStrings.WebMethodInvocationVerboseMsg,
1288-
request.Version,
1289-
request.Method,
1290-
requestContentLength);
1291-
1292-
WriteVerbose(reqVerboseMsg);
1293-
1294-
string reqDebugMsg = string.Format(
1295-
CultureInfo.CurrentCulture,
1296-
WebCmdletStrings.WebRequestDebugMsg,
1297-
request.ToString());
1288+
if (IsWriteVerboseEnabled())
1289+
{
1290+
WriteWebRequestVerboseInfo(currentRequest);
1291+
}
12981292

1299-
WriteDebug(reqDebugMsg);
1293+
if (IsWriteDebugEnabled())
1294+
{
1295+
WriteWebRequestDebugInfo(currentRequest);
1296+
}
13001297

13011298
response = client.SendAsync(currentRequest, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult();
13021299

1303-
string contentType = ContentHelper.GetContentType(response);
1304-
long? contentLength = response.Content.Headers.ContentLength;
1305-
string respVerboseMsg = contentLength is null
1306-
? string.Format(CultureInfo.CurrentCulture, WebCmdletStrings.WebResponseNoSizeVerboseMsg, response.Version, contentType)
1307-
: string.Format(CultureInfo.CurrentCulture, WebCmdletStrings.WebResponseVerboseMsg, response.Version, contentLength, contentType);
1308-
1309-
WriteVerbose(respVerboseMsg);
1310-
1311-
string resDebugMsg = string.Format(
1312-
CultureInfo.CurrentCulture,
1313-
WebCmdletStrings.WebResponseDebugMsg,
1314-
response.ToString());
1300+
if (IsWriteVerboseEnabled())
1301+
{
1302+
WriteWebResponseVerboseInfo(response);
1303+
}
13151304

1316-
WriteDebug(resDebugMsg);
1305+
if (IsWriteDebugEnabled())
1306+
{
1307+
WriteWebResponseDebugInfo(response);
1308+
}
13171309
}
13181310
catch (TaskCanceledException ex)
13191311
{
@@ -1437,13 +1429,206 @@ internal virtual void UpdateSession(HttpResponseMessage response)
14371429
{
14381430
ArgumentNullException.ThrowIfNull(response);
14391431
}
1440-
14411432
#endregion Virtual Methods
14421433

14431434
#region Helper Methods
1444-
1435+
#nullable enable
14451436
internal static TimeSpan ConvertTimeoutSecondsToTimeSpan(int timeout) => timeout > 0 ? TimeSpan.FromSeconds(timeout) : Timeout.InfiniteTimeSpan;
14461437

1438+
private void WriteWebRequestVerboseInfo(HttpRequestMessage request)
1439+
{
1440+
try
1441+
{
1442+
// Typical Basic Example: 'WebRequest: v1.1 POST https://httpstat.us/200 with query length 6'
1443+
StringBuilder verboseBuilder = new(128);
1444+
1445+
// "Redact" the query string from verbose output, the details will be visible in Debug output
1446+
string uriWithoutQuery = request.RequestUri?.GetLeftPart(UriPartial.Path) ?? string.Empty;
1447+
verboseBuilder.Append($"WebRequest: v{request.Version} {request.Method} {uriWithoutQuery}");
1448+
if (request.RequestUri?.Query is not null && request.RequestUri.Query.Length > 1)
1449+
{
1450+
verboseBuilder.Append($" with query length {request.RequestUri.Query.Length - 1}");
1451+
}
1452+
1453+
string? requestContentType = ContentHelper.GetContentType(request);
1454+
if (requestContentType is not null)
1455+
{
1456+
verboseBuilder.Append($" with {requestContentType} payload");
1457+
}
1458+
1459+
long? requestContentLength = request.Content?.Headers?.ContentLength;
1460+
if (requestContentLength is not null)
1461+
{
1462+
verboseBuilder.Append($" with body size {ContentHelper.GetFriendlyContentLength(requestContentLength)}");
1463+
}
1464+
if (OutFile is not null)
1465+
{
1466+
verboseBuilder.Append($" output to {QualifyFilePath(OutFile)}");
1467+
}
1468+
1469+
WriteVerbose(verboseBuilder.ToString().Trim());
1470+
}
1471+
catch (Exception ex)
1472+
{
1473+
// Just in case there are any edge cases we missed, we don't break workflows with an exception
1474+
WriteVerbose($"Failed to Write WebRequest Verbose Info: {ex} {ex.StackTrace}");
1475+
}
1476+
}
1477+
1478+
private void WriteWebRequestDebugInfo(HttpRequestMessage request)
1479+
{
1480+
try
1481+
{
1482+
// Typical basic example:
1483+
// WebRequest Detail
1484+
// ---QUERY
1485+
// test = 5
1486+
// --- HEADERS
1487+
// User - Agent: Mozilla / 5.0, (Linux;Ubuntu 24.04.2 LTS;en - US), PowerShell / 7.6.0
1488+
StringBuilder debugBuilder = new("WebRequest Detail" + Environment.NewLine, 512);
1489+
1490+
if (!string.IsNullOrEmpty(request.RequestUri?.Query))
1491+
{
1492+
debugBuilder.Append(DebugHeaderPrefix).AppendLine("QUERY");
1493+
string[] queryParams = request.RequestUri.Query.TrimStart('?').Split('&');
1494+
debugBuilder
1495+
.AppendJoin(Environment.NewLine, queryParams)
1496+
.AppendLine()
1497+
.AppendLine();
1498+
}
1499+
1500+
debugBuilder.Append(DebugHeaderPrefix).AppendLine("HEADERS");
1501+
1502+
foreach (var headerSet in new HttpHeaders?[] { request.Headers, request.Content?.Headers })
1503+
{
1504+
if (headerSet is null)
1505+
{
1506+
continue;
1507+
}
1508+
1509+
debugBuilder.AppendLine(headerSet.ToString());
1510+
}
1511+
1512+
if (request.Content is not null)
1513+
{
1514+
debugBuilder
1515+
.Append(DebugHeaderPrefix).AppendLine("BODY")
1516+
.AppendLine(request.Content switch
1517+
{
1518+
StringContent stringContent => stringContent
1519+
.ReadAsStringAsync(_cancelToken.Token)
1520+
.GetAwaiter().GetResult(),
1521+
MultipartFormDataContent multipartContent => "=> Multipart Form Content"
1522+
+ Environment.NewLine
1523+
+ multipartContent.ReadAsStringAsync(_cancelToken.Token)
1524+
.GetAwaiter().GetResult(),
1525+
ByteArrayContent byteContent => InFile is not null
1526+
? "[Binary content: "
1527+
+ ContentHelper.GetFriendlyContentLength(byteContent.Headers.ContentLength)
1528+
+ "]"
1529+
: byteContent.ReadAsStringAsync(_cancelToken.Token).GetAwaiter().GetResult(),
1530+
StreamContent streamContent =>
1531+
"[Stream content: " + ContentHelper.GetFriendlyContentLength(streamContent.Headers.ContentLength) + "]",
1532+
_ => "[Unknown content type]",
1533+
})
1534+
.AppendLine();
1535+
}
1536+
1537+
WriteDebug(debugBuilder.ToString().Trim());
1538+
}
1539+
catch (Exception ex)
1540+
{
1541+
// Just in case there are any edge cases we missed, we don't break workflows with an exception
1542+
WriteVerbose($"Failed to Write WebRequest Debug Info: {ex} {ex.StackTrace}");
1543+
}
1544+
}
1545+
1546+
private void WriteWebResponseVerboseInfo(HttpResponseMessage response)
1547+
{
1548+
try
1549+
{
1550+
// Typical basic example: WebResponse: 200 OK with text/plain payload body size 6 B (6 bytes)
1551+
StringBuilder verboseBuilder = new(128);
1552+
verboseBuilder.Append($"WebResponse: {(int)response.StatusCode} {response.ReasonPhrase ?? response.StatusCode.ToString()}");
1553+
1554+
string? responseContentType = ContentHelper.GetContentType(response);
1555+
if (responseContentType is not null)
1556+
{
1557+
verboseBuilder.Append($" with {responseContentType} payload");
1558+
}
1559+
1560+
long? responseContentLength = response.Content?.Headers?.ContentLength;
1561+
if (responseContentLength is not null)
1562+
{
1563+
verboseBuilder.Append($" with body size {ContentHelper.GetFriendlyContentLength(responseContentLength)}");
1564+
}
1565+
1566+
WriteVerbose(verboseBuilder.ToString().Trim());
1567+
}
1568+
catch (Exception ex)
1569+
{
1570+
// Just in case there are any edge cases we missed, we don't break workflows with an exception
1571+
WriteVerbose($"Failed to Write WebResponse Verbose Info: {ex} {ex.StackTrace}");
1572+
}
1573+
}
1574+
1575+
private void WriteWebResponseDebugInfo(HttpResponseMessage response)
1576+
{
1577+
try
1578+
{
1579+
// Typical basic example
1580+
// WebResponse Detail
1581+
// --- HEADERS
1582+
// Date: Fri, 09 May 2025 18:06:44 GMT
1583+
// Server: Kestrel
1584+
// Set-Cookie: ARRAffinity=ee0b467f95b53d8dcfe48aeeb4173f93cf819be6e4721f434341647f4695039d;Path=/;HttpOnly;Secure;Domain=httpstat.us, ARRAffinitySameSite=ee0b467f95b53d8dcfe48aeeb4173f93cf819be6e4721f434341647f4695039d;Path=/;HttpOnly;SameSite=None;Secure;Domain=httpstat.us
1585+
// Strict-Transport-Security: max-age=2592000
1586+
// Request-Context: appId=cid-v1:3548b0f5-7f75-492f-82bb-b6eb0e864e53
1587+
// Content-Length: 6
1588+
// Content-Type: text/plain
1589+
// --- BODY
1590+
// 200 OK
1591+
StringBuilder debugBuilder = new("WebResponse Detail" + Environment.NewLine, 512);
1592+
1593+
debugBuilder.Append(DebugHeaderPrefix).AppendLine("HEADERS");
1594+
1595+
foreach (var headerSet in new HttpHeaders?[] { response.Headers, response.Content?.Headers })
1596+
{
1597+
if (headerSet is null)
1598+
{
1599+
continue;
1600+
}
1601+
1602+
debugBuilder.AppendLine(headerSet.ToString());
1603+
}
1604+
1605+
if (response.Content is not null)
1606+
{
1607+
debugBuilder.Append(DebugHeaderPrefix).AppendLine("BODY");
1608+
1609+
if (ContentHelper.IsTextBasedContentType(ContentHelper.GetContentType(response)))
1610+
{
1611+
debugBuilder.AppendLine(
1612+
response.Content.ReadAsStringAsync(_cancelToken.Token)
1613+
.GetAwaiter().GetResult());
1614+
}
1615+
else
1616+
{
1617+
string friendlyContentLength = ContentHelper.GetFriendlyContentLength(
1618+
response.Content?.Headers?.ContentLength);
1619+
debugBuilder.AppendLine($"[Binary content: {friendlyContentLength}]");
1620+
}
1621+
}
1622+
1623+
WriteDebug(debugBuilder.ToString().Trim());
1624+
}
1625+
catch (Exception ex)
1626+
{
1627+
// Just in case there are any edge cases we missed, we don't break workflows with an exception
1628+
WriteVerbose($"Failed to Write WebResponse Debug Info: {ex} {ex.StackTrace}");
1629+
}
1630+
}
1631+
14471632
private Uri PrepareUri(Uri uri)
14481633
{
14491634
uri = CheckProtocol(uri);
@@ -1478,6 +1663,7 @@ private static Uri CheckProtocol(Uri uri)
14781663

14791664
return uri.IsAbsoluteUri ? uri : new Uri("http://" + uri.OriginalString);
14801665
}
1666+
#nullable restore
14811667

14821668
private string QualifyFilePath(string path) => PathUtils.ResolveFilePath(filePath: path, command: this, isLiteralPath: true);
14831669

src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -234,21 +234,9 @@
234234
<data name="FollowingRelLinkVerboseMsg" xml:space="preserve">
235235
<value>Following rel link {0}</value>
236236
</data>
237-
<data name="WebMethodInvocationVerboseMsg" xml:space="preserve">
238-
<value>Requested HTTP/{0} {1} with {2}-byte payload</value>
239-
</data>
240237
<data name="WebMethodResumeFailedVerboseMsg" xml:space="preserve">
241238
<value>The remote server indicated it could not resume downloading. The local file will be overwritten.</value>
242239
</data>
243-
<data name="WebResponseVerboseMsg" xml:space="preserve">
244-
<value>Received HTTP/{0} {1}-byte response of content type {2}</value>
245-
</data>
246-
<data name="WebRequestDebugMsg" xml:space="preserve">
247-
<value>Request {0}</value>
248-
</data>
249-
<data name="WebResponseDebugMsg" xml:space="preserve">
250-
<value>Response {0}</value>
251-
</data>
252240
<data name="WebResponseNoSizeVerboseMsg" xml:space="preserve">
253241
<value>Received HTTP/{0} response of content type {1} of unknown size</value>
254242
</data>

0 commit comments

Comments
 (0)