< Summary

Information
Class: AmbientServices.FilteredStackTrace
Assembly: AmbientServices
File(s): /home/runner/work/AmbientServices/AmbientServices/AmbientServices/Helpers/FilteredStackTrace.cs
Tag: 332_35464845198
Line coverage
100%
Covered lines: 130
Uncovered lines: 0
Coverable lines: 130
Total lines: 464
Line coverage: 100%
Branch coverage
85%
Covered branches: 75
Total branches: 88
Branch coverage: 85.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Current()100%11100%
FilterFrames()100%11100%
.ctor()100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
get_FrameCount()100%11100%
GetFrame(...)100%22100%
GetFrames()100%11100%
Equals(...)100%22100%
GetHashCode()100%11100%
ToString()100%11100%
.cctor()100%11100%
AddSourcePathToErase(...)100%11100%
AddNamespacesToErase(...)100%11100%
AddNamespaceToFilter(...)100%11100%
AddNamespaceToFilterAfterFirst(...)100%11100%
FilterFrames()77.78%1818100%
ShouldFilterMethod(...)100%66100%
ShouldFilterMethodAfterFirst(...)100%66100%
MatchingAfterFirstNamespace(...)100%66100%
EraseSourcePath(...)100%66100%
EraseNamespace(...)100%66100%
InitializeSourcePathsToErase()62.5%88100%
InitializeNamespacesToErase()100%11100%
InitializeNamespacesToFilterAfterFirst()100%11100%
InitializeNamespacesToFilter()100%11100%
EraseCallingSourcePath(...)83.33%66100%
ToString(...)72.22%1818100%

File(s)

/home/runner/work/AmbientServices/AmbientServices/AmbientServices/Helpers/FilteredStackTrace.cs

#LineLine coverage
 1using AmbientServices.Utilities;
 2using System;
 3using System.Collections.Generic;
 4using System.Diagnostics;
 5using System.IO;
 6using System.Linq;
 7using System.Runtime.CompilerServices;
 8using System.Text;
 9using static System.FormattableString;
 10
 11namespace AmbientServices;
 12
 13/// <summary>
 14/// A class that is a stack trace that always filters the output to hide file paths and not-very-helpful system stack fr
 15/// </summary>
 16/// <remarks>
 17/// <pitch>A drop-in <see cref="StackTrace"/> (and <c>Environment.StackTrace</c>, via <see cref="Current"/>) replacement
 18/// <pledge>
 19/// Mirrors every <see cref="StackTrace"/> constructor form (frame skipping, exception sources, file info) while <see cr
 20/// Filter configuration is static and process-wide, add-only, and applies to traces rendered after the addition: namesp
 21/// </pledge>
 22/// <plan>Derives from <see cref="StackTrace"/> and lazily builds a filtered <see cref="StackFrame"/> array from the bas
 23/// <priority>
 24/// 1. A readable trace that leaks nothing over a complete one: framework frames, wrapper namespaces, library namespace 
 25/// 2. Keeping each transition into filtered code visible over maximum shortening: frames in a clustered namespace are d
 26/// 3. Capture cost matching the base class over filtering eagerly: the filtered view is built lazily on first read, so 
 27/// </priority>
 28/// </remarks>
 29public class FilteredStackTrace : StackTrace
 30{
 31    /// <summary>
 32    /// Gets the current filtered stack trace as a string.
 33    /// Intended as a replacement for Environment.StackTrace.
 34    /// </summary>
 35    public static string Current
 36    {
 37        [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 238        get => new FilteredStackTrace(1, true).ToString();
 39    }
 40
 41    private readonly Lazy<StackFrame[]> _filteredFrames;
 42
 43    private Lazy<StackFrame[]> FilterFrames()
 44    {
 245        return new Lazy<StackFrame[]>(() =>
 246        {
 247            List<StackFrame> filteredFrames = new();
 248#if NETSTANDARD2_1 || NETCOREAPP3_1 || NET5_0_OR_GREATER
 249            StackFrame?[] baseFrames = base.GetFrames();
 250#else
 251            StackFrame[] baseFrames = base.GetFrames();
 252#endif
 253            if (baseFrames != null)
 254            {
 255                filteredFrames.AddRange(FilterFrames(baseFrames.Where(f => f != null)!));
 256            }
 257            return filteredFrames.ToArray();
 258        }, System.Threading.LazyThreadSafetyMode.PublicationOnly);
 59    }
 60    /// <summary>
 61    /// Initializes a new instance of the System.Diagnostics.StackTrace class from the caller's frame.
 62    /// </summary>
 63    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 64    public FilteredStackTrace()
 265        : base(1)
 66    {
 267        _filteredFrames = FilterFrames();
 268    }
 69    /// <summary>
 70    /// Initializes a new instance of the System.Diagnostics.StackTrace class from the caller's frame, optionally captur
 71    /// </summary>
 72    /// <param name="needFileInfo">true to capture the file name, line number, and column number; otherwise, false.</par
 73    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 74    public FilteredStackTrace(bool needFileInfo)
 275        : base(1, needFileInfo)
 76    {
 277        _filteredFrames = FilterFrames();
 278    }
 79    /// <summary>
 80    /// Initializes a new instance of the System.Diagnostics.StackTrace class using the provided exception object.
 81    /// </summary>
 82    /// <param name="e">The exception object from which to construct the stack trace.</param>
 83    /// <exception cref="System.ArgumentNullException">The parameter e is null.</exception>
 84    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 85    public FilteredStackTrace(Exception e)
 286        : base(e, 1)
 87    {
 288        _filteredFrames = FilterFrames();
 289    }
 90    /// <summary>
 91    /// Initializes a new instance of the System.Diagnostics.StackTrace class from the caller's frame, skipping the spec
 92    /// </summary>
 93    /// <param name="skipFrames">The number of frames up the stack from which to start the trace.</param>
 94    /// <exception cref="System.ArgumentOutOfRangeException">The skipFrames parameter is negative.</exception>
 95    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 96    public FilteredStackTrace(int skipFrames)
 297        : base(skipFrames + 1)
 98    {
 299        _filteredFrames = FilterFrames();
 2100    }
 101    /// <summary>
 102    /// Initializes a new instance of the System.Diagnostics.StackTrace class that contains a single frame.
 103    /// </summary>
 104    /// <param name="frame">The frame that the System.Diagnostics.StackTrace object should contain.</param>
 105    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 106    public FilteredStackTrace(StackFrame frame)
 2107        : base(frame)
 108    {
 2109        _filteredFrames = FilterFrames();
 2110    }
 111    /// <summary>
 112    /// Initializes a new instance of the System.Diagnostics.StackTrace class, using the provided exception object and o
 113    /// </summary>
 114    /// <param name="e">The exception object from which to construct the stack trace.</param>
 115    /// <param name="needFileInfo">true to capture the file name, line number, and column number; otherwise, false.</par
 116    /// <exception cref="System.ArgumentNullException">The parameter e is null.</exception>
 117    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 118    public FilteredStackTrace(Exception e, bool needFileInfo)
 2119        : base(e, 1, needFileInfo)
 120    {
 2121        _filteredFrames = FilterFrames();
 2122    }
 123    /// <summary>
 124    /// Initializes a new instance of the System.Diagnostics.StackTrace class using the provided exception object and sk
 125    /// </summary>
 126    /// <param name="e">The exception object from which to construct the stack trace.</param>
 127    /// <param name="skipFrames">The number of frames up the stack from which to start the trace.</param>
 128    /// <exception cref="System.ArgumentNullException">The parameter e is null.</exception>
 129    /// <exception cref="System.ArgumentOutOfRangeException">The skipFrames parameter is negative.</exception>
 130    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 131    public FilteredStackTrace(Exception e, int skipFrames)
 2132        : base(e, skipFrames + 1)
 133    {
 2134        _filteredFrames = FilterFrames();
 2135    }
 136    /// <summary>
 137    /// Initializes a new instance of the System.Diagnostics.StackTrace class from the caller's frame, skipping the spec
 138    /// </summary>
 139    /// <param name="skipFrames">The number of frames up the stack from which to start the trace.</param>
 140    /// <param name="needFileInfo">true to capture the file name, line number, and column number; otherwise, false.</par
 141    /// <exception cref="System.ArgumentOutOfRangeException">The skipFrames parameter is negative.</exception>
 142    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 143    public FilteredStackTrace(int skipFrames, bool needFileInfo)
 2144        : base(skipFrames + 1, needFileInfo)
 145    {
 2146        _filteredFrames = FilterFrames();
 2147    }
 148    /// <summary>
 149    /// Initializes a new instance of the System.Diagnostics.StackTrace class using the provided exception object, skipp
 150    /// </summary>
 151    /// <param name="e">The exception object from which to construct the stack trace.</param>
 152    /// <param name="skipFrames">The number of frames up the stack from which to start the trace.</param>
 153    /// <param name="needFileInfo">true to capture the file name, line number, and column number; otherwise, false.</par
 154    /// <exception cref="System.ArgumentNullException">The parameter e is null.</exception>
 155    /// <exception cref="System.ArgumentOutOfRangeException">The skipFrames parameter is negative.</exception>
 156    [MethodImpl(MethodImplOptions.NoInlining)] // Prevent inlining from affecting where the stacktrace starts
 157    public FilteredStackTrace(Exception e, int skipFrames, bool needFileInfo)
 2158        : base(e, skipFrames + 1, needFileInfo)
 159    {
 2160        _filteredFrames = FilterFrames();
 2161    }
 162    /// <summary>
 163    /// Gets the number of frames in the stack trace.
 164    /// </summary>
 2165    public override int FrameCount => _filteredFrames.Value.Length;
 166    /// <summary>
 167    /// Gets the specified stack frame.
 168    /// </summary>
 169    /// <param name="index">The index of the stack frame requested.</param>
 170    /// <returns>The specified stack frame.</returns>
 171    public override StackFrame? GetFrame(int index)
 172    {
 173        // there seems to be a bug in the base implementation where it calls this derivative class function without boun
 2174        if (index >= _filteredFrames.Value.Length) return base.GetFrame(index);
 2175        return _filteredFrames.Value[index];
 176    }
 177    /// <summary>
 178    /// Returns a copy of all stack frames in the current stack trace.
 179    /// </summary>
 180    /// <returns>An array of type System.Diagnostics.StackFrame representing the function calls in the stack trace.</ret
 181    public override StackFrame[] GetFrames()
 182    {
 2183        return _filteredFrames.Value;
 184    }
 185    /// <summary>
 186    /// Checks to see if the specified object is equal to this one.
 187    /// </summary>
 188    /// <param name="obj">The object to test.</param>
 189    /// <returns><b>true</b> if the objects are equal, <b>false</b> if they are not.</returns>
 190    public override bool Equals(object? obj)
 191    {
 2192        if (obj is not FilteredStackTrace) return false;
 2193        return base.Equals((StackTrace)obj);
 194    }
 195    /// <summary>
 196    /// Computes a 32-bit hash code for the object.
 197    /// </summary>
 198    /// <returns>A 32-bit hash code for the object.</returns>
 199    public override int GetHashCode()
 200    {
 2201        return base.GetHashCode();
 202    }
 203    /// <summary>
 204    /// Builds a readable representation of the stack trace.
 205    /// </summary>
 206    /// <returns>A readable representation of the stack trace.</returns>
 207    public override string ToString()
 208    {
 2209        return ToString(_filteredFrames.Value);
 210    }
 211
 2212    private static readonly ConcurrentHashSet<string> _SourcePathsToErase = InitializeSourcePathsToErase();
 2213    private static readonly ConcurrentHashSet<string> _NamespacesToErase = InitializeNamespacesToErase();
 2214    private static readonly ConcurrentHashSet<string> _NamespacesToFilter = InitializeNamespacesToFilter();
 2215    private static readonly ConcurrentHashSet<string> _NamespacesToFilterAfterFirst = InitializeNamespacesToFilterAfterF
 216
 217    /// <summary>
 218    /// Adds the specified source path as one that should be erased during filtered stack trace generation.
 219    /// Defaults include the source path for the code that initializes this code.
 220    /// </summary>
 221    /// <param name="sourcePathToErase">The source path to erase.</param>
 222    /// <returns>Whether or not <paramref name="sourcePathToErase"/> was already in the list.</returns>
 223    public static bool AddSourcePathToErase(string sourcePathToErase)
 224    {
 2225        return _SourcePathsToErase.TryAdd(sourcePathToErase);
 226    }
 227    /// <summary>
 228    /// Adds the specified namespace as one that should be erased during filtered stack trace generation.
 229    /// Defaults include AmbientServices.
 230    /// </summary>
 231    /// <param name="namespaceToErase">The namespace to erase.</param>
 232    /// <returns>Whether or not <paramref name="namespaceToErase"/> was already in the list.</returns>
 233    public static bool AddNamespacesToErase(string namespaceToErase)
 234    {
 2235        return _NamespacesToErase.TryAdd(namespaceToErase);
 236    }
 237    /// <summary>
 238    /// Adds the specified namespace as one that indicates that the corresponding stack frame should be removed from the
 239    /// This is usually used to remove wrapper methods that are irrelevant to diagnosing problems.
 240    /// Defaults include AmbientServices.Async.
 241    /// </summary>
 242    /// <param name="namespaceToFilter">The namespace whose methods should be filtered out during filtered stack trace g
 243    /// <returns>Whether or not <paramref name="namespaceToFilter"/> was already in the list.</returns>
 244    public static bool AddNamespaceToFilter(string namespaceToFilter)
 245    {
 2246        return _NamespacesToFilter.TryAdd(namespaceToFilter);
 247    }
 248    /// <summary>
 249    /// Adds the specified namespace as one that indicates that matching stack frames after the first one should be remo
 250    /// This is usually used for third-party namespaces where you want to see that there was a transition into their cod
 251    /// Default values include Microsoft., System., and Amazon.
 252    /// </summary>
 253    /// <param name="namespaceToFilterAfterFirst">The namespace whose methods should be reduced to a single entry during
 254    /// <returns>Whether or not <paramref name="namespaceToFilterAfterFirst"/> was already in the list.</returns>
 255    public static bool AddNamespaceToFilterAfterFirst(string namespaceToFilterAfterFirst)
 256    {
 2257        return _NamespacesToFilterAfterFirst.TryAdd(namespaceToFilterAfterFirst);
 258    }
 259
 260    /// <summary>
 261    /// Filters the specified enumeration of <see cref="StackFrame"/>s, dropping always-filtered frames entirely and
 262    /// collapsing each run of consecutive filter-after-first frames (e.g. System/Microsoft) down to just its first fram
 263    /// </summary>
 264    /// <param name="frames">The <see cref="StackFrame"/>s to filter.</param>
 265    /// <returns>A filtered enumeration of <see cref="StackFrame"/>s.</returns>
 266    public static IEnumerable<StackFrame> FilterFrames(IEnumerable<StackFrame?> frames)
 267    {
 2268        if (frames != null)
 269        {
 2270            string? currentClusterNamespace = null;   // the filter-after-first namespace of the cluster we're currently
 2271            foreach (StackFrame? frame in frames)
 272            {
 2273                if (frame == null) continue;
 274                // the Method/Declaring type IS null in some situations!
 2275                string fullMethodName = (frame.GetMethod()?.DeclaringType ?? typeof(object)).FullName ?? "Unknown.Method
 276                // always-filtered frames are dropped entirely, and they end any current cluster
 2277                if (ShouldFilterMethod(fullMethodName)) { currentClusterNamespace = null; continue; }
 2278                string? afterFirstNamespace = MatchingAfterFirstNamespace(fullMethodName);
 2279                if (afterFirstNamespace == null)
 280                {
 281                    // an ordinary frame: emit it and end any cluster we were in
 2282                    currentClusterNamespace = null;
 2283                    yield return frame;
 284                }
 2285                else if (afterFirstNamespace != currentClusterNamespace)
 286                {
 287                    // the first frame of a new filter-after-first cluster: keep it so the transition into that code sta
 2288                    currentClusterNamespace = afterFirstNamespace;
 2289                    yield return frame;
 290                }
 291                // else: a subsequent frame within the same cluster--drop it
 292            }
 2293        }
 2294    }
 295    /// <summary>
 296    /// Gets whether or not the stack frame for the specified method should always be removed from the stack trace.
 297    /// </summary>
 298    /// <param name="methodName">The namespace-qualified name of the method.</param>
 299    /// <returns>true iff the stack frame should be filtered.</returns>
 300    public static bool ShouldFilterMethod(string methodName)
 301    {
 2302        if (methodName != null)
 303        {
 2304            foreach (string namespaceToFilter in _NamespacesToFilter)
 305            {
 2306                if (methodName.StartsWith(namespaceToFilter, StringComparison.Ordinal))
 307                {
 2308                    return true;
 309                }
 310            }
 311        }
 2312        return false;
 2313    }
 314    /// <summary>
 315    /// Gets whether or not the stack frame for the specified method should be removed from the stack trace if it is not
 316    /// </summary>
 317    /// <param name="methodName">The namespace-qualified name of the method.</param>
 318    /// <returns>true iff the stack frame should be filtered if it not the first stack frame in a cluster of stack frame
 319    public static bool ShouldFilterMethodAfterFirst(string methodName)
 320    {
 2321        if (methodName != null)
 322        {
 2323            foreach (string namespaceToFilter in _NamespacesToFilterAfterFirst)
 324            {
 2325                if (methodName.StartsWith(namespaceToFilter, StringComparison.Ordinal))
 326                {
 2327                    return true;
 328                }
 329            }
 330        }
 2331        return false;
 2332    }
 333    /// <summary>
 334    /// Gets the first configured "filter after first" namespace prefix that the specified method name belongs to, or nu
 335    /// </summary>
 336    /// <param name="methodName">The namespace-qualified name of the method.</param>
 337    /// <returns>The matching namespace prefix, or null if the method is not in any filter-after-first namespace.</retur
 338    private static string? MatchingAfterFirstNamespace(string methodName)
 339    {
 2340        if (methodName != null)
 341        {
 2342            foreach (string namespaceToFilter in _NamespacesToFilterAfterFirst)
 343            {
 2344                if (methodName.StartsWith(namespaceToFilter, StringComparison.Ordinal))
 345                {
 2346                    return namespaceToFilter;
 347                }
 348            }
 349        }
 2350        return null;
 2351    }
 352    /// <summary>
 353    /// Erases any configured source paths from the specified filename.
 354    /// </summary>
 355    /// <param name="filename">The fully-qualified filename to filter.</param>
 356    /// <returns>The filtered filename, which may be just a partial path.</returns>
 357    public static string EraseSourcePath(string filename)
 358    {
 2359        if (filename == null) return string.Empty;
 2360        foreach (string sourcePathToErase in _SourcePathsToErase)
 361        {
 2362            if (filename.StartsWith(sourcePathToErase, StringComparison.Ordinal))
 363            {
 2364                filename = filename.Substring(sourcePathToErase.Length + 1);
 365            }
 366        }
 2367        return filename.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 368    }
 369    /// <summary>
 370    /// Erases any configured namespaces from the specified namespace-qualified method name.
 371    /// </summary>
 372    /// <param name="qualifiedMethodName">The namespace-qualified method name to filter.</param>
 373    /// <returns>The filtered method name, which may have leading namespaces removed.</returns>
 374    public static string EraseNamespace(string qualifiedMethodName)
 375    {
 2376        if (qualifiedMethodName == null) return string.Empty;
 2377        foreach (string namespaceToErase in _NamespacesToErase)
 378        {
 2379            if (qualifiedMethodName.StartsWith(namespaceToErase, StringComparison.Ordinal))
 380            {
 2381                qualifiedMethodName = qualifiedMethodName.Substring(namespaceToErase.Length);
 382            }
 383        }
 2384        return qualifiedMethodName.TrimStart('.');
 385    }
 386
 387    private static ConcurrentHashSet<string> InitializeSourcePathsToErase()
 388    {
 2389        ConcurrentHashSet<string> dict = new();
 2390        string? projectPath = AssemblyUtilities.GetCallingCodeSourceFolder(1, 1);
 391#if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER
 2392        if (projectPath != null && !string.IsNullOrEmpty(projectPath) && (projectPath.Contains(Path.DirectorySeparatorCh
 393#else
 394        if (projectPath != null && !string.IsNullOrEmpty(projectPath) && (projectPath.Contains(Path.DirectorySeparatorCh
 395#endif
 396        {
 397            // suppress the folder for the project folder so we get the project folder name in the output
 2398            dict.Add(projectPath);
 399        }
 2400        return dict;
 401    }
 402    private static ConcurrentHashSet<string> InitializeNamespacesToErase()
 403    {
 2404        ConcurrentHashSet<string> dict = new();
 2405        dict.Add("AmbientServices.");
 2406        return dict;
 407    }
 408    private static ConcurrentHashSet<string> InitializeNamespacesToFilterAfterFirst()
 409    {
 2410        ConcurrentHashSet<string> dict = new();
 2411        dict.Add("System.");                    // filter the guts of these framework and SDK calls
 2412        dict.Add("Microsoft.");
 2413        dict.Add("Amazon.");
 2414        return dict;
 415    }
 416    private static ConcurrentHashSet<string> InitializeNamespacesToFilter()
 417    {
 2418        ConcurrentHashSet<string> dict = new();
 2419        dict.Add("AmbientServices.Async.");     // filter this one because this library is mostly async-control wrappers
 2420        return dict;
 421    }
 422
 423    /// <summary>
 424    /// Erases the calling code's source file path folders from stack traces.
 425    /// </summary>
 426    /// <param name="subfolders">The number of subfolders the calling code's source module is in, with zero meaning the 
 427    public static void EraseCallingSourcePath(int subfolders = 0)
 428    {
 2429        string? projectPath = AssemblyUtilities.GetCallingCodeSourceFolder(subfolders, 1)?.TrimEnd(Path.DirectorySeparat
 2430        if (projectPath != null && !string.IsNullOrEmpty(projectPath)) _SourcePathsToErase.Add(projectPath);
 2431    }
 432    /// <summary>
 433    /// Builds a readable representation of the stack trace.
 434    /// </summary>
 435    /// <param name="frames">An array of stack frames to build a string for.</param>
 436    /// <returns>A readable representation of the stack trace.</returns>
 437    /// <remarks>
 438    /// Note that this function does not filter system frames from the list given.
 439    /// If the caller wants to filter system frames, they need to filter the input frames using <see cref="FilterFrames(
 440    /// </remarks>
 441    public static string ToString(IEnumerable<StackFrame> frames)
 442    {
 443        try
 444        {
 2445            StringBuilder output = new();
 2446            if (frames != null)
 447            {
 2448                foreach (StackFrame frame in FilterFrames(frames))
 449                {
 2450                    if (frame == null) continue;
 2451                    string line = Invariant($" at {EraseNamespace(frame.GetMethod()?.DeclaringType?.Name ?? "<unknown>")
 2452                    output.AppendLine(line);
 453                }
 454            }
 2455            return output.ToString();
 456        }
 457#pragma warning disable CA1031 // Do not catch general exception types--this string will be preferable to *any* exceptio
 2458        catch (Exception ex)
 459#pragma warning restore CA1031 // Do not catch general exception types
 460        {
 2461            return "Error generating stack trace string: " + ex.ToString();
 462        }
 2463    }
 464}