Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ The repository follows standard .NET coding conventions defined in the `.editorc
- Insert final newline
- Prefer braces even for single-line blocks

### Comments

- **ASCII only**: Comments must not contain non-ASCII characters. Use plain ASCII substitutes: `-` (not an em/en dash), `->` (not an arrow), `"` `'` (not smart quotes), `...` (not an ellipsis character).
- **Describe the current state, not the history**: A comment should explain what the code does now. Do not narrate what the code "used to" do, what changed, or reference a prior implementation as history. (Rationale for a choice, per the next point, is allowed - phrase it in terms of the alternative, not the past.)
- **Explain non-obvious "why", not the "what"**: Only comment where the reasoning cannot be inferred from the code. The most valuable comment explains why a more complicated implementation was chosen when a simpler one would cause a regression (compatibility, performance, correctness, etc.). Describe the concrete regression the simpler approach would cause.
- Do not comment self-explanatory code.

### Native Code (C/C++)

Native code follows similar conventions:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;

namespace Microsoft.Diagnostics.Monitoring.EventPipe
{
// Counter tags arrive from MetricsEventSource as a single string. Newer event versions escape
// each key and value so a ',' or '=' inside a key/value is distinguishable from the ',' that
// separates pairs and the '=' that separates a key from its value:
// '\' -> '\\' ',' -> '\,' '=' -> '\='

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggested an alternative encoding scheme in the runtime PR so if use that we'd need to update the decoder to match.

// This mirrors the runtime encoder (System.Diagnostics.Helpers.FormatTags). The escaped form is
// a transport detail; it must be decoded before tags are shown to a user or written to output.
internal static class CounterTagFormatter
{
// Splits an escaped tag string into its unescaped key/value pairs. Tolerant of malformed
// input (never throws in release) because it runs on data received over a diagnostic channel.
// Canonical input escapes '\', ',' and '=' inside a key/value and separates pairs with a bare
// ',' and a key from its value with the first bare '='. Three shapes that canonical input can
// never produce are still handled so a mismatched or old encoder cannot crash decoding:
// - '\' before any character other than '\', ',' or '=' (e.g. "\a"): the '\' is dropped and
// the next character kept. A Debug.Assert flags this as an encoder/decoder mismatch.
// - a further bare '=' once inside a value (e.g. the second '=' in "k=val=bad"): appended to
// the value verbatim rather than starting another split.
// - a lone trailing '\': kept literally.
public static List<KeyValuePair<string, string>> Decode(string tags)
{
if (string.IsNullOrEmpty(tags))
{
return [];
}

// With no backslash there are no escape sequences, so every '=' and ',' is a separator and
// each key/value is a verbatim substring of the input. This is the common case (most tags
// contain no special characters), and slicing it avoids the two StringBuilders and the
// per-character copy the escaped path needs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd optimize for code simplicity here, not minimal allocations. These tools aren't designed for the volume of metrics where a few extra allocations per metric would have measurable impact. I'd suggest don't dual version the decode, just write it once including the escape sequence handling.

if (tags.IndexOf('\\') < 0)
{
return DecodeUnescaped(tags);
}

return DecodeEscaped(tags);
}

private static List<KeyValuePair<string, string>> DecodeUnescaped(string tags)
{
List<KeyValuePair<string, string>> result = [];

ReadOnlySpan<char> remaining = tags;
while (true)
{
int comma = remaining.IndexOf(',');
ReadOnlySpan<char> pair = comma < 0 ? remaining : remaining.Slice(0, comma);

int separator = pair.IndexOf('=');
if (separator < 0)
{
result.Add(new KeyValuePair<string, string>(pair.ToString(), string.Empty));
}
else
{
result.Add(new KeyValuePair<string, string>(pair.Slice(0, separator).ToString(), pair.Slice(separator + 1).ToString()));
}

if (comma < 0)
{
break;
}

remaining = remaining.Slice(comma + 1);
}

return result;
}

private static List<KeyValuePair<string, string>> DecodeEscaped(string tags)
{
List<KeyValuePair<string, string>> result = [];

StringBuilder key = new();
StringBuilder value = new();
bool isTokenizingValue = false;

for (int parseCursor = 0; parseCursor < tags.Length; parseCursor++)
{
char c = tags[parseCursor];
if (c == '\\' && parseCursor + 1 < tags.Length)
{
parseCursor++;
char escapedChar = tags[parseCursor];
Debug.Assert(escapedChar is '\\' or ',' or '=', $"Unexpected escape sequence '\\{escapedChar}' in tag string '{tags}'.");
(isTokenizingValue ? value : key).Append(escapedChar);
}
else if (c == '=' && !isTokenizingValue)
{
isTokenizingValue = true;
}
else if (c == ',')
{
result.Add(new KeyValuePair<string, string>(key.ToString(), value.ToString()));
key.Clear();
value.Clear();
isTokenizingValue = false;
}
else
{
(isTokenizingValue ? value : key).Append(c);
}
}

result.Add(new KeyValuePair<string, string>(key.ToString(), value.ToString()));
return result;
}

// Converts a tag string read from a trace event into the escaped form the rest of the
// pipeline expects. Payloads from newer events are already escaped. Older payloads are
// re-escaped here so a later Decode does not swallow a literal '\', ',' or '=' that an old
// runtime emitted unescaped (for example a value of "C:\temp").
public static string Normalize(string tags, bool escaped)
{
if (string.IsNullOrEmpty(tags))
{
return string.Empty;
}

return escaped ? tags : Encode(ParseLegacy(tags));
}

private static string Encode(List<KeyValuePair<string, string>> pairs)
{
StringBuilder builder = new();
for (int i = 0; i < pairs.Count; i++)
{
if (i > 0)
{
builder.Append(',');
}

AppendEscaped(builder, pairs[i].Key);
builder.Append('=');
AppendEscaped(builder, pairs[i].Value);
}

return builder.ToString();
}

// Legacy (unescaped) tag strings split naively: pairs on ',', key/value on the first '='.
// A value that itself contained ',' or '=' was already indistinguishable in this format, so
// that ambiguity is inherited here rather than introduced.
private static List<KeyValuePair<string, string>> ParseLegacy(string tags)
{
List<KeyValuePair<string, string>> pairs = new();
foreach (string pair in tags.Split(','))
{
int separator = pair.IndexOf('=');
if (separator < 0)
{
pairs.Add(new KeyValuePair<string, string>(pair, string.Empty));
}
else
{
pairs.Add(new KeyValuePair<string, string>(pair.Substring(0, separator), pair.Substring(separator + 1)));
}
}

return pairs;
}

private static void AppendEscaped(StringBuilder builder, string value)
{
if (string.IsNullOrEmpty(value))
{
return;
}

foreach (char c in value)
{
if (c is '\\' or ',' or '=')
{
builder.Append('\\');
}

builder.Append(c);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ private static void HandleGauge(TraceEvent obj, CounterMetadataCache counterMeta
//string meterVersion = (string)obj.PayloadValue(2);
string instrumentName = (string)obj.PayloadValue(3);
//string unit = (string)obj.PayloadValue(4);
string tags = (string)obj.PayloadValue(5);
string tags = CounterTagFormatter.Normalize((string)obj.PayloadValue(5), escaped: obj.Version >= 3);
string lastValueText = (string)obj.PayloadValue(6);
int? id = null;

Expand Down Expand Up @@ -313,8 +313,8 @@ private static void HandleBeginInstrumentReporting(TraceEvent traceEvent, Counte
// string instrumentType = (string)traceEvent.PayloadValue(4);
instrumentUnit = (string)traceEvent.PayloadValue(5);
instrumentDescription = (string)traceEvent.PayloadValue(6);
instrumentTags = (string)traceEvent.PayloadValue(7);
meterTags = (string)traceEvent.PayloadValue(8);
instrumentTags = CounterTagFormatter.Normalize((string)traceEvent.PayloadValue(7), escaped: traceEvent.Version >= 4);
meterTags = CounterTagFormatter.Normalize((string)traceEvent.PayloadValue(8), escaped: traceEvent.Version >= 4);
meterScopeHash = (string)traceEvent.PayloadValue(9);
}
if (traceEvent.Version >= 2)
Expand Down Expand Up @@ -354,7 +354,7 @@ private static void HandleCounterRate(TraceEvent traceEvent, CounterMetadataCach
//string meterVersion = (string)obj.PayloadValue(2);
string instrumentName = (string)traceEvent.PayloadValue(3);
//string unit = (string)traceEvent.PayloadValue(4);
string tags = (string)traceEvent.PayloadValue(5);
string tags = CounterTagFormatter.Normalize((string)traceEvent.PayloadValue(5), escaped: traceEvent.Version >= 3);
string rateText = (string)traceEvent.PayloadValue(6);
//Starting in .NET 8 we also publish the absolute value of these counters
string absoluteValueText = null;
Expand Down Expand Up @@ -410,7 +410,7 @@ private static void HandleUpDownCounterValue(TraceEvent traceEvent, CounterMetad
//string meterVersion = (string)obj.PayloadValue(2);
string instrumentName = (string)traceEvent.PayloadValue(3);
//string unit = (string)traceEvent.PayloadValue(4);
string tags = (string)traceEvent.PayloadValue(5);
string tags = CounterTagFormatter.Normalize((string)traceEvent.PayloadValue(5), escaped: traceEvent.Version >= 3);
string rateText = (string)traceEvent.PayloadValue(6);
string valueText = (string)traceEvent.PayloadValue(7);
int? id = null;
Expand Down Expand Up @@ -460,7 +460,7 @@ private static void HandleHistogram(TraceEvent obj, CounterMetadataCache counter
//string meterVersion = (string)obj.PayloadValue(2);
string instrumentName = (string)obj.PayloadValue(3);
//string unit = (string)obj.PayloadValue(4);
string tags = (string)obj.PayloadValue(5);
string tags = CounterTagFormatter.Normalize((string)obj.PayloadValue(5), escaped: obj.Version >= 3);
string quantilesText = (string)obj.PayloadValue(6);

int count;
Expand Down
69 changes: 60 additions & 9 deletions src/Tools/dotnet-counters/Exporters/CSVExporter.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.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
Expand Down Expand Up @@ -69,24 +70,74 @@ public void CounterPayloadReceived(CounterPayload payload, bool _)
builder.Clear();
}

builder
.Append(payload.Timestamp.ToString()).Append(',')
.Append(payload.CounterMetadata.ProviderName).Append(',')
.Append(payload.GetDisplay());

string counterName = payload.GetDisplay();
string tags = payload.CombineTags();
if (!string.IsNullOrEmpty(tags))
{
builder.Append('[').Append(tags.Replace(',', ';')).Append(']');
counterName += "[" + FormatTags(tags, payload.IsMeter) + "]";
}
builder.Append(',')
.Append(payload.CounterType).Append(',')
.Append(payload.Value.ToString(CultureInfo.InvariantCulture)).Append('\n');

AppendField(builder, payload.Timestamp.ToString());
builder.Append(',');
AppendField(builder, payload.CounterMetadata.ProviderName);
builder.Append(',');
AppendField(builder, counterName);
builder.Append(',');
AppendField(builder, payload.CounterType.ToString());
builder.Append(',');
AppendField(builder, payload.Value.ToString(CultureInfo.InvariantCulture));
builder.Append('\n');
}
}

public void CounterStopped(CounterPayload payload) { }

// Renders decoded tags into the single "[key=value;key=value]" column this exporter writes.
// Pairs are separated by ';'. A decoded key or value may still contain a real ',': it is kept
// as-is here because AppendField quotes the whole field per RFC 4180, so the comma cannot spill
// into the next column.
private static string FormatTags(string tags, bool isMeter)
{
if (!isMeter)
{
return tags.Replace(',', ';');
}

StringBuilder sb = new();
foreach (KeyValuePair<string, string> tag in CounterTagFormatter.Decode(tags))
{
if (sb.Length > 0)
{
sb.Append(';');
}

sb.Append(tag.Key).Append('=').Append(tag.Value);
}

return sb.ToString();
}

// Appends one field using RFC 4180 quoting: a field containing ',', '"', CR or LF is wrapped in
// double quotes with any embedded '"' doubled. Every field is written through this so a comma in
// a tag value, provider name, or counter display name cannot corrupt the row.
private static void AppendField(StringBuilder builder, string field)
{
if (string.IsNullOrEmpty(field))
{
return;
}

if (field.IndexOfAny(s_csvSpecialCharacters) < 0)
{
builder.Append(field);
return;
}

builder.Append('"').Append(field.Replace("\"", "\"\"")).Append('"');
}

private static readonly char[] s_csvSpecialCharacters = [',', '"', '\r', '\n'];

public void Stop()
{
string outputString;
Expand Down
28 changes: 17 additions & 11 deletions src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ public ObservedProvider(string name)
/// <summary>Information about an observed counter.</summary>
private class ObservedCounter
{
public ObservedCounter(string displayName) => DisplayName = displayName;
public ObservedCounter(string displayName, bool isMeter)
{
DisplayName = displayName;
IsMeter = isMeter;
}

public string DisplayName { get; } // Display name for this counter.
public bool IsMeter { get; }
public int Row { get; set; } // Assigned row for this counter. May change during operation.
public Dictionary<string, ObservedTagSet> TagSets { get; } = new Dictionary<string, ObservedTagSet>();

Expand Down Expand Up @@ -218,21 +224,21 @@ private bool RenderTagSetsInColumnMode(ref int row, ObservedCounter counter)
int tagsCount = 0;
foreach (ObservedTagSet tagSet in counter.TagSets.Values.OrderBy(t => t.Tags))
{
string[] tags = tagSet.DisplayTags.Split(',');
for (int i = 0; i < tags.Length; i++)
IEnumerable<KeyValuePair<string, string>> tags = counter.IsMeter
? CounterTagFormatter.Decode(tagSet.Tags)
: CounterUtilities.GetMetadata(tagSet.Tags);
foreach ((string tagKey, string tagValue) in tags)
{
string tag = tags[i];
string[] keyValue = tag.Split("=");
int posTag = observedTags.FindIndex (tag => tag.header == keyValue[0]);
int posTag = observedTags.FindIndex(tag => tag.header == tagKey);
if (posTag == -1)
{
observedTags.Add((keyValue[0], new string[counter.TagSets.Count]));
columnHeaderLen.Add(keyValue[0].Length);
observedTags.Add((tagKey, new string[counter.TagSets.Count]));
columnHeaderLen.Add(tagKey.Length);
maxValueColumnLen.Add(default(int));
posTag = observedTags.Count - 1;
}
observedTags[posTag].values[tagsCount] = keyValue[1];
maxValueColumnLen[posTag] = Math.Max(keyValue[1].Length, maxValueColumnLen[posTag]);
observedTags[posTag].values[tagsCount] = tagValue;
maxValueColumnLen[posTag] = Math.Max(tagValue.Length, maxValueColumnLen[posTag]);
}
tagsCount++;
}
Expand Down Expand Up @@ -359,7 +365,7 @@ public void CounterPayloadReceived(CounterPayload payload, bool pauseCmdSet)
if (!provider.Counters.TryGetValue(name, out ObservedCounter counter))
{
string displayName = payload.GetDisplay();
provider.Counters[name] = counter = new ObservedCounter(displayName);
provider.Counters[name] = counter = new ObservedCounter(displayName, payload.IsMeter);
redraw = true;
}
else
Expand Down
Loading