From 19fd00d3ef84000f2c09dee63b4437002cfe25f3 Mon Sep 17 00:00:00 2001 From: Juan Hoyos <19413848+hoyosjs@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:46:22 -0700 Subject: [PATCH 1/2] Decode escaped counter tags at output boundaries (#5935) MetricsEventSource escapes counter tag keys/values in newer event versions ('\'->'\\', ','->'\,', '='->'\='). Add CounterTagFormatter to mirror the runtime encoder: Normalize re-escapes legacy payloads at ingestion, Decode unescapes at each output boundary. - Fix ConsoleWriter.RenderTagSetsInColumnMode IndexOutOfRange on tags containing '=' or ','. - JSON/CSV/Console exporters decode tags to their real key/value pairs. - CSVExporter is now RFC 4180 compliant: every field is quoted when it contains ',', '"', CR or LF, so a comma in a tag value, provider name or display name no longer spills into the next column. - JSONExporter escapes all control chars below U+0020 as \u00XX so a control char in a tag value cannot produce invalid JSON. - Decode has a no-backslash fast path that slices key/values from the source string, avoiding StringBuilders on the common (unescaped) case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 7 + .../Counters/CounterTagFormatter.cs | 190 ++++++++++++++++++ .../Counters/TraceEventExtensions.cs | 12 +- .../dotnet-counters/Exporters/CSVExporter.cs | 64 +++++- .../Exporters/ConsoleWriter.cs | 15 +- .../dotnet-counters/Exporters/JSONExporter.cs | 59 +++++- .../CounterTagFormatterTests.cs | 118 +++++++++++ src/tests/dotnet-counters/CSVExporterTests.cs | 123 ++++++++++++ .../dotnet-counters/ConsoleExporterTests.cs | 23 ++- .../dotnet-counters/JSONExporterTests.cs | 69 ++++++- 10 files changed, 645 insertions(+), 35 deletions(-) create mode 100644 src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/CounterTagFormatter.cs create mode 100644 src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/CounterTagFormatterTests.cs diff --git a/AGENTS.md b/AGENTS.md index f48dcd3802..75f0a66a5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/CounterTagFormatter.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/CounterTagFormatter.cs new file mode 100644 index 0000000000..4d1935c842 --- /dev/null +++ b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/CounterTagFormatter.cs @@ -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: + // '\' -> '\\' ',' -> '\,' '=' -> '\=' + // 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> 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. + if (tags.IndexOf('\\') < 0) + { + return DecodeUnescaped(tags); + } + + return DecodeEscaped(tags); + } + + private static List> DecodeUnescaped(string tags) + { + List> result = []; + + ReadOnlySpan remaining = tags; + while (true) + { + int comma = remaining.IndexOf(','); + ReadOnlySpan pair = comma < 0 ? remaining : remaining.Slice(0, comma); + + int separator = pair.IndexOf('='); + if (separator < 0) + { + result.Add(new KeyValuePair(pair.ToString(), string.Empty)); + } + else + { + result.Add(new KeyValuePair(pair.Slice(0, separator).ToString(), pair.Slice(separator + 1).ToString())); + } + + if (comma < 0) + { + break; + } + + remaining = remaining.Slice(comma + 1); + } + + return result; + } + + private static List> DecodeEscaped(string tags) + { + List> 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(key.ToString(), value.ToString())); + key.Clear(); + value.Clear(); + isTokenizingValue = false; + } + else + { + (isTokenizingValue ? value : key).Append(c); + } + } + + result.Add(new KeyValuePair(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> 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> ParseLegacy(string tags) + { + List> pairs = new(); + foreach (string pair in tags.Split(',')) + { + int separator = pair.IndexOf('='); + if (separator < 0) + { + pairs.Add(new KeyValuePair(pair, string.Empty)); + } + else + { + pairs.Add(new KeyValuePair(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); + } + } + } +} diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/TraceEventExtensions.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/TraceEventExtensions.cs index 01bc977816..512eaae9ef 100644 --- a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/TraceEventExtensions.cs +++ b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/TraceEventExtensions.cs @@ -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; @@ -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) @@ -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; @@ -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; @@ -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; diff --git a/src/Tools/dotnet-counters/Exporters/CSVExporter.cs b/src/Tools/dotnet-counters/Exporters/CSVExporter.cs index 49d1405896..3a7a60ed89 100644 --- a/src/Tools/dotnet-counters/Exporters/CSVExporter.cs +++ b/src/Tools/dotnet-counters/Exporters/CSVExporter.cs @@ -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; @@ -69,24 +70,69 @@ 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) + "]"; } - 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) + { + StringBuilder sb = new(); + foreach (KeyValuePair 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; diff --git a/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs b/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs index 83ed70ad5e..059ac74f10 100644 --- a/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs +++ b/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs @@ -218,21 +218,18 @@ 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++) + foreach ((string tagKey, string tagValue) in CounterTagFormatter.Decode(tagSet.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++; } diff --git a/src/Tools/dotnet-counters/Exporters/JSONExporter.cs b/src/Tools/dotnet-counters/Exporters/JSONExporter.cs index 66bee3a760..f6736d7af7 100644 --- a/src/Tools/dotnet-counters/Exporters/JSONExporter.cs +++ b/src/Tools/dotnet-counters/Exporters/JSONExporter.cs @@ -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; @@ -75,16 +76,34 @@ public void CounterPayloadReceived(CounterPayload payload, bool _) .Append("{ \"timestamp\": \"").Append(DateTime.Now.ToString("O")).Append("\", ") .Append(" \"provider\": \"").Append(JsonEscape(payload.CounterMetadata.ProviderName)).Append("\", ") .Append(" \"name\": \"").Append(JsonEscape(payload.GetDisplay())).Append("\", ") - .Append(" \"tags\": \"").Append(JsonEscape(payload.ValueTags)).Append("\", ") + .Append(" \"tags\": \"").Append(JsonEscape(FormatTags(payload.ValueTags))).Append("\", ") .Append(" \"counterType\": \"").Append(JsonEscape(payload.CounterType.ToString())).Append("\", ") - .Append(" \"meterTags\": \"").Append(JsonEscape(payload.CounterMetadata.MeterTags)).Append("\", ") - .Append(" \"instrumentTags\": \"").Append(JsonEscape(payload.CounterMetadata.InstrumentTags)).Append("\", ") + .Append(" \"meterTags\": \"").Append(JsonEscape(FormatTags(payload.CounterMetadata.MeterTags))).Append("\", ") + .Append(" \"instrumentTags\": \"").Append(JsonEscape(FormatTags(payload.CounterMetadata.InstrumentTags))).Append("\", ") .Append(" \"value\": ").Append(payload.Value.ToString(CultureInfo.InvariantCulture)).Append(" },"); } } public void CounterStopped(CounterPayload payload) { } + // Renders decoded tags as the flat "key=value,key=value" string this exporter emits. The values + // are unescaped; JSON string quoting handles any ',' or '=' they contain. + private static string FormatTags(string tags) + { + StringBuilder sb = new(); + foreach (KeyValuePair tag in CounterTagFormatter.Decode(tags)) + { + if (sb.Length > 0) + { + sb.Append(','); + } + + sb.Append(tag.Key).Append('=').Append(tag.Value); + } + + return sb.ToString(); + } + public void Stop() { lock (_lock) @@ -97,8 +116,10 @@ public void Stop() Console.WriteLine("File saved to " + _output); } - private static readonly char[] s_escapeChars = new char[] { '"', '\n', '\r', '\t', '\\', '\b', '\f' }; - + // Escapes a string for embedding in a JSON string literal. The named short escapes are used for + // the common control characters; every other character below U+0020 is emitted as \u00XX because + // JSON (RFC 8259) forbids raw control characters in a string and a strict parser would otherwise + // reject the document. private static string JsonEscape(string input) { if (input is null) @@ -106,8 +127,7 @@ private static string JsonEscape(string input) return string.Empty; } - int offset = input.IndexOfAny(s_escapeChars); - if (offset == -1) + if (IndexOfEscapable(input) == -1) { // fast path return input; @@ -144,11 +164,34 @@ private static string JsonEscape(string input) sb.Append("\\f"); break; default: - sb.Append(c); + if (c < '\u0020') + { + sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + sb.Append(c); + } break; } } return sb.ToString(); } + + // Returns the index of the first character that must be escaped in a JSON string, or -1 if none. + // '"' and '\' are structural; every character below U+0020 is a control character JSON forbids raw. + private static int IndexOfEscapable(string input) + { + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + if (c == '"' || c == '\\' || c < '\u0020') + { + return i; + } + } + + return -1; + } } } diff --git a/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/CounterTagFormatterTests.cs b/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/CounterTagFormatterTests.cs new file mode 100644 index 0000000000..8e4e53be06 --- /dev/null +++ b/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/CounterTagFormatterTests.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Diagnostics.Monitoring.EventPipe.UnitTests +{ + public class CounterTagFormatterTests + { + // These canonical strings must match the runtime encoder + // (System.Diagnostics.Helpers.FormatTags / AppendEscaped) byte-for-byte. + [Theory] + [InlineData("plain=simple", "plain", "simple")] + [InlineData(@"comma=a\,b\,c", "comma", "a,b,c")] + [InlineData(@"equals=x\=1", "equals", "x=1")] + [InlineData(@"url=/api/items?filter\=red\,blue&sort\=name", "url", "/api/items?filter=red,blue&sort=name")] + [InlineData(@"path=C:\\temp", "path", @"C:\temp")] + public void Decode_SinglePair_UnescapesKeyAndValue(string encoded, string expectedKey, string expectedValue) + { + List> pairs = CounterTagFormatter.Decode(encoded); + + KeyValuePair pair = Assert.Single(pairs); + Assert.Equal(expectedKey, pair.Key); + Assert.Equal(expectedValue, pair.Value); + } + + [Fact] + public void Decode_MultiplePairs_SplitsOnUnescapedComma() + { + List> pairs = CounterTagFormatter.Decode(@"comma=a\,b\,c,equals=x\=1"); + + Assert.Equal(2, pairs.Count); + Assert.Equal(new KeyValuePair("comma", "a,b,c"), pairs[0]); + Assert.Equal(new KeyValuePair("equals", "x=1"), pairs[1]); + } + + [Fact] + public void Decode_EmptyString_ReturnsZeroPairs() + { + Assert.Empty(CounterTagFormatter.Decode(string.Empty)); + Assert.Empty(CounterTagFormatter.Decode(null)); + } + + [Theory] + [InlineData("key=", "key", "")] + [InlineData("=value", "", "value")] + [InlineData("=", "", "")] + public void Decode_EmptyKeyOrValue_Roundtrips(string encoded, string expectedKey, string expectedValue) + { + KeyValuePair pair = Assert.Single(CounterTagFormatter.Decode(encoded)); + Assert.Equal(expectedKey, pair.Key); + Assert.Equal(expectedValue, pair.Value); + } + + [Fact] + public void Decode_MissingSeparator_YieldsEmptyValue() + { + // A pair with no '=' must not throw (this was the original ConsoleWriter crash). + KeyValuePair pair = Assert.Single(CounterTagFormatter.Decode("keyonly")); + Assert.Equal("keyonly", pair.Key); + Assert.Equal(string.Empty, pair.Value); + } + + [Fact] + public void Decode_TrailingBackslash_TreatedAsLiteral() + { + // A lone trailing backslash never occurs in canonical output, but decode must be defensive. + KeyValuePair pair = Assert.Single(CounterTagFormatter.Decode(@"key=value\")); + Assert.Equal("key", pair.Key); + Assert.Equal(@"value\", pair.Value); + } + + [Fact] + public void Decode_ExtraUnescapedEqualsInValue_KeptLiterally() + { + // A second bare '=' inside a value never occurs in canonical output (it would be escaped + // as "\="), but decode keeps it verbatim in the value rather than throwing. + KeyValuePair pair = Assert.Single(CounterTagFormatter.Decode("k=val=bad")); + Assert.Equal("k", pair.Key); + Assert.Equal("val=bad", pair.Value); + } + + [Fact] + public void Normalize_Escaped_ReturnsInputUnchanged() + { + const string escaped = @"comma=a\,b\,c,equals=x\=1"; + Assert.Equal(escaped, CounterTagFormatter.Normalize(escaped, escaped: true)); + } + + [Fact] + public void Normalize_Legacy_ReEscapesSpecialCharacters() + { + // A '\' or '=' inside a legacy value must be escaped so a later Decode recovers it. + Assert.Equal(@"path=C:\\temp", CounterTagFormatter.Normalize(@"path=C:\temp", escaped: false)); + Assert.Equal(@"expr=a\=b", CounterTagFormatter.Normalize("expr=a=b", escaped: false)); + + // Simple values are unchanged. + Assert.Equal("a=1,b=2", CounterTagFormatter.Normalize("a=1,b=2", escaped: false)); + } + + [Fact] + public void Normalize_LegacyRoundTripsThroughDecode() + { + string canonical = CounterTagFormatter.Normalize(@"path=C:\temp", escaped: false); + KeyValuePair pair = Assert.Single(CounterTagFormatter.Decode(canonical)); + Assert.Equal("path", pair.Key); + Assert.Equal(@"C:\temp", pair.Value); + } + + [Fact] + public void Normalize_EmptyOrNull_ReturnsEmptyString() + { + Assert.Equal(string.Empty, CounterTagFormatter.Normalize(string.Empty, escaped: true)); + Assert.Equal(string.Empty, CounterTagFormatter.Normalize(null, escaped: false)); + } + } +} diff --git a/src/tests/dotnet-counters/CSVExporterTests.cs b/src/tests/dotnet-counters/CSVExporterTests.cs index a8c4d3decf..140db9271d 100644 --- a/src/tests/dotnet-counters/CSVExporterTests.cs +++ b/src/tests/dotnet-counters/CSVExporterTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using Microsoft.Diagnostics.Tools.Counters; using Microsoft.Diagnostics.Tools.Counters.Exporters; using Microsoft.Diagnostics.Monitoring.EventPipe; @@ -198,6 +199,83 @@ public void CounterTest_SameMeterDifferentTagsPerInstrument() } } + [Fact] + public void EscapedTagsAreDecoded() + { + // Tags arrive already escaped. The exporter decodes them to their real values ('\=' -> '=' + // and '\\' -> '\'). A decoded ',' is preserved and the whole Counter Name field is RFC 4180 + // quoted so the comma cannot spill into the next column. + string valueTags = @"filter=x\=1,region=us\,west,path=C:\\logs"; // filter=x=1, region=us,west, path=C:\logs + + string fileName = "EscapedTagsTest.csv"; + CSVExporter exporter = new(fileName); + exporter.Initialize(); + DateTime start = DateTime.Now; + + exporter.CounterPayloadReceived(new GaugePayload( + new CounterMetadata("myProvider", "counterOne", string.Empty, string.Empty), "Counter One", string.Empty, valueTags, 0, start + TimeSpan.FromSeconds(0)), false); + + exporter.Stop(); + + Assert.True(File.Exists(fileName)); + + try + { + List lines = File.ReadLines(fileName).ToList(); + Assert.Equal(2, lines.Count); // header + one row + + ValidateHeaderTokens(lines[0]); + + List tokens = SplitCsvLine(lines[1]); + Assert.Equal(5, tokens.Count); // the decoded ',' must not create an extra column + Assert.Equal("myProvider", tokens[1]); + Assert.Equal(@"Counter One[filter=x=1;region=us,west;path=C:\logs]", tokens[2]); + Assert.Equal("Metric", tokens[3]); + } + finally + { + File.Delete(fileName); + } + } + + [Fact] + public void SpecialCharactersAreCsvQuoted() + { + // A decoded tag value containing a ',' and a '"' forces RFC 4180 quoting of the field, + // with the embedded '"' doubled. The row must still parse back to exactly 5 fields. + string valueTags = @"note=a\,b" + "\"" + "c"; // decodes to note=a,b"c + + string fileName = "CsvQuotingTest.csv"; + CSVExporter exporter = new(fileName); + exporter.Initialize(); + DateTime start = DateTime.Now; + + exporter.CounterPayloadReceived(new GaugePayload( + new CounterMetadata("myProvider", "counterOne", string.Empty, string.Empty), "Counter One", string.Empty, valueTags, 0, start + TimeSpan.FromSeconds(0)), false); + + exporter.Stop(); + + Assert.True(File.Exists(fileName)); + + try + { + List lines = File.ReadLines(fileName).ToList(); + Assert.Equal(2, lines.Count); + + Assert.Contains("\"\"", lines[1]); // the embedded quote is doubled in the raw output + + List tokens = SplitCsvLine(lines[1]); + Assert.Equal(5, tokens.Count); + Assert.Equal("myProvider", tokens[1]); + Assert.Equal("Counter One[note=a,b\"c]", tokens[2]); + Assert.Equal("Metric", tokens[3]); + } + finally + { + File.Delete(fileName); + } + } + [Fact] public void DifferentDisplayRateTest() { @@ -358,5 +436,50 @@ internal static void ValidateHeaderTokens(string headerLine) Assert.Equal("Counter Type", headerTokens[TestConstants.CounterTypeIndex]); Assert.Equal("Mean/Increment", headerTokens[TestConstants.ValueIndex]); } + + // Splits one RFC 4180 CSV line, honoring double-quoted fields (embedded '""' is an escaped quote). + private static List SplitCsvLine(string line) + { + List fields = new(); + StringBuilder field = new(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inQuotes) + { + if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') + { + field.Append('"'); + i++; + } + else if (c == '"') + { + inQuotes = false; + } + else + { + field.Append(c); + } + } + else if (c == '"') + { + inQuotes = true; + } + else if (c == ',') + { + fields.Add(field.ToString()); + field.Clear(); + } + else + { + field.Append(c); + } + } + + fields.Add(field.ToString()); + return fields; + } } } diff --git a/src/tests/dotnet-counters/ConsoleExporterTests.cs b/src/tests/dotnet-counters/ConsoleExporterTests.cs index 12680effa1..30f1819d2b 100644 --- a/src/tests/dotnet-counters/ConsoleExporterTests.cs +++ b/src/tests/dotnet-counters/ConsoleExporterTests.cs @@ -288,6 +288,25 @@ public void LongMultidimensionalTagsAreTruncated() " hot 160"); } + [Fact] + public void EscapedTagsAreDecodedInColumnMode() + { + MockConsole console = new MockConsole(80, 40, _outputHelper); + ConsoleWriter exporter = new ConsoleWriter(console); + exporter.Initialize(); + + // Tags arrive already escaped (as normalized from the runtime payload). Values contain + // the structural characters ',' and '=', so they must be decoded rather than split: + // a naive Split(',')/Split('=') would crash or mangle these values. + exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter1", "{widget}", @"region=us\,west", 87), false); + exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter1", "{widget}", @"filter=x\=1", 5), false); + + string[] lines = console.Lines; + Assert.Contains(lines, line => line.Contains("us,west")); // comma inside a value decoded intact + Assert.Contains(lines, line => line.Contains("x=1")); // equals inside a value decoded intact + Assert.DoesNotContain(lines, line => line.Contains(@"\")); // no stray escape characters leak to the display + } + [Fact] public void CountersAreTruncatedBeyondScreenHeight() { @@ -573,7 +592,7 @@ public void NoAbbreviateValueGrowsOnIncrementalUpdate() " Offset (ms) 42"); // Second payload: same counter, unix ms timestamp. Incremental update only. - // Fits within the 21-char minimum column — no spill. + // Fits within the 21-char minimum column - no spill. exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "Offset", "ms", 1701200000000.0), false); console.AssertLinesEqual("Press p to pause, r to resume, q to quit.", " Status: Running", @@ -604,7 +623,7 @@ public void NoAbbreviateValueOverflowTriggersRedraw() " Offset (ms) 42"); // Second payload: same counter, value exceeds 21-char column (26 chars formatted). - // Incremental path detects overflow → full redraw with _counterValueLength=26. + // Incremental path detects overflow -> full redraw with _counterValueLength=26. exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "Offset", "ms", 17012000000000000.0), false); console.AssertLinesEqual("Press p to pause, r to resume, q to quit.", " Status: Running", diff --git a/src/tests/dotnet-counters/JSONExporterTests.cs b/src/tests/dotnet-counters/JSONExporterTests.cs index 929825df1d..6ad80a8f35 100644 --- a/src/tests/dotnet-counters/JSONExporterTests.cs +++ b/src/tests/dotnet-counters/JSONExporterTests.cs @@ -164,6 +164,37 @@ public void CounterTest_SameMeterDifferentTagsPerInstrument() } } + [Fact] + public void EscapedTagsAreDecoded() + { + // Tags arrive already escaped (as normalized from the runtime payload). The exporter + // must surface the DECODED (unescaped) values, never the transport escaping. + string meterTags = @"env=prod\=1"; // decodes to env=prod=1 + string instrumentTags = @"path=C:\\logs"; // decodes to path=C:\logs + string valueTags = @"status=ok\,done,region=us\,west"; // decodes to status=ok,done and region=us,west + + string fileName = "EscapedTagsTest.json"; + JSONExporter exporter = new(fileName, "myProcess.exe"); + exporter.Initialize(); + DateTime start = DateTime.Now; + + exporter.CounterPayloadReceived(new GaugePayload(new CounterMetadata("myProvider", "counterOne", meterTags, instrumentTags), "Counter One", string.Empty, valueTags, 1, start + TimeSpan.FromSeconds(1)), false); + + exporter.Stop(); + + Assert.True(File.Exists(fileName)); + using (StreamReader r = new(fileName)) + { + string json = r.ReadToEnd(); + JSONCounterTrace counterTrace = JsonConvert.DeserializeObject(json); + + JSONCounterPayload payload = Assert.Single(counterTrace.events); + Assert.Equal("status=ok,done,region=us,west", payload.tags); + Assert.Equal("env=prod=1", payload.meterTags); + Assert.Equal(@"path=C:\logs", payload.instrumentTags); + } + } + [Fact] public void DisplayUnitsTest() { @@ -281,11 +312,47 @@ public void EscapingTest() Assert.Equal("CounterOne\f", payload.name); Assert.Equal("Metric", payload.counterType); Assert.Equal(1.0, payload.value); - Assert.Equal("f\b\"\n=abc\r\\,\ttwo=9", payload.tags); + // Tags are decoded at the output boundary: the '\,' in the input is an escaped + // comma and decodes to a literal ',', so the backslash is consumed. All other + // characters (including control characters) are passed through and JSON-escaped. + Assert.Equal("f\b\"\n=abc\r,\ttwo=9", payload.tags); } } } + [Fact] + public void ControlCharactersAreEscapedForValidJson() + { + // A tag value with a control character other than the named short escapes (here U+0001) + // must be emitted as \u0001; a raw control character makes the document invalid under a + // strict JSON parser (RFC 8259). + string valueTags = "note=a" + (char)0x01 + "b"; // decodes to note=ab + + string fileName = "ControlCharTest.json"; + JSONExporter exporter = new(fileName, "myProcess.exe"); + exporter.Initialize(); + DateTime start = DateTime.Now; + exporter.CounterPayloadReceived(new GaugePayload(new CounterMetadata("myProvider", "counterOne", counterUnit: ""), "CounterOne", string.Empty, valueTags, 1, start), false); + exporter.Stop(); + + Assert.True(File.Exists(fileName)); + try + { + string json = File.ReadAllText(fileName); + Assert.Contains(@"\u0001", json); // escaped form is present + Assert.False(json.Contains((char)0x01)); // the raw control char never leaks (ordinal check) + + // A strict parser accepts the document and round-trips the value. + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); + System.Text.Json.JsonElement firstEvent = doc.RootElement.GetProperty("Events")[0]; + Assert.Equal("note=a\u0001b", firstEvent.GetProperty("tags").GetString()); + } + finally + { + File.Delete(fileName); + } + } + [Fact] public void PercentilesTest() { From 8c686c96dff64648db4ec30a77430dfc00d72f02 Mon Sep 17 00:00:00 2001 From: Juan Hoyos <19413848+hoyosjs@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:29:36 -0700 Subject: [PATCH 2/2] Preserve EventCounters metadata in exporters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52f40a78-0e0a-49cd-a98d-5970e0935969 --- .../dotnet-counters/Exporters/CSVExporter.cs | 9 ++++- .../Exporters/ConsoleWriter.cs | 15 ++++++-- .../dotnet-counters/Exporters/JSONExporter.cs | 13 +++++-- .../EventCounterTriggerTests.cs | 10 +++++ src/tests/dotnet-counters/CSVExporterTests.cs | 37 +++++++++++++++++++ .../dotnet-counters/ConsoleExporterTests.cs | 24 +++++++++++- .../dotnet-counters/JSONExporterTests.cs | 36 ++++++++++++++++++ 7 files changed, 133 insertions(+), 11 deletions(-) diff --git a/src/Tools/dotnet-counters/Exporters/CSVExporter.cs b/src/Tools/dotnet-counters/Exporters/CSVExporter.cs index 3a7a60ed89..4518ef1818 100644 --- a/src/Tools/dotnet-counters/Exporters/CSVExporter.cs +++ b/src/Tools/dotnet-counters/Exporters/CSVExporter.cs @@ -74,7 +74,7 @@ public void CounterPayloadReceived(CounterPayload payload, bool _) string tags = payload.CombineTags(); if (!string.IsNullOrEmpty(tags)) { - counterName += "[" + FormatTags(tags) + "]"; + counterName += "[" + FormatTags(tags, payload.IsMeter) + "]"; } AppendField(builder, payload.Timestamp.ToString()); @@ -96,8 +96,13 @@ public void CounterStopped(CounterPayload payload) { } // 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) + private static string FormatTags(string tags, bool isMeter) { + if (!isMeter) + { + return tags.Replace(',', ';'); + } + StringBuilder sb = new(); foreach (KeyValuePair tag in CounterTagFormatter.Decode(tags)) { diff --git a/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs b/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs index 059ac74f10..fc2b5f9074 100644 --- a/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs +++ b/src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs @@ -31,8 +31,14 @@ public ObservedProvider(string name) /// Information about an observed counter. 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 TagSets { get; } = new Dictionary(); @@ -218,7 +224,10 @@ private bool RenderTagSetsInColumnMode(ref int row, ObservedCounter counter) int tagsCount = 0; foreach (ObservedTagSet tagSet in counter.TagSets.Values.OrderBy(t => t.Tags)) { - foreach ((string tagKey, string tagValue) in CounterTagFormatter.Decode(tagSet.Tags)) + IEnumerable> tags = counter.IsMeter + ? CounterTagFormatter.Decode(tagSet.Tags) + : CounterUtilities.GetMetadata(tagSet.Tags); + foreach ((string tagKey, string tagValue) in tags) { int posTag = observedTags.FindIndex(tag => tag.header == tagKey); if (posTag == -1) @@ -356,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 diff --git a/src/Tools/dotnet-counters/Exporters/JSONExporter.cs b/src/Tools/dotnet-counters/Exporters/JSONExporter.cs index f6736d7af7..748039ae77 100644 --- a/src/Tools/dotnet-counters/Exporters/JSONExporter.cs +++ b/src/Tools/dotnet-counters/Exporters/JSONExporter.cs @@ -76,10 +76,10 @@ public void CounterPayloadReceived(CounterPayload payload, bool _) .Append("{ \"timestamp\": \"").Append(DateTime.Now.ToString("O")).Append("\", ") .Append(" \"provider\": \"").Append(JsonEscape(payload.CounterMetadata.ProviderName)).Append("\", ") .Append(" \"name\": \"").Append(JsonEscape(payload.GetDisplay())).Append("\", ") - .Append(" \"tags\": \"").Append(JsonEscape(FormatTags(payload.ValueTags))).Append("\", ") + .Append(" \"tags\": \"").Append(JsonEscape(FormatTags(payload.ValueTags, payload.IsMeter))).Append("\", ") .Append(" \"counterType\": \"").Append(JsonEscape(payload.CounterType.ToString())).Append("\", ") - .Append(" \"meterTags\": \"").Append(JsonEscape(FormatTags(payload.CounterMetadata.MeterTags))).Append("\", ") - .Append(" \"instrumentTags\": \"").Append(JsonEscape(FormatTags(payload.CounterMetadata.InstrumentTags))).Append("\", ") + .Append(" \"meterTags\": \"").Append(JsonEscape(FormatTags(payload.CounterMetadata.MeterTags, payload.IsMeter))).Append("\", ") + .Append(" \"instrumentTags\": \"").Append(JsonEscape(FormatTags(payload.CounterMetadata.InstrumentTags, payload.IsMeter))).Append("\", ") .Append(" \"value\": ").Append(payload.Value.ToString(CultureInfo.InvariantCulture)).Append(" },"); } } @@ -88,8 +88,13 @@ public void CounterStopped(CounterPayload payload) { } // Renders decoded tags as the flat "key=value,key=value" string this exporter emits. The values // are unescaped; JSON string quoting handles any ',' or '=' they contain. - private static string FormatTags(string tags) + private static string FormatTags(string tags, bool isMeter) { + if (!isMeter) + { + return tags; + } + StringBuilder sb = new(); foreach (KeyValuePair tag in CounterTagFormatter.Decode(tags)) { diff --git a/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/EventCounterTriggerTests.cs b/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/EventCounterTriggerTests.cs index def5d754ab..5aa184c8fc 100644 --- a/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/EventCounterTriggerTests.cs +++ b/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/EventCounterTriggerTests.cs @@ -508,6 +508,16 @@ public void ValidateMetadataParsing_Success() Assert.Equal(value2, metadataDict[key2]); } + [Fact] + public void ValidateEventCounterMetadataParsing_PreservesBackslashesAndEquals() + { + IDictionary metadataDict = CounterUtilities.GetMetadata(@"path:C:\temp,expression:x\=1"); + + Assert.Equal(2, metadataDict.Count); + Assert.Equal(@"C:\temp", metadataDict["path"]); + Assert.Equal(@"x\=1", metadataDict["expression"]); + } + /// /// Validates that metadata with an invalid format from TraceEvent payloads is handled correctly. /// diff --git a/src/tests/dotnet-counters/CSVExporterTests.cs b/src/tests/dotnet-counters/CSVExporterTests.cs index 140db9271d..3b5417e19b 100644 --- a/src/tests/dotnet-counters/CSVExporterTests.cs +++ b/src/tests/dotnet-counters/CSVExporterTests.cs @@ -238,6 +238,43 @@ public void EscapedTagsAreDecoded() } } + [Fact] + public void EventCounterMetadataIsNotDecodedAsMeterTags() + { + const string metadata = @"path:C:\temp,expression:x\=1"; + string fileName = "EventCounterMetadataTest.csv"; + CSVExporter exporter = new(fileName); + exporter.Initialize(); + + exporter.CounterPayloadReceived( + new EventCounterPayload( + DateTime.Now, + "myProvider", + "counterOne", + "Counter One", + string.Empty, + 1, + CounterType.Metric, + 1, + 1, + metadata), + false); + exporter.Stop(); + + try + { + List lines = File.ReadLines(fileName).ToList(); + List tokens = SplitCsvLine(Assert.Single(lines.Skip(1))); + + Assert.Equal(5, tokens.Count); + Assert.Equal(@"Counter One[path:C:\temp;expression:x\=1]", tokens[2]); + } + finally + { + File.Delete(fileName); + } + } + [Fact] public void SpecialCharactersAreCsvQuoted() { diff --git a/src/tests/dotnet-counters/ConsoleExporterTests.cs b/src/tests/dotnet-counters/ConsoleExporterTests.cs index 30f1819d2b..d7edb23502 100644 --- a/src/tests/dotnet-counters/ConsoleExporterTests.cs +++ b/src/tests/dotnet-counters/ConsoleExporterTests.cs @@ -307,6 +307,26 @@ public void EscapedTagsAreDecodedInColumnMode() Assert.DoesNotContain(lines, line => line.Contains(@"\")); // no stray escape characters leak to the display } + [Fact] + public void EventCounterMetadataIsNotDecodedAsMeterTags() + { + MockConsole console = new MockConsole(80, 40, _outputHelper); + ConsoleWriter exporter = new ConsoleWriter(console); + exporter.Initialize(); + + exporter.CounterPayloadReceived( + CreateEventCounter( + "Provider1", + "Counter1", + "{widget}", + 1, + @"path:C:\temp,expression:x\=1"), + false); + + Assert.Contains(console.Lines, line => line.Contains(@"C:\temp")); + Assert.Contains(console.Lines, line => line.Contains(@"x\=1")); + } + [Fact] public void CountersAreTruncatedBeyondScreenHeight() { @@ -487,9 +507,9 @@ public void MeterCounterIsAbsoluteInNet8() } - private static CounterPayload CreateEventCounter(string provider, string displayName, string unit, double value) + private static CounterPayload CreateEventCounter(string provider, string displayName, string unit, double value, string tags = "") { - return new EventCounterPayload(DateTime.MinValue, provider, displayName, displayName, unit, value, CounterType.Metric, 0, 0, ""); + return new EventCounterPayload(DateTime.MinValue, provider, displayName, displayName, unit, value, CounterType.Metric, 0, 0, tags); } private static CounterPayload CreateIncrementingEventCounter(string provider, string displayName, string unit, double value) diff --git a/src/tests/dotnet-counters/JSONExporterTests.cs b/src/tests/dotnet-counters/JSONExporterTests.cs index 6ad80a8f35..a9aba62f79 100644 --- a/src/tests/dotnet-counters/JSONExporterTests.cs +++ b/src/tests/dotnet-counters/JSONExporterTests.cs @@ -195,6 +195,42 @@ public void EscapedTagsAreDecoded() } } + [Fact] + public void EventCounterMetadataIsNotDecodedAsMeterTags() + { + const string metadata = @"path:C:\temp,expression:x\=1"; + string fileName = "EventCounterMetadataTest.json"; + JSONExporter exporter = new(fileName, "myProcess.exe"); + exporter.Initialize(); + + exporter.CounterPayloadReceived( + new EventCounterPayload( + DateTime.Now, + "myProvider", + "counterOne", + "Counter One", + string.Empty, + 1, + CounterType.Metric, + 1, + 1, + metadata), + false); + exporter.Stop(); + + try + { + string json = File.ReadAllText(fileName); + JSONCounterPayload payload = Assert.Single(JsonConvert.DeserializeObject(json).events); + + Assert.Equal(metadata, payload.tags); + } + finally + { + File.Delete(fileName); + } + } + [Fact] public void DisplayUnitsTest() {