diff --git a/src/PerfView/StackViewer/StackWindow.xaml.cs b/src/PerfView/StackViewer/StackWindow.xaml.cs index 103c5b908..a403ea215 100644 --- a/src/PerfView/StackViewer/StackWindow.xaml.cs +++ b/src/PerfView/StackViewer/StackWindow.xaml.cs @@ -898,7 +898,7 @@ internal void DoSave(object sender, RoutedEventArgs e) saveDialog.InitialDirectory = Path.GetDirectoryName(DataSource.FilePath); saveDialog.Title = "File to save view"; saveDialog.DefaultExt = ".perfView.xml.zip"; - saveDialog.Filter = "PerfView view file|*.perfView.xml.zip|Comma Separated Value|*.csv|Speed Scope Format|*.speedscope.json|All Files|*.*"; + saveDialog.Filter = "PerfView view file|*.perfView.xml.zip|Comma Separated Value|*.csv|Speed Scope|*.speedscope.json|Chromium Trace Event|*.chromium.json|All Files|*.*"; saveDialog.AddExtension = true; saveDialog.OverwritePrompt = true; @@ -948,6 +948,10 @@ internal void DoSave(object sender, RoutedEventArgs e) { SpeedScopeStackSourceWriter.WriteStackViewAsJson(CallTree.StackSource, m_fileName); } + else if (m_fileName.EndsWith(".chromium.json", StringComparison.OrdinalIgnoreCase)) + { + ChromiumStackSourceWriter.WriteStackViewAsJson(CallTree.StackSource, m_fileName, false); + } else { if (m_fileName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) diff --git a/src/TraceEvent/Stacks/ChromiumStackSourceWriter.cs b/src/TraceEvent/Stacks/ChromiumStackSourceWriter.cs new file mode 100644 index 000000000..a907c3fed --- /dev/null +++ b/src/TraceEvent/Stacks/ChromiumStackSourceWriter.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using static Microsoft.Diagnostics.Tracing.Stacks.StackSourceWriterHelper; + +namespace Microsoft.Diagnostics.Tracing.Stacks.Formats +{ + public class ChromiumStackSourceWriter + { + /// + /// exports provided StackSource to a Chromium Trace File format + /// schema: https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/ + /// + public static void WriteStackViewAsJson(StackSource source, string filePath, bool compress) + { + if (compress && !filePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)) + filePath += ".gz"; + + if (File.Exists(filePath)) + File.Delete(filePath); + + using (var writeStream = compress ? (Stream)new GZipStream(File.Create(filePath), CompressionMode.Compress, leaveOpen: false) : File.Create(filePath)) + using (var streamWriter = new StreamWriter(writeStream)) + { + Export(source, streamWriter, Path.GetFileNameWithoutExtension(filePath)); + } + } + + #region private + private static void Export(StackSource source, TextWriter writer, string name) + { + var samplesPerThread = GetSortedSamplesPerThread(source); + + var exportedFrameNameToExportedFrameId = new Dictionary(); + var exportedFrameIdToFrameTuple = new Dictionary(); + var profileEventsPerThread = new Dictionary>(); + + foreach (var pair in samplesPerThread) + { + var frameIdToSamples = WalkTheStackAndExpandSamples(source, pair.Value, exportedFrameNameToExportedFrameId, exportedFrameIdToFrameTuple); + + var sortedProfileEvents = GetAggregatedOrderedProfileEvents(frameIdToSamples); + + profileEventsPerThread.Add(pair.Key, sortedProfileEvents); + }; + + WriteToFile(exportedFrameIdToFrameTuple, profileEventsPerThread, writer, name); + } + + private static void WriteToFile(Dictionary frameIdToFrameTuple, + IReadOnlyDictionary> sortedProfileEventsPerThread, + TextWriter writer, string name) + { + writer.Write("{"); + writer.Write("\"traceEvents\": ["); + bool isFirst = true; + foreach (var perThread in sortedProfileEventsPerThread.OrderBy(pair => pair.Value.First().RelativeTime)) + { + foreach (var profileEvent in perThread.Value) + { + if (!isFirst) + writer.Write(", "); + else + isFirst = false; + + writer.Write("{"); + writer.Write($"\"name\": \"{frameIdToFrameTuple[profileEvent.FrameId].Name}\", "); + writer.Write($"\"cat\": \"sampleEvent\", "); + writer.Write($"\"ph\": \"{(profileEvent.Type == ProfileEventType.Open ? "B" : "E")}\", "); + writer.Write($"\"ts\": {profileEvent.RelativeTime.ToString("R", CultureInfo.InvariantCulture)}, "); + writer.Write($"\"pid\": {perThread.Key.ProcessId}, "); + writer.Write($"\"tid\": {perThread.Key.Id}, "); + writer.Write($"\"sf\": {profileEvent.FrameId}"); + writer.Write("}"); + } + } + writer.Write("], "); + writer.Write("\"displayTimeUnit\": \"ms\", "); + writer.Write("\"stackFrames\": {"); + isFirst = true; + foreach (var frame in frameIdToFrameTuple) + { + if (!isFirst) + writer.Write(", "); + else + isFirst = false; + + var frameId = frame.Key; + var frameInfo = frame.Value; + writer.Write($"\"{frameId}\": {{"); + writer.Write($"\"name\": \"{frameInfo.Name}\", "); + writer.Write($"\"category\": \"{frameInfo.Category}\""); + if (frameInfo.ParentId != -1) + writer.Write($", \"parent\": {frameInfo.ParentId}"); + writer.Write("}"); + } + writer.Write("}, "); + writer.Write($"\"otherData\": {{ \"name\": \"{name}\" }}"); + writer.Write("}"); + } + #endregion private + } +} \ No newline at end of file diff --git a/src/TraceEvent/Stacks/SpeedScopeStackSourceWriter.cs b/src/TraceEvent/Stacks/SpeedScopeStackSourceWriter.cs index 7f34b18f7..0a94d3507 100644 --- a/src/TraceEvent/Stacks/SpeedScopeStackSourceWriter.cs +++ b/src/TraceEvent/Stacks/SpeedScopeStackSourceWriter.cs @@ -1,9 +1,10 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using static Microsoft.Diagnostics.Tracing.Stacks.StackSourceWriterHelper; + namespace Microsoft.Diagnostics.Tracing.Stacks.Formats { public static class SpeedScopeStackSourceWriter @@ -22,20 +23,21 @@ public static void WriteStackViewAsJson(StackSource source, string filePath) } #region private - internal static void Export(StackSource source, TextWriter writer, string name) + private static void Export(StackSource source, TextWriter writer, string name) { var samplesPerThread = GetSortedSamplesPerThread(source); var exportedFrameNameToExportedFrameId = new Dictionary(); + var exportedFrameIdToFrameTuple = new Dictionary(); var profileEventsPerThread = new Dictionary>(); foreach(var pair in samplesPerThread) { - var frameIdToSamples = WalkTheStackAndExpandSamples(source, pair.Value, exportedFrameNameToExportedFrameId); + var frameIdToSamples = WalkTheStackAndExpandSamples(source, pair.Value, exportedFrameNameToExportedFrameId, exportedFrameIdToFrameTuple); var sortedProfileEvents = GetAggregatedOrderedProfileEvents(frameIdToSamples); - profileEventsPerThread.Add(pair.Key, sortedProfileEvents); + profileEventsPerThread.Add(pair.Key.Name, sortedProfileEvents); }; var orderedFrameNames = exportedFrameNameToExportedFrameId.OrderBy(pair => pair.Value).Select(pair => pair.Key).ToArray(); @@ -43,201 +45,10 @@ internal static void Export(StackSource source, TextWriter writer, string name) WriteToFile(profileEventsPerThread, orderedFrameNames, writer, name); } - /// - /// we want to identify the thread for every sample to prevent from - /// overlaping of samples for the concurrent code so we group the samples by Threads - /// this method also sorts the samples by relative time (ascending) - /// - internal static IReadOnlyDictionary> GetSortedSamplesPerThread(StackSource stackSource) - { - var samplesPerThread = new Dictionary>(); - - stackSource.ForEach(sample => - { - var stackIndex = sample.StackIndex; - - while(stackIndex != StackSourceCallStackIndex.Invalid) - { - var frameName = stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false); - - // we walk the stack up until we find the Thread name - if (!frameName.StartsWith("Thread (")) - { - stackIndex = stackSource.GetCallerIndex(stackIndex); - continue; - } - - if (!samplesPerThread.TryGetValue(frameName, out var samples)) - samplesPerThread[frameName] = samples = new List(); - - samples.Add(new Sample(sample.StackIndex, -1, sample.TimeRelativeMSec, sample.Metric, -1)); - - return; - } - - throw new InvalidOperationException("Sample with no Thread assigned!"); - }); - - foreach (var samples in samplesPerThread.Values) - { - // all samples in the StackSource should be sorted, but we want to ensure it - samples.Sort(CompareSamples); - } - - return samplesPerThread; - } - - /// - /// all the samples that we have are leafs (last sample in the call stack) - /// this method expands those samples to full information - /// it walks the stack up to the begining and adds a sample for every method on the stack - /// it's required to build full information - /// - internal static IReadOnlyDictionary> WalkTheStackAndExpandSamples(StackSource stackSource, IEnumerable leafs, - Dictionary exportedFrameNameToExportedFrameId) - { - var frameIdToSamples = new Dictionary>(); - - // we use stack here bacause we want a certain order: from the root to the leaf - var stackIndexesToHandle = new Stack(); - - foreach (var leafSample in leafs) - { - // walk the stack first - var stackIndex = leafSample.StackIndex; - while (stackIndex != StackSourceCallStackIndex.Invalid) - { - stackIndexesToHandle.Push(stackIndex); - - stackIndex = stackSource.GetCallerIndex(stackIndex); - } - - // add sample for every method on the stack - int depth = -1; - int callerFrameId = -1; - while (stackIndexesToHandle.Count > 0) - { - stackIndex = stackIndexesToHandle.Pop(); - depth++; - - var frameIndex = stackSource.GetFrameIndex(stackIndex); - if (frameIndex == StackSourceFrameIndex.Broken || frameIndex == StackSourceFrameIndex.Invalid) - continue; - - var frameName = stackSource.GetFrameName(frameIndex, false); - if (string.IsNullOrEmpty(frameName)) - continue; - - if (!exportedFrameNameToExportedFrameId.TryGetValue(frameName, out int exportedFrameId)) - exportedFrameNameToExportedFrameId.Add(frameName, exportedFrameId = exportedFrameNameToExportedFrameId.Count); - - if (!frameIdToSamples.TryGetValue(exportedFrameId, out var samples)) - frameIdToSamples.Add(exportedFrameId, samples = new List()); - - // the time and metric are the same as for the leaf sample - // the difference is stack index (not really used from here), caller frame id and depth (used for sorting the exported data) - samples.Add(new Sample(stackIndex, callerFrameId, leafSample.RelativeTime, leafSample.Metric, depth)); - - callerFrameId = exportedFrameId; - } - } - - return frameIdToSamples; - } - - /// - /// this method aggregates all the singular samples to continuous events - /// example: samples for Main taken at time 0.1 0.2 0.3 0.4 0.5 - /// are gonna be translated to Main start at 0.1 stop at 0.5 - /// - internal static IReadOnlyList GetAggregatedOrderedProfileEvents(IReadOnlyDictionary> frameIdToSamples) - { - List profileEvents = new List(); - - foreach (var samplesInfo in frameIdToSamples) - { - var frameId = samplesInfo.Key; - var samples = samplesInfo.Value; - - // this should not be required, but I prefer to be sure that the data is sorted - samples.Sort(CompareSamples); - - Sample openSample = samples[0]; // samples are never empty - for (int i = 1; i < samples.Count; i++) - { - if (AreNotContinuous(samples[i - 1], samples[i])) - { - AddEvents(profileEvents, openSample, samples[i - 1], frameId); - - openSample = samples[i]; - } - } - - // we need to handle the last (or the only one) profile event - AddEvents(profileEvents, openSample, samples[samples.Count - 1], frameId); - } - - // MUST HAVE!!! the tool expects the profile events in certain order!! - return OrderForExport(profileEvents).ToArray(); - } - - /// - /// this method checks if both samples do NOT belong to the same profile event - /// - private static bool AreNotContinuous(Sample left, Sample right) - { - if (left.Depth != right.Depth) - return true; - if (left.CallerFrameId != right.CallerFrameId) - return true; - - // 1.2 is a magic number based on some experiments ;) - return left.RelativeTime + (left.Metric * 1.2) < right.RelativeTime; - } - - /// - /// this method adds a new profile event for provided samples - /// it also make sure that a profile event does not open and close at the same time (would be ignored by SpeedScope) - /// - private static void AddEvents(List profileEvents, Sample openSample, Sample closeSample, int frameId) - { - if (openSample.Depth != closeSample.Depth) - throw new ArgumentException("Invalid arguments, both samples must be of the same depth"); - if (openSample.RelativeTime == closeSample.RelativeTime + closeSample.Metric) - throw new ArgumentException("Invalid samples, two samples can not happen at the same time."); - - profileEvents.Add(new ProfileEvent(ProfileEventType.Open, frameId, openSample.RelativeTime, openSample.Depth)); - profileEvents.Add(new ProfileEvent(ProfileEventType.Close, frameId, closeSample.RelativeTime + closeSample.Metric, closeSample.Depth)); - } - - /// - /// this method orders the profile events in the order required by SpeedScope - /// it's just the order of drawing the time graph - /// - internal static IEnumerable OrderForExport(IEnumerable profiles) - { - return profiles - .GroupBy(@event => @event.RelativeTime) - .OrderBy(group => group.Key) - .SelectMany(group => - { - // MakeSureSamplesDoNotOverlap guarantees that samples do NOT overlap - // AddEvents guarantees us that there is no event which starts and end at the same time - // so we don't need to worry about this edge case here - - // first of all, we need to emit close events, descending by depth (tool format requires that) - var closingDescendingByDepth = group.Where(@event => @event.Type == ProfileEventType.Close).OrderByDescending(@event => @event.Depth); - // then we can open new events, ascending by depth (tool format requires that) - var openingAscendingByDepth = group.Where(@event => @event.Type == ProfileEventType.Open).OrderBy(@event => @event.Depth); - - return closingDescendingByDepth.Concat(openingAscendingByDepth); - }); - } - /// /// writes pre-calculated data to SpeedScope format /// - internal static void WriteToFile(IReadOnlyDictionary> sortedProfileEventsPerThread, + private static void WriteToFile(IReadOnlyDictionary> sortedProfileEventsPerThread, IReadOnlyList orderedFrameNames, TextWriter writer, string name) { writer.Write("{"); @@ -293,64 +104,6 @@ internal static void WriteToFile(IReadOnlyDictionary RelativeTime.ToString(CultureInfo.InvariantCulture); - - #region private - internal StackSourceCallStackIndex StackIndex { get; } - internal int CallerFrameId { get; } - internal double RelativeTime { get; } - internal double Metric { get; } - internal int Depth { get; } - #endregion private - } - - internal enum ProfileEventType : byte - { - Open = 0, Close = 1 - } - - internal struct ProfileEvent - { - public ProfileEvent(ProfileEventType type, int frameId, double relativeTime, int depth) - { - Type = type; - FrameId = frameId; - RelativeTime = relativeTime; - Depth = depth; - } - - public override string ToString() => $"{RelativeTime.ToString(CultureInfo.InvariantCulture)} {Type} {FrameId}"; - - #region private - internal ProfileEventType Type { get; } - internal int FrameId { get; } - internal double RelativeTime { get; } - internal int Depth { get; } - #endregion private - } #endregion private } } diff --git a/src/TraceEvent/Stacks/StackSourceWriterHelper.cs b/src/TraceEvent/Stacks/StackSourceWriterHelper.cs new file mode 100644 index 000000000..0970e9c9a --- /dev/null +++ b/src/TraceEvent/Stacks/StackSourceWriterHelper.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.Diagnostics.Tracing.Stacks +{ + internal static class StackSourceWriterHelper + { + /// + /// we want to identify the thread for every sample to prevent from + /// overlaping of samples for the concurrent code so we group the samples by Threads + /// this method also sorts the samples by relative time (ascending) + /// + internal static IReadOnlyDictionary> GetSortedSamplesPerThread(StackSource stackSource) + { + var samplesPerThread = new Dictionary>(); + + stackSource.ForEach(sample => + { + var stackIndex = sample.StackIndex; + + while (stackIndex != StackSourceCallStackIndex.Invalid) + { + var frameName = stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false); + + // we walk the stack up until we find the Thread name + if (!frameName.StartsWith("Thread (")) + { + stackIndex = stackSource.GetCallerIndex(stackIndex); + continue; + } + + // we assume that the next caller is always process + var processStackIndex = stackSource.GetCallerIndex(stackIndex); + var processFrameName = processStackIndex == StackSourceCallStackIndex.Invalid + ? "Unknown" + : stackSource.GetFrameName(stackSource.GetFrameIndex(processStackIndex), false); + + var threadInfo = new ThreadInfo(frameName, processFrameName); + + if (!samplesPerThread.TryGetValue(threadInfo, out var samples)) + samplesPerThread[threadInfo] = samples = new List(); + + samples.Add(new Sample(sample.StackIndex, -1, sample.TimeRelativeMSec, sample.Metric, -1)); + + return; + } + + // Sample with no Thread assigned - it's most probably a "Process" sample, we just ignore it + }); + + foreach (var samples in samplesPerThread.Values) + { + // all samples in the StackSource should be sorted, but we want to ensure it + samples.Sort(CompareSamples); + } + + return samplesPerThread; + } + + /// + /// all the samples that we have are leafs (last sample in the call stack) + /// this method expands those samples to full information + /// it walks the stack up to the begining and adds a sample for every method on the stack + /// it's required to build full information + /// + internal static IReadOnlyDictionary> WalkTheStackAndExpandSamples(StackSource stackSource, IEnumerable leafs, + Dictionary exportedFrameNameToExportedFrameId, Dictionary exportedFrameIdToExportedNameAndCallerId) + { + var frameIdToSamples = new Dictionary>(); + + // we use stack here bacause we want a certain order: from the root to the leaf + var stackIndexesToHandle = new Stack(); + + foreach (var leafSample in leafs) + { + // walk the stack first + var stackIndex = leafSample.StackIndex; + while (stackIndex != StackSourceCallStackIndex.Invalid) + { + stackIndexesToHandle.Push(stackIndex); + + stackIndex = stackSource.GetCallerIndex(stackIndex); + } + + // add sample for every method on the stack + int depth = -1; + int callerFrameId = -1; + while (stackIndexesToHandle.Count > 0) + { + stackIndex = stackIndexesToHandle.Pop(); + depth++; + + var frameIndex = stackSource.GetFrameIndex(stackIndex); + if (frameIndex == StackSourceFrameIndex.Broken || frameIndex == StackSourceFrameIndex.Invalid) + continue; + + var frameName = stackSource.GetFrameName(frameIndex, false); + if (string.IsNullOrEmpty(frameName)) + continue; + + if (!exportedFrameNameToExportedFrameId.TryGetValue(frameName, out int exportedFrameId)) + exportedFrameNameToExportedFrameId.Add(frameName, exportedFrameId = exportedFrameNameToExportedFrameId.Count); + + if (!frameIdToSamples.TryGetValue(exportedFrameId, out var samples)) + frameIdToSamples.Add(exportedFrameId, samples = new List()); + + // the time and metric are the same as for the leaf sample + // the difference is stack index (not really used from here), caller frame id and depth (used for sorting the exported data) + samples.Add(new Sample(stackIndex, callerFrameId, leafSample.RelativeTime, leafSample.Metric, depth)); + + if (!exportedFrameIdToExportedNameAndCallerId.ContainsKey(exportedFrameId)) + { + // in the future we could identify the categories in a more advance way + // and split JIT, GC, Runtime, Libraries and ASP.NET Code into separate categories + int index = frameName.IndexOf('!'); + string category = index > 0 ? frameName.Substring(0, index) : string.Empty; + string shortName = index > 0 ? frameName.Substring(index + 1) : frameName; + exportedFrameIdToExportedNameAndCallerId.Add(exportedFrameId, new FrameInfo(callerFrameId, shortName, category)); + } + + callerFrameId = exportedFrameId; + } + } + + return frameIdToSamples; + } + + /// + /// this method aggregates all the singular samples to continuous events + /// example: samples for Main taken at time 0.1 0.2 0.3 0.4 0.5 + /// are gonna be translated to Main start at 0.1 stop at 0.5 + /// + internal static IReadOnlyList GetAggregatedOrderedProfileEvents(IReadOnlyDictionary> frameIdToSamples) + { + List profileEvents = new List(); + + foreach (var samplesInfo in frameIdToSamples) + { + var frameId = samplesInfo.Key; + var samples = samplesInfo.Value; + + // this should not be required, but I prefer to be sure that the data is sorted + samples.Sort(CompareSamples); + + Sample openSample = samples[0]; // samples are never empty + for (int i = 1; i < samples.Count; i++) + { + if (AreNotContinuous(samples[i - 1], samples[i])) + { + AddEvents(profileEvents, openSample, samples[i - 1], frameId); + + openSample = samples[i]; + } + } + + // we need to handle the last (or the only one) profile event + AddEvents(profileEvents, openSample, samples[samples.Count - 1], frameId); + } + + // MUST HAVE!!! the tool expects the profile events in certain order!! + return OrderForExport(profileEvents).ToArray(); + } + + /// + /// this method checks if both samples do NOT belong to the same profile event + /// + private static bool AreNotContinuous(Sample left, Sample right) + { + if (left.Depth != right.Depth) + return true; + if (left.CallerFrameId != right.CallerFrameId) + return true; + + // 1.2 is a magic number based on some experiments ;) + return left.RelativeTime + (left.Metric * 1.2) < right.RelativeTime; + } + + /// + /// this method adds a new profile event for provided samples + /// it also make sure that a profile event does not open and close at the same time (would be ignored by SpeedScope) + /// + private static void AddEvents(List profileEvents, Sample openSample, Sample closeSample, int frameId) + { + if (openSample.Depth != closeSample.Depth) + throw new ArgumentException("Invalid arguments, both samples must be of the same depth"); + if (openSample.RelativeTime == closeSample.RelativeTime + closeSample.Metric) + throw new ArgumentException("Invalid samples, two samples can not happen at the same time."); + + profileEvents.Add(new ProfileEvent(ProfileEventType.Open, frameId, openSample.RelativeTime, openSample.Depth)); + profileEvents.Add(new ProfileEvent(ProfileEventType.Close, frameId, closeSample.RelativeTime + closeSample.Metric, closeSample.Depth)); + } + + /// + /// this method orders the profile events in the order required by SpeedScope + /// it's just the order of drawing the time graph + /// + internal static IEnumerable OrderForExport(IEnumerable profiles) + { + return profiles + .GroupBy(@event => @event.RelativeTime) + .OrderBy(group => group.Key) + .SelectMany(group => + { + // MakeSureSamplesDoNotOverlap guarantees that samples do NOT overlap + // AddEvents guarantees us that there is no event which starts and end at the same time + // so we don't need to worry about this edge case here + + // first of all, we need to emit close events, descending by depth (tool format requires that) + var closingDescendingByDepth = group.Where(@event => @event.Type == ProfileEventType.Close).OrderByDescending(@event => @event.Depth); + // then we can open new events, ascending by depth (tool format requires that) + var openingAscendingByDepth = group.Where(@event => @event.Type == ProfileEventType.Open).OrderBy(@event => @event.Depth); + + return closingDescendingByDepth.Concat(openingAscendingByDepth); + }); + } + + private static int CompareSamples(Sample x, Sample y) + { + int timeComparison = x.RelativeTime.CompareTo(y.RelativeTime); + + if (timeComparison != 0) + return timeComparison; + + // in case both samples start at the same time, the one with smaller metric should be the first one + return x.Metric.CompareTo(y.Metric); + } + + internal readonly struct ThreadInfo : IEquatable + { + private static readonly Regex IdExpression = new Regex(@"\((\d+)\)", RegexOptions.Compiled); + + internal ThreadInfo(string threadFrameName, string processFrameName) + { + var threadIdMatch = IdExpression.Match(threadFrameName); + var processIdMatch = IdExpression.Match(processFrameName); + + Name = threadFrameName; + Id = threadIdMatch.Success ? int.Parse(threadIdMatch.Groups[1].Value) : 0; + ProcessId = processIdMatch.Success ? int.Parse(processIdMatch.Groups[1].Value) : 0; + } + + internal ThreadInfo(string name, int id, int processId) + { + Name = name; + Id = id; + ProcessId = processId; + } + + public override string ToString() => Name; + + public bool Equals(ThreadInfo other) => Name == other.Name && Id == other.Id && ProcessId == other.ProcessId; + + public override bool Equals(object obj) => obj is ThreadInfo other && Equals(other); + + public override int GetHashCode() => Name.GetHashCode() ^ Id ^ ProcessId; + + #region private + internal string Name { get; } + internal int Id { get; } + internal int ProcessId { get; } + #endregion private + } + + internal readonly struct Sample + { + internal Sample(StackSourceCallStackIndex stackIndex, int callerFrameId, double relativeTime, double metric, int depth) + { + StackIndex = stackIndex; + CallerFrameId = callerFrameId; + RelativeTime = relativeTime; + Metric = metric; + Depth = depth; + } + + public override string ToString() => RelativeTime.ToString(CultureInfo.InvariantCulture); + + #region private + internal StackSourceCallStackIndex StackIndex { get; } + internal int CallerFrameId { get; } + internal double RelativeTime { get; } + internal double Metric { get; } + internal int Depth { get; } + #endregion private + } + + internal enum ProfileEventType : byte + { + Open = 0, Close = 1 + } + + internal readonly struct ProfileEvent + { + public ProfileEvent(ProfileEventType type, int frameId, double relativeTime, int depth) + { + Type = type; + FrameId = frameId; + RelativeTime = relativeTime; + Depth = depth; + } + + public override string ToString() => $"{RelativeTime.ToString(CultureInfo.InvariantCulture)} {Type} {FrameId}"; + + #region private + internal ProfileEventType Type { get; } + internal int FrameId { get; } + internal double RelativeTime { get; } + internal int Depth { get; } + #endregion private + } + + internal readonly struct FrameInfo + { + public FrameInfo(int parentId, string frameName, string category) + { + ParentId = parentId; + Name = frameName; + Category = category; + } + + #region private + internal int ParentId { get; } + internal string Name { get; } + internal string Category { get; } + #endregion private + } + } +} diff --git a/src/TraceEvent/TraceEvent.Tests/SpeedScopeExporterTests.cs b/src/TraceEvent/TraceEvent.Tests/SpeedScopeExporterTests.cs index b8bcfa703..53d9a8cdc 100644 --- a/src/TraceEvent/TraceEvent.Tests/SpeedScopeExporterTests.cs +++ b/src/TraceEvent/TraceEvent.Tests/SpeedScopeExporterTests.cs @@ -1,48 +1,58 @@ using Microsoft.Diagnostics.Tracing.Stacks; -using Microsoft.Diagnostics.Tracing.Stacks.Formats; using System; using System.Collections.Generic; using System.Linq; using Xunit; +using static Microsoft.Diagnostics.Tracing.Stacks.StackSourceWriterHelper; + namespace TraceEventTests { public class SpeedScopeStackSourceWriterTests { - [Fact] - public void GetSortedSamplesReturnsSamplesSortedByRelativeTimeAndGrouppedByThread() + [Theory] + [InlineData("Process (321)", 321)] + [InlineData("Unknown", 0)] + public void GetSortedSamplesReturnsSamplesSortedByRelativeTimeAndGrouppedByThreadWithProcessInfo(string processName, int expectedProcessId) { const string ThreadName = "Thread (123)"; - var thread_1 = new FakeStackSourceSample( + + var process = new FakeStackSourceSample( relativeTime: 0.1, - name: ThreadName, + name: processName, frameIndex: (StackSourceFrameIndex)5, // 5 is first non-taken enum value stackIndex: (StackSourceCallStackIndex)1, // 1 is first non-taken enum value callerIndex: StackSourceCallStackIndex.Invalid); - var a_1 = new FakeStackSourceSample( + var thread_1 = new FakeStackSourceSample( relativeTime: 0.1, - name: "A", + name: ThreadName, frameIndex: (StackSourceFrameIndex)6, stackIndex: (StackSourceCallStackIndex)2, + callerIndex: process.StackIndex); + var a_1 = new FakeStackSourceSample( + relativeTime: 0.1, + name: "A", + frameIndex: (StackSourceFrameIndex)7, + stackIndex: (StackSourceCallStackIndex)3, callerIndex: thread_1.StackIndex); var thread_2 = new FakeStackSourceSample( relativeTime: 0.2, name: ThreadName, - frameIndex: (StackSourceFrameIndex)5, // 5 is first non-taken enum value - stackIndex: (StackSourceCallStackIndex)3, // 1 is first non-taken enum value - callerIndex: StackSourceCallStackIndex.Invalid); + frameIndex: (StackSourceFrameIndex)6, + stackIndex: (StackSourceCallStackIndex)4, + callerIndex: process.StackIndex); var a_2 = new FakeStackSourceSample( relativeTime: 0.2, name: "A", - frameIndex: (StackSourceFrameIndex)6, - stackIndex: (StackSourceCallStackIndex)4, + frameIndex: (StackSourceFrameIndex)7, + stackIndex: (StackSourceCallStackIndex)5, callerIndex: thread_2.StackIndex); - var sourceSamples = new[] { thread_1, thread_2, a_2, a_1 }; + var sourceSamples = new[] { process, thread_1, thread_2, a_2, a_1 }; var stackSource = new StackSourceStub(sourceSamples); - var result = SpeedScopeStackSourceWriter.GetSortedSamplesPerThread(stackSource)[ThreadName]; + var result = GetSortedSamplesPerThread(stackSource)[new ThreadInfo(ThreadName, 123, expectedProcessId)]; Assert.Equal(0.1, result[0].RelativeTime); Assert.Equal(0.1, result[1].RelativeTime); @@ -75,11 +85,11 @@ public void WalkTheStackAndExpandSamplesProducesFullInformation() callerIndex: a.StackIndex); var allSamples = new[] { main, a, b }; - var leafs = new[] { new SpeedScopeStackSourceWriter.Sample(b.StackIndex, -1, b.RelativeTime, b.Metric, -1) }; + var leafs = new[] { new Sample(b.StackIndex, -1, b.RelativeTime, b.Metric, -1) }; var stackSource = new StackSourceStub(allSamples); var frameNameToId = new Dictionary(); - var frameIdToSamples = SpeedScopeStackSourceWriter.WalkTheStackAndExpandSamples(stackSource, leafs, frameNameToId); + var frameIdToSamples = WalkTheStackAndExpandSamples(stackSource, leafs, frameNameToId, new Dictionary()); Assert.Equal(0, frameNameToId[main.Name]); Assert.Equal(1, frameNameToId[a.Name]); @@ -112,11 +122,11 @@ public void WalkTheStackAndExpandSamplesHandlesBrokenStacks(StackSourceFrameInde callerIndex: main.StackIndex); var allSamples = new[] { main, wrong }; - var leafs = new[] { new SpeedScopeStackSourceWriter.Sample(wrong.StackIndex, -1, wrong.RelativeTime, wrong.Metric, -1) }; + var leafs = new[] { new Sample(wrong.StackIndex, -1, wrong.RelativeTime, wrong.Metric, -1) }; var stackSource = new StackSourceStub(allSamples); var frameNameToId = new Dictionary(); - var frameIdToSamples = SpeedScopeStackSourceWriter.WalkTheStackAndExpandSamples(stackSource, leafs, frameNameToId); + var frameIdToSamples = WalkTheStackAndExpandSamples(stackSource, leafs, frameNameToId, new Dictionary()); Assert.Equal(0, frameNameToId[main.Name]); Assert.False(frameNameToId.ContainsKey(wrong.Name)); @@ -133,37 +143,37 @@ public void GetAggregatedOrderedProfileEventsConvertsContinuousSamplesWithPauses var samples = new[] { - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 0.1), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 0.2), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 0.1), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 0.2), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 0.7), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 0.7), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 1.1), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 1.2), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 1.3), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 1.1), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 1.2), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, depth: 0, relativeTime: 1.3), }; - var input = new Dictionary>() { { 0, samples.ToList() } }; + var input = new Dictionary>() { { 0, samples.ToList() } }; - var aggregatedEvents = SpeedScopeStackSourceWriter.GetAggregatedOrderedProfileEvents(input); + var aggregatedEvents = GetAggregatedOrderedProfileEvents(input); // we should have <0.1, 0.3> and <0.7, 0.8> (the tool would ignore <0.7, 0.7>) and <1.1, 1.4> Assert.Equal(6, aggregatedEvents.Count); Assert.Equal(0.1, aggregatedEvents[0].RelativeTime); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[0].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[0].Type); Assert.Equal(0.2 + metric, aggregatedEvents[1].RelativeTime); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[1].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[1].Type); Assert.Equal(0.7, aggregatedEvents[2].RelativeTime); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[2].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[2].Type); Assert.Equal(0.7 + metric, aggregatedEvents[3].RelativeTime); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[3].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[3].Type); Assert.Equal(1.1, aggregatedEvents[4].RelativeTime); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[4].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[4].Type); Assert.Equal(1.3 + metric, aggregatedEvents[5].RelativeTime); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[5].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[5].Type); } [Fact] @@ -173,32 +183,32 @@ public void GetAggregatedOrderedProfileEventsConvertsContinuousSamplesWithDiffer var samples = new[] { - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, relativeTime: 0.1, depth: 0), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, relativeTime: 0.2, depth: 1), // depth change! + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, relativeTime: 0.1, depth: 0), + new Sample((StackSourceCallStackIndex)1, callerFrameId: 0, metric: metric, relativeTime: 0.2, depth: 1), // depth change! }; - var input = new Dictionary>() { { 0, samples.ToList() } }; + var input = new Dictionary>() { { 0, samples.ToList() } }; - var aggregatedEvents = SpeedScopeStackSourceWriter.GetAggregatedOrderedProfileEvents(input); + var aggregatedEvents = GetAggregatedOrderedProfileEvents(input); // we should have: // Open at 0.1 depth 0 and Close 0.2 // Open at 0.2 depth 1 and Close 0.3 Assert.Equal(4, aggregatedEvents.Count); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[0].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[0].Type); Assert.Equal(0.1, aggregatedEvents[0].RelativeTime); Assert.Equal(0, aggregatedEvents[0].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[1].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[1].Type); Assert.Equal(0.1 + metric, aggregatedEvents[1].RelativeTime); Assert.Equal(0, aggregatedEvents[0].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[2].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[2].Type); Assert.Equal(0.2, aggregatedEvents[2].RelativeTime); Assert.Equal(1, aggregatedEvents[2].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[3].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[3].Type); Assert.Equal(0.2 + metric, aggregatedEvents[3].RelativeTime); Assert.Equal(1, aggregatedEvents[3].Depth); } @@ -210,32 +220,32 @@ public void GetAggregatedOrderedProfileEventsConvertsContinuousSamplesWithDiffer var samples = new[] { - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.1, depth: 0, callerFrameId: 0), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.2, depth: 0, callerFrameId: 1), // callerFrameId change! + new Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.1, depth: 0, callerFrameId: 0), + new Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.2, depth: 0, callerFrameId: 1), // callerFrameId change! }; - var input = new Dictionary>() { { 0, samples.ToList() } }; + var input = new Dictionary>() { { 0, samples.ToList() } }; - var aggregatedEvents = SpeedScopeStackSourceWriter.GetAggregatedOrderedProfileEvents(input); + var aggregatedEvents = GetAggregatedOrderedProfileEvents(input); // we should have: // Open at 0.1 depth 0 and Close 0.2 // Open at 0.2 depth 0 and Close 0.3 Assert.Equal(4, aggregatedEvents.Count); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[0].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[0].Type); Assert.Equal(0.1, aggregatedEvents[0].RelativeTime); Assert.Equal(0, aggregatedEvents[0].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[1].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[1].Type); Assert.Equal(0.1 + metric, aggregatedEvents[1].RelativeTime); Assert.Equal(0, aggregatedEvents[0].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[2].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[2].Type); Assert.Equal(0.2, aggregatedEvents[2].RelativeTime); Assert.Equal(0, aggregatedEvents[2].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[3].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[3].Type); Assert.Equal(0.2 + metric, aggregatedEvents[3].RelativeTime); Assert.Equal(0, aggregatedEvents[3].Depth); } @@ -247,24 +257,24 @@ public void CloseMetricCanBeZeroIfItDoesNotCreateAProfileEventThatStartsAndEndsA var samples = new[] { - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.1, depth: 0, callerFrameId: 0), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.2, depth: 0, callerFrameId: 0), - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: 0.0, relativeTime: 0.3, depth: 0, callerFrameId: 0), // 0.0 metric + new Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.1, depth: 0, callerFrameId: 0), + new Sample((StackSourceCallStackIndex)1, metric: metric, relativeTime: 0.2, depth: 0, callerFrameId: 0), + new Sample((StackSourceCallStackIndex)1, metric: 0.0, relativeTime: 0.3, depth: 0, callerFrameId: 0), // 0.0 metric }; - var input = new Dictionary>() { { 0, samples.ToList() } }; + var input = new Dictionary>() { { 0, samples.ToList() } }; - var aggregatedEvents = SpeedScopeStackSourceWriter.GetAggregatedOrderedProfileEvents(input); + var aggregatedEvents = GetAggregatedOrderedProfileEvents(input); // we should have: // Open at 0.1 depth 0 and Close 0.3 Assert.Equal(2, aggregatedEvents.Count); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, aggregatedEvents[0].Type); + Assert.Equal(ProfileEventType.Open, aggregatedEvents[0].Type); Assert.Equal(0.1, aggregatedEvents[0].RelativeTime); Assert.Equal(0, aggregatedEvents[0].Depth); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, aggregatedEvents[1].Type); + Assert.Equal(ProfileEventType.Close, aggregatedEvents[1].Type); Assert.Equal(0.3, aggregatedEvents[1].RelativeTime); Assert.Equal(0, aggregatedEvents[0].Depth); } @@ -277,58 +287,58 @@ public void TwoSamplesCanNotHappenAtTheSameTime() var samples = new[] { - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: zeroMetric, relativeTime: relativeTime, depth: 0, callerFrameId: 0), // 0.0 metric - new SpeedScopeStackSourceWriter.Sample((StackSourceCallStackIndex)1, metric: zeroMetric, relativeTime: relativeTime, depth: 0, callerFrameId: 0), // 0.0 metric and same relative time + new Sample((StackSourceCallStackIndex)1, metric: zeroMetric, relativeTime: relativeTime, depth: 0, callerFrameId: 0), // 0.0 metric + new Sample((StackSourceCallStackIndex)1, metric: zeroMetric, relativeTime: relativeTime, depth: 0, callerFrameId: 0), // 0.0 metric and same relative time }; - var input = new Dictionary>() { { 0, samples.ToList() } }; + var input = new Dictionary>() { { 0, samples.ToList() } }; - Assert.Throws(() => SpeedScopeStackSourceWriter.GetAggregatedOrderedProfileEvents(input)); + Assert.Throws(() => GetAggregatedOrderedProfileEvents(input)); } [Fact] public void OrderForExportOrdersTheProfileEventsAsExpectedByTheSpeedScope() { - var profileEvents = new List() + var profileEvents = new List() { - new SpeedScopeStackSourceWriter.ProfileEvent(SpeedScopeStackSourceWriter.ProfileEventType.Open, frameId: 0, depth: 0, relativeTime: 0.1), - new SpeedScopeStackSourceWriter.ProfileEvent(SpeedScopeStackSourceWriter.ProfileEventType.Open, frameId: 1, depth: 1, relativeTime: 0.1), - new SpeedScopeStackSourceWriter.ProfileEvent(SpeedScopeStackSourceWriter.ProfileEventType.Close, frameId: 1, depth: 1, relativeTime: 0.3), - new SpeedScopeStackSourceWriter.ProfileEvent(SpeedScopeStackSourceWriter.ProfileEventType.Close, frameId: 0, depth: 0, relativeTime: 0.3), - new SpeedScopeStackSourceWriter.ProfileEvent(SpeedScopeStackSourceWriter.ProfileEventType.Open, frameId: 2, depth: 0, relativeTime: 0.3), - new SpeedScopeStackSourceWriter.ProfileEvent(SpeedScopeStackSourceWriter.ProfileEventType.Close, frameId: 2, depth: 0, relativeTime: 0.4), + new ProfileEvent(ProfileEventType.Open, frameId: 0, depth: 0, relativeTime: 0.1), + new ProfileEvent(ProfileEventType.Open, frameId: 1, depth: 1, relativeTime: 0.1), + new ProfileEvent(ProfileEventType.Close, frameId: 1, depth: 1, relativeTime: 0.3), + new ProfileEvent(ProfileEventType.Close, frameId: 0, depth: 0, relativeTime: 0.3), + new ProfileEvent(ProfileEventType.Open, frameId: 2, depth: 0, relativeTime: 0.3), + new ProfileEvent(ProfileEventType.Close, frameId: 2, depth: 0, relativeTime: 0.4), }; profileEvents.Reverse(); // reverse to make sure that it does sort the elements in right way - var ordered = SpeedScopeStackSourceWriter.OrderForExport(profileEvents).ToArray(); + var ordered = OrderForExport(profileEvents).ToArray(); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, ordered[0].Type); + Assert.Equal(ProfileEventType.Open, ordered[0].Type); Assert.Equal(0.1, ordered[0].RelativeTime); Assert.Equal(0, ordered[0].Depth); Assert.Equal(0, ordered[0].FrameId); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, ordered[1].Type); + Assert.Equal(ProfileEventType.Open, ordered[1].Type); Assert.Equal(0.1, ordered[1].RelativeTime); Assert.Equal(1, ordered[1].Depth); Assert.Equal(1, ordered[1].FrameId); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, ordered[2].Type); + Assert.Equal(ProfileEventType.Close, ordered[2].Type); Assert.Equal(0.3, ordered[2].RelativeTime); Assert.Equal(1, ordered[2].Depth); Assert.Equal(1, ordered[2].FrameId); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, ordered[3].Type); + Assert.Equal(ProfileEventType.Close, ordered[3].Type); Assert.Equal(0.3, ordered[3].RelativeTime); Assert.Equal(0, ordered[3].Depth); Assert.Equal(0, ordered[3].FrameId); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Open, ordered[4].Type); + Assert.Equal(ProfileEventType.Open, ordered[4].Type); Assert.Equal(0.3, ordered[4].RelativeTime); Assert.Equal(0, ordered[4].Depth); Assert.Equal(2, ordered[4].FrameId); - Assert.Equal(SpeedScopeStackSourceWriter.ProfileEventType.Close, ordered[5].Type); + Assert.Equal(ProfileEventType.Close, ordered[5].Type); Assert.Equal(0.4, ordered[5].RelativeTime); Assert.Equal(0, ordered[5].Depth); Assert.Equal(2, ordered[5].FrameId);