--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Tracing;
+using Microsoft.Diagnostics.NETCore.Client;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe
+{
+ public sealed class AspNetTriggerSourceConfiguration : MonitoringSourceConfiguration
+ {
+ // In order to handle hung requests, we also capture metrics on a regular interval.
+ // This acts as a wake up timer, since we cannot rely on Activity1Stop.
+ private readonly bool _supportHeartbeat;
+
+ public const int DefaultHeartbeatInterval = 10;
+
+ public AspNetTriggerSourceConfiguration(bool supportHeartbeat = false)
+ {
+ _supportHeartbeat = supportHeartbeat;
+ }
+
+ /// <summary>
+ /// Filter string for trigger data. Note that even though some triggers use start OR stop,
+ /// collecting just one causes unusual behavior in data collection.
+ /// </summary>
+ /// <remarks>
+ /// IMPORTANT! We rely on these transformations to make sure we can access relevant data
+ /// by index. The order must match the data extracted in the triggers.
+ /// </remarks>
+ private const string DiagnosticFilterString =
+ "Microsoft.AspNetCore/Microsoft.AspNetCore.Hosting.HttpRequestIn.Start@Activity1Start:-" +
+ "ActivityId=*Activity.Id" +
+ ";Request.Path" +
+ ";ActivityStartTime=*Activity.StartTimeUtc.Ticks" +
+ "\r\n" +
+ "Microsoft.AspNetCore/Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop@Activity1Stop:-" +
+ "ActivityId=*Activity.Id" +
+ ";Request.Path" +
+ ";Response.StatusCode" +
+ ";ActivityDuration=*Activity.Duration.Ticks" +
+ "\r\n";
+
+ public override IList<EventPipeProvider> GetProviders()
+ {
+ if (_supportHeartbeat)
+ {
+ return new AggregateSourceConfiguration(
+ new AspNetTriggerSourceConfiguration(supportHeartbeat: false),
+ new MetricSourceConfiguration(DefaultHeartbeatInterval, new[] { MicrosoftAspNetCoreHostingEventSourceName })).GetProviders();
+
+ }
+ else
+ {
+ return new[]
+ {
+ new EventPipeProvider(DiagnosticSourceEventSource,
+ keywords: DiagnosticSourceEventSourceEvents | DiagnosticSourceEventSourceMessages,
+ eventLevel: EventLevel.Verbose,
+ arguments: new Dictionary<string,string>
+ {
+ { "FilterAndPayloadSpecs", DiagnosticFilterString }
+ })
+ };
+ }
+ }
+ }
+}
{
// Diagnostic source events
new EventPipeProvider(DiagnosticSourceEventSource,
- keywords: 0x1 | 0x2,
+ keywords: DiagnosticSourceEventSourceEvents | DiagnosticSourceEventSourceMessages,
eventLevel: EventLevel.Verbose,
arguments: new Dictionary<string,string>
{
{
public abstract class MonitoringSourceConfiguration
{
+ /// <summary>
+ /// Indicates diagnostics messages from DiagnosticSourceEventSource should be included.
+ /// </summary>
+ public const long DiagnosticSourceEventSourceMessages = 0x1;
+
+ /// <summary>
+ /// Indicates that all events from all diagnostic sources should be forwarded to the EventSource using the 'Event' event.
+ /// </summary>
+ public const long DiagnosticSourceEventSourceEvents = 0x2;
+
public const string MicrosoftExtensionsLoggingProviderName = "Microsoft-Extensions-Logging";
public const string SystemRuntimeEventSourceName = "System.Runtime";
public const string MicrosoftAspNetCoreHostingEventSourceName = "Microsoft.AspNetCore.Hosting";
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Diagnostics.Tracing;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestCountTrigger : AspNetTrigger<AspNetRequestCountTriggerSettings>
+ {
+ private SlidingWindow _window;
+
+ public AspNetRequestCountTrigger(AspNetRequestCountTriggerSettings settings) : base(settings)
+ {
+ _window = new SlidingWindow(settings.SlidingWindowDuration);
+ }
+
+ protected override bool ActivityStart(DateTime timestamp, string activityId)
+ {
+ _window.AddDataPoint(timestamp);
+ return _window.Count >= Settings.RequestCount;
+ }
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestCountTriggerSettings : AspNetTriggerSettings
+ {
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Diagnostics.Tracing;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestDurationTrigger : AspNetTrigger<AspNetRequestDurationTriggerSettings>
+ {
+ private readonly long _durationTicks;
+
+ //This is adjusted due to rounding errors on event counter timestamp math.
+ private readonly TimeSpan _heartBeatInterval = TimeSpan.FromSeconds(AspNetTriggerSourceConfiguration.DefaultHeartbeatInterval - 1);
+ private SlidingWindow _window;
+ private Dictionary<string, DateTime> _requests = new();
+ private DateTime _lastHeartbeatProcessed = DateTime.MinValue;
+
+ public AspNetRequestDurationTrigger(AspNetRequestDurationTriggerSettings settings) : base(settings)
+ {
+ _durationTicks = Settings.RequestDuration.Ticks;
+ _window = new SlidingWindow(settings.SlidingWindowDuration);
+ }
+
+ protected override bool ActivityStart(DateTime timestamp, string activityId)
+ {
+ _requests.Add(activityId, timestamp);
+
+ return false;
+ }
+
+ protected override bool Heartbeat(DateTime timestamp)
+ {
+ //May get additional heartbeats based on multiple counters or extra intervals. We only
+ //process the data periodically.
+ if (timestamp - _lastHeartbeatProcessed > _heartBeatInterval)
+ {
+ _lastHeartbeatProcessed = timestamp;
+ List<string> requestsToRemove = new();
+
+ foreach (KeyValuePair<string, DateTime> request in _requests)
+ {
+ if ((timestamp - request.Value) >= Settings.RequestDuration)
+ {
+ _window.AddDataPoint(timestamp);
+
+ //We don't want to count the request more than once, since it could still finish later.
+ //At this point we already deeemed it too slow. We also want to make sure we
+ //clear the cached requests periodically even if they don't finish.
+ requestsToRemove.Add(request.Key);
+ }
+ }
+
+ foreach(string requestId in requestsToRemove)
+ {
+ _requests.Remove(requestId);
+ }
+
+ return _window.Count >= Settings.RequestCount;
+ }
+
+ return false;
+ }
+
+ protected override bool ActivityStop(DateTime timestamp, string activityId, long durationTicks, int statusCode)
+ {
+ if (!_requests.Remove(activityId))
+ {
+ //This request was already removed by the heartbeat. No need to evaluate duration since we don't want to double count the request.
+ return false;
+ }
+
+ if (durationTicks >= _durationTicks)
+ {
+ _window.AddDataPoint(timestamp);
+ }
+
+ return _window.Count >= Settings.RequestCount;
+ }
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestDurationTriggerSettings : AspNetTriggerSettings
+ {
+ public const string RequestDuration_MaxValue = "01:00:00"; // 1 hour
+ public const string RequestDuration_MinValue = "00:00:00"; // No minimum
+
+ /// <summary>
+ /// The minimum duration of the request to be considered slow.
+ /// </summary>
+ [Range(typeof(TimeSpan), RequestDuration_MinValue, RequestDuration_MaxValue)]
+ public TimeSpan RequestDuration { get; set; }
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Diagnostics.Tracing;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestStatusTrigger : AspNetTrigger<AspNetRequestStatusTriggerSettings>
+ {
+ private SlidingWindow _window;
+
+ public AspNetRequestStatusTrigger(AspNetRequestStatusTriggerSettings settings) : base(settings)
+ {
+ _window = new SlidingWindow(settings.SlidingWindowDuration);
+ }
+
+ protected override bool ActivityStop(DateTime timestamp, string activityId, long durationTicks, int statusCode)
+ {
+ if (Settings.StatusCodes.Any(r => statusCode >= r.Min && statusCode <= r.Max))
+ {
+ _window.AddDataPoint(timestamp);
+ }
+
+ return _window.Count >= Settings.RequestCount;
+ }
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestStatusTriggerSettings : AspNetTriggerSettings
+ {
+ /// <summary>
+ /// Specifies the set of status codes for the trigger.
+ /// E.g. 200-200;400-500
+ /// </summary>
+ [Required]
+ [MinLength(1)]
+ [CustomValidation(typeof(StatusCodeRangeValidator), nameof(StatusCodeRangeValidator.ValidateStatusCodes))]
+ public StatusCodeRange[] StatusCodes { get; set; }
+ }
+
+ internal struct StatusCodeRange
+ {
+ public StatusCodeRange(int min) : this(min, min) { }
+
+ public StatusCodeRange(int min, int max)
+ {
+ Min = min;
+ Max = max;
+ }
+
+ public int Min { get; set; }
+ public int Max { get; set; }
+ }
+
+ public static class StatusCodeRangeValidator
+ {
+ private static readonly string[] _validationMembers = new[] { nameof(AspNetRequestStatusTriggerSettings.StatusCodes)};
+
+ public static ValidationResult ValidateStatusCodes(object statusCodes)
+ {
+ StatusCodeRange[] statusCodeRanges = (StatusCodeRange[])statusCodes;
+
+ Func<int, bool> validateStatusCode = (int statusCode) => statusCode >= 100 && statusCode < 600;
+
+ foreach(StatusCodeRange statusCodeRange in statusCodeRanges)
+ {
+ if (statusCodeRange.Min > statusCodeRange.Max)
+ {
+ return new ValidationResult($"{nameof(StatusCodeRange.Min)} cannot be greater than {nameof(StatusCodeRange.Max)}",
+ _validationMembers);
+ }
+
+ if (!validateStatusCode(statusCodeRange.Min) || !validateStatusCode(statusCodeRange.Max))
+ {
+ return new ValidationResult($"Invalid status code", _validationMembers);
+ }
+ }
+
+ return ValidationResult.Success;
+ }
+ }
+
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Diagnostics.Tracing;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ /// <summary>
+ /// Base class for all Asp.net triggers.
+ /// </summary>
+ internal abstract class AspNetTrigger<TSettings> : ITraceEventTrigger where TSettings : AspNetTriggerSettings
+ {
+ private const string Activity1Start = "Activity1/Start";
+ private const string Activity1Stop = "Activity1/Stop";
+ private static readonly Guid MicrosoftAspNetCoreHostingGuid = new Guid("{adb401e1-5296-51f8-c125-5fda75826144}");
+ private static readonly Dictionary<string, IReadOnlyCollection<string>> _providerMap = new()
+ {
+ { MonitoringSourceConfiguration.DiagnosticSourceEventSource, new[]{ Activity1Start, Activity1Stop } },
+ { MonitoringSourceConfiguration.MicrosoftAspNetCoreHostingEventSourceName, new[]{ "EventCounters" } }
+ };
+
+ protected AspNetTrigger(TSettings settings)
+ {
+ Settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ Validate(settings);
+
+ IncludePaths = new HashSet<string>(Settings.IncludePaths ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
+ ExcludePaths = new HashSet<string>(Settings.ExcludePaths ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static void Validate(TSettings settings)
+ {
+ ValidationContext context = new(settings);
+ Validator.ValidateObject(settings, context, validateAllProperties: true);
+ }
+
+ public IReadOnlyDictionary<string, IReadOnlyCollection<string>> GetProviderEventMap() => _providerMap;
+
+ public TSettings Settings { get; }
+
+ //CONSIDER The current design is to trigger in aggregate across all url matches.
+ //If we want per path triggering, we can simply setup more triggers.
+ //TODO Add support for wildcards or globs for paths
+ protected HashSet<string> IncludePaths { get; }
+
+ protected HashSet<string> ExcludePaths { get; }
+
+ protected virtual bool ActivityStart(DateTime timestamp, string activityId) => false;
+
+ protected virtual bool ActivityStop(DateTime timestamp, string activityId, long durationTicks, int statusCode) => false;
+
+ protected virtual bool Heartbeat(DateTime timestamp) => false;
+
+ public bool HasSatisfiedCondition(TraceEvent traceEvent)
+ {
+ //We deconstruct the TraceEvent data to make it easy to write tests
+ DateTime timeStamp = traceEvent.TimeStamp;
+
+ if (traceEvent.ProviderGuid == MicrosoftAspNetCoreHostingGuid)
+ {
+ int? statusCode = null;
+ long? duration = null;
+ AspnetTriggerEventType eventType = AspnetTriggerEventType.Start;
+
+ System.Collections.IList arguments = (System.Collections.IList)traceEvent.PayloadValue(2);
+ string activityId = ExtractByIndex(arguments, 0);
+ string path = ExtractByIndex(arguments, 1);
+
+ if (traceEvent.EventName == Activity1Stop)
+ {
+ statusCode = int.Parse(ExtractByIndex(arguments, 2));
+ duration = long.Parse(ExtractByIndex(arguments, 3));
+ eventType = AspnetTriggerEventType.Stop;
+
+ Debug.Assert(statusCode != null, "Status code cannot be null.");
+ Debug.Assert(duration != null, "Duration cannot be null.");
+ }
+
+ return HasSatisfiedCondition(timeStamp, eventType, activityId, path, statusCode, duration);
+ }
+
+ //Heartbeat only
+ return HasSatisfiedCondition(timeStamp, eventType: AspnetTriggerEventType.Heartbeat, activityId: null, path: null, statusCode: null, duration: null);
+
+ }
+
+ /// <summary>
+ /// This method is to enable testing.
+ /// </summary>
+ internal bool HasSatisfiedCondition(DateTime timestamp, AspnetTriggerEventType eventType, string activityId, string path, int? statusCode, long? duration)
+ {
+ if (eventType == AspnetTriggerEventType.Heartbeat)
+ {
+ return Heartbeat(timestamp);
+ }
+
+ if (!CheckPathFilter(path))
+ {
+ //No need to update counts if the path is excluded.
+ return false;
+ }
+
+ if (eventType == AspnetTriggerEventType.Start)
+ {
+ return ActivityStart(timestamp, activityId);
+ }
+ else if (eventType == AspnetTriggerEventType.Stop)
+ {
+ return ActivityStop(timestamp, activityId, duration.Value, statusCode.Value);
+ }
+ return false;
+ }
+
+ private bool CheckPathFilter(string path)
+ {
+ if (IncludePaths.Count > 0)
+ {
+ return IncludePaths.Contains(path);
+ }
+ if (ExcludePaths.Count > 0)
+ {
+ return !ExcludePaths.Contains(path);
+ }
+ return true;
+ }
+
+ private static string ExtractByIndex(System.Collections.IList arguments, int index)
+ {
+ IEnumerable<KeyValuePair<string, object>> values = (IEnumerable<KeyValuePair<string, object>>)arguments[index];
+ //The data is internally organized as two KeyValuePair entries,
+ //The first entry is { Key, "KeyValue"}
+ //The second is { Value, "Value"}
+ //e.g.
+ //{{ Key:"StatusCode", Value:"200" }}
+ return (string)values.Last().Value;
+ }
+ }
+
+ internal enum AspnetTriggerEventType
+ {
+ Start,
+ Stop,
+ Heartbeat
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal sealed class AspNetRequestCountTriggerFactory : ITraceEventTriggerFactory<AspNetRequestCountTriggerSettings>
+ {
+ public ITraceEventTrigger Create(AspNetRequestCountTriggerSettings settings) => new AspNetRequestCountTrigger(settings);
+ }
+
+ internal sealed class AspNetRequestDurationTriggerFactory : ITraceEventTriggerFactory<AspNetRequestDurationTriggerSettings>
+ {
+ public ITraceEventTrigger Create(AspNetRequestDurationTriggerSettings settings) => new AspNetRequestDurationTrigger(settings);
+ }
+
+ internal sealed class AspNetRequestStatusTriggerFactory : ITraceEventTriggerFactory<AspNetRequestStatusTriggerSettings>
+ {
+ public ITraceEventTrigger Create(AspNetRequestStatusTriggerSettings settings) => new AspNetRequestStatusTrigger(settings);
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet
+{
+ internal class AspNetTriggerSettings : IValidatableObject
+ {
+ public const string SlidingWindowDuration_MaxValue = "1.00:00:00"; // 1 day
+ public const string SlidingWindowDuration_MinValue = "00:00:01"; // 1 second
+
+ /// <summary>
+ /// The sliding duration in which an Asp.net request trigger condition must occur.
+ /// </summary>
+ [Range(typeof(TimeSpan), SlidingWindowDuration_MinValue, SlidingWindowDuration_MaxValue)]
+ public TimeSpan SlidingWindowDuration { get; set; }
+
+ /// <summary>
+ /// The amount of requests that must accumulate in the sliding window and meet the trigger condition.
+ /// Note that requests that do not meet the condition do NOT reset the count.
+ /// </summary>
+ [Range(1, long.MaxValue)]
+ public long RequestCount { get; set; }
+
+ /// <summary>
+ /// List of request paths to include in the trigger condition, such as "/" and "/About".
+ /// </summary>
+ public string[] IncludePaths { get; set; }
+
+ /// <summary>
+ /// List of request paths to exclude in the trigger condition.
+ /// </summary>
+ public string[] ExcludePaths { get; set; }
+
+ public virtual IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
+ {
+ List<ValidationResult> results = new();
+
+ if (IncludePaths?.Any() == true && ExcludePaths?.Any() == true)
+ {
+ results.Add(new ValidationResult($"Cannot set both {nameof(IncludePaths)} and {nameof(ExcludePaths)}."));
+ }
+
+ return results;
+ }
+ }
+}
// Make a copy of the provided map and change the comparers as appropriate.
IDictionary<string, IEnumerable<string>> providerEventMap = providerEventMapFromTrigger.ToDictionary(
kvp => kvp.Key,
- kvp => kvp.Value.ToArray().AsEnumerable(),
+ //Accept null or empty, both indicating that any event will be accepted.
+ kvp => (kvp.Value == null) ? null : (kvp.Value.Count == 0) ? null : kvp.Value.ToArray().AsEnumerable(),
StringComparer.OrdinalIgnoreCase);
// Only allow events described in the mapping to be forwarded to the trigger.
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers
+{
+ internal sealed class SlidingWindow
+ {
+ //Any events that occur within this interval are merged.
+ private readonly TimeSpan _interval = TimeSpan.FromSeconds(1);
+ private readonly LinkedList<(DateTime Timestamp, int Count)> _timeData = new();
+ private readonly TimeSpan _window;
+
+ public SlidingWindow(TimeSpan slidingWindow)
+ {
+ _window = slidingWindow;
+ }
+
+ public int Count { get; private set; }
+
+ public void AddDataPoint(DateTime timestamp)
+ {
+ //ASSUMPTION! We are always expecting to get events that are equal or increasing in time.
+ if (_timeData.Last == null)
+ {
+ _timeData.AddLast((timestamp, 1));
+ Count++;
+ return;
+ }
+
+ (DateTime lastTimestamp, int lastCount) = _timeData.Last.Value;
+
+ Debug.Assert(timestamp >= lastTimestamp, "Unexpected timestamp");
+
+ //Coalesce close points together
+ if (timestamp - lastTimestamp < _interval)
+ {
+ _timeData.Last.Value = (lastTimestamp, lastCount + 1);
+ Count++;
+ //No need for further processing since we can't fall out of the sliding window.
+ return;
+ }
+
+ _timeData.AddLast((timestamp, 1));
+ Count++;
+
+ while (_timeData.First != null)
+ {
+ (DateTime firstTimestamp, int firstCount) = _timeData.First.Value;
+ if (timestamp - firstTimestamp > _window)
+ {
+ _timeData.RemoveFirst();
+ Count -= firstCount;
+ }
+ else
+ {
+ break;
+ }
+ }
+ }
+
+ public void Clear()
+ {
+ _timeData.Clear();
+ Count = 0;
+ }
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet;
+using Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.Pipelines;
+using Microsoft.Diagnostics.NETCore.Client;
+using Microsoft.Diagnostics.Tracing;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.UnitTests
+{
+ public class AspNetTriggerUnitTests
+ {
+ private readonly ITestOutputHelper _outputHelper;
+
+ public AspNetTriggerUnitTests(ITestOutputHelper outputHelper)
+ {
+ _outputHelper = outputHelper;
+ }
+
+ [Fact]
+ public void TestAspNetRequestCount()
+ {
+ AspNetRequestCountTriggerSettings settings = new()
+ {
+ IncludePaths = new[] { "/" },
+ RequestCount = 3,
+ SlidingWindowDuration = TimeSpan.FromMinutes(1)
+ };
+
+ AspNetRequestCountTriggerFactory factory = new();
+ var trigger = (AspNetRequestCountTrigger)factory.Create(settings);
+
+ PayloadGenerator generator = new();
+
+ var s1 = generator.CreateEvent(DateTime.UtcNow);
+
+ // These should not trigger anything because they are not included
+ var s2 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(30), "/notIncluded");
+ var s3 = generator.CreateEvent(s2.Timestamp, "/notIncluded");
+ var s4 = generator.CreateEvent(s2.Timestamp, "/notIncluded");
+
+ var s5 = generator.CreateEvent(s2.Timestamp);
+
+ //Pushes the first event out of sliding window
+ var s6 = generator.CreateEvent(s2.Timestamp + TimeSpan.FromSeconds(40));
+
+ var s7 = generator.CreateEvent(s6.Timestamp + TimeSpan.FromSeconds(0.5));
+
+ ValidateTriggers(trigger, s1, s2, s3, s4, s5, s6, s7);
+ }
+
+ [Fact]
+ public void TestAspNetRequestCountExclusions()
+ {
+ AspNetRequestCountTriggerSettings settings = new()
+ {
+ ExcludePaths = new[] {"/"},
+ RequestCount = 3,
+ SlidingWindowDuration = TimeSpan.FromMinutes(1)
+ };
+
+ AspNetRequestCountTriggerFactory factory = new();
+ var trigger = (AspNetRequestCountTrigger)factory.Create(settings);
+
+ PayloadGenerator generator = new();
+
+ // These should not trigger anything because they are excluded
+ var s1 = generator.CreateEvent(DateTime.UtcNow);
+ var s2 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(1));
+ var s3 = generator.CreateEvent(s2.Timestamp);
+ var s4 = generator.CreateEvent(s2.Timestamp);
+
+ var s5 = generator.CreateEvent(s2.Timestamp, "/notExcluded");
+ var s6 = generator.CreateEvent(s2.Timestamp, "/notExcluded");
+ var s7 = generator.CreateEvent(s6.Timestamp + TimeSpan.FromSeconds(10), "/notExcluded");
+
+ ValidateTriggers(trigger, s1, s2, s3, s4, s5, s6, s7);
+ }
+
+ [Fact]
+ public void TestAspNetDuration()
+ {
+ AspNetRequestDurationTriggerSettings settings = new()
+ {
+ RequestCount = 3,
+ RequestDuration = TimeSpan.FromSeconds(3),
+ SlidingWindowDuration = TimeSpan.FromMinutes(1),
+ };
+
+ AspNetRequestDurationTriggerFactory factory = new();
+ var trigger = (AspNetRequestDurationTrigger)factory.Create(settings);
+
+ PayloadGenerator generator = new();
+
+ var s1 = generator.CreateEvent(DateTime.UtcNow);
+ var e1 = generator.CreateEvent(s1, TimeSpan.FromSeconds(3.1).Ticks);
+
+ var s2 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(1));
+ var e2 = generator.CreateEvent(s2, TimeSpan.FromSeconds(10).Ticks);
+
+ //does not exceed duration
+ var s3 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(1));
+ var e3 = generator.CreateEvent(s3, TimeSpan.FromSeconds(1).Ticks);
+
+ //pushes the sliding window past all prevoius events
+ var s4 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromMinutes(5));
+ var e4 = generator.CreateEvent(s4, TimeSpan.FromSeconds(15).Ticks);
+
+ var s5 = generator.CreateEvent(s4.Timestamp + TimeSpan.FromSeconds(1));
+ var e5 = generator.CreateEvent(s5, TimeSpan.FromSeconds(10).Ticks);
+
+ var s6 = generator.CreateEvent(s4.Timestamp + TimeSpan.FromSeconds(1));
+ var e6 = generator.CreateEvent(s6, TimeSpan.FromSeconds(20).Ticks);
+
+ //Reflects actual ordering
+ ValidateTriggers(trigger, s1, s2, s3, e3, e1, e2, s4, s5, s6, e5, e4, e6);
+ }
+
+ [Fact]
+ public void TestAspNetDurationHungRequests()
+ {
+ AspNetRequestDurationTriggerSettings settings = new()
+ {
+ RequestCount = 3,
+ RequestDuration = TimeSpan.FromSeconds(3),
+ SlidingWindowDuration = TimeSpan.FromMinutes(1),
+ };
+
+ AspNetRequestDurationTriggerFactory factory = new();
+ var trigger = (AspNetRequestDurationTrigger)factory.Create(settings);
+
+ PayloadGenerator generator = new();
+
+ var s1 = generator.CreateEvent(DateTime.UtcNow);
+ var e1 = generator.CreateEvent(s1, TimeSpan.FromMinutes(1).Ticks);
+
+ var s2 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(1));
+ var e2 = generator.CreateEvent(s2, TimeSpan.FromMinutes(2).Ticks);
+
+ var s3 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(2));
+ var e3 = generator.CreateEvent(s3, TimeSpan.FromMinutes(3).Ticks);
+
+ var h1 = generator.CreateCounterEvent(s1.Timestamp + TimeSpan.FromSeconds(15));
+
+ ValidateTriggers(trigger, triggerIndex: 3, s1, s2, s3, h1, e1, e2, e3);
+ }
+
+ [Fact]
+ public void TestAspNetStatus()
+ {
+ AspNetRequestStatusTriggerSettings settings = new()
+ {
+ RequestCount = 3,
+ StatusCodes = new StatusCodeRange[] { new(520), new(521), new(400, 500) },
+ SlidingWindowDuration = TimeSpan.FromMinutes(1),
+ };
+
+ AspNetRequestStatusTriggerFactory factory = new();
+ var trigger = (AspNetRequestStatusTrigger)factory.Create(settings);
+
+ PayloadGenerator generator = new();
+
+ var s1 = generator.CreateEvent(DateTime.UtcNow);
+ var e1 = generator.CreateEvent(s1, TimeSpan.FromSeconds(3.1).Ticks, statusCode: 404);
+
+ var s2 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(1));
+ var e2 = generator.CreateEvent(s2, TimeSpan.FromSeconds(10).Ticks, statusCode: 420);
+
+ //does not meet status code
+ var s3 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromSeconds(1));
+ var e3 = generator.CreateEvent(s3, TimeSpan.FromSeconds(1).Ticks);
+
+ //pushes the sliding window past all prevoius events
+ var s4 = generator.CreateEvent(s1.Timestamp + TimeSpan.FromMinutes(5));
+ var e4 = generator.CreateEvent(s4, TimeSpan.FromSeconds(15).Ticks, statusCode: 520);
+
+ var s5 = generator.CreateEvent(s4.Timestamp + TimeSpan.FromSeconds(1));
+ var e5 = generator.CreateEvent(s5, TimeSpan.FromSeconds(10).Ticks, statusCode: 521);
+
+ //does not meet status code
+ var s6 = generator.CreateEvent(s4.Timestamp + TimeSpan.FromSeconds(1));
+ var e6 = generator.CreateEvent(s5, TimeSpan.FromSeconds(10).Ticks);
+
+ var s7 = generator.CreateEvent(s4.Timestamp + TimeSpan.FromSeconds(1));
+ var e7 = generator.CreateEvent(s7, TimeSpan.FromSeconds(20).Ticks, statusCode: 404);
+
+ //Reflects actual ordering
+ ValidateTriggers(trigger, s1, s2, s3, e3, e1, e2, s4, s5, s6, s7, e5, e4, e6, e7);
+ }
+
+ private static void ValidateTriggers<T>(AspNetTrigger<T> requestTrigger, params SimulatedTraceEvent[] events) where T: AspNetTriggerSettings
+ {
+ ValidateTriggers(requestTrigger, events.Length - 1, events);
+ }
+
+ private static void ValidateTriggers<T>(AspNetTrigger<T> requestTrigger, int triggerIndex, params SimulatedTraceEvent[] events) where T : AspNetTriggerSettings
+ {
+ for (int i = 0; i < events.Length; i++)
+ {
+ bool shouldSatisfy = i == triggerIndex;
+ bool validTriggerResult = (shouldSatisfy == requestTrigger.HasSatisfiedCondition(
+ events[i].Timestamp,
+ events[i].EventType,
+ events[i].ActivityId,
+ events[i].Path,
+ events[i].StatusCode,
+ events[i].Duration));
+
+ Assert.True(validTriggerResult, $"Failed at index {i}");
+ }
+ }
+
+ private sealed class PayloadGenerator
+ {
+ public SimulatedTraceEvent CreateCounterEvent(DateTime timestamp)
+ {
+ return new SimulatedTraceEvent { Timestamp = timestamp, EventType = AspnetTriggerEventType.Heartbeat };
+ }
+
+ public SimulatedTraceEvent CreateEvent(DateTime timestamp, string path = "/", string activityId = null)
+ {
+ return new SimulatedTraceEvent
+ {
+ Timestamp = timestamp,
+ EventType = AspnetTriggerEventType.Start,
+ ActivityId = activityId ?? Guid.NewGuid().ToString(),
+ Path = path
+ };
+ }
+
+ public SimulatedTraceEvent CreateEvent(SimulatedTraceEvent previousEvent, long duration, int statusCode = 200, string path = null, string activityId = null)
+ {
+ Assert.NotNull(previousEvent);
+ return new SimulatedTraceEvent
+ {
+ ActivityId = activityId ?? previousEvent.ActivityId,
+ Timestamp = previousEvent.Timestamp + TimeSpan.FromTicks(duration),
+ Path = path ?? previousEvent.Path,
+ Duration = duration,
+ EventType = AspnetTriggerEventType.Stop,
+ StatusCode = statusCode
+ };
+ }
+ }
+
+ private sealed class SimulatedTraceEvent
+ {
+ public DateTime Timestamp { get; set; }
+
+ public AspnetTriggerEventType EventType { get; set; }
+
+ public string ActivityId { get; set; }
+
+ public string Path { get; set; }
+
+ public int? StatusCode { get; set; }
+
+ public long? Duration { get; set; }
+ }
+
+ }
+}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Diagnostics.Monitoring.EventPipe.Triggers;
+using Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.AspNet;
+using Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.Pipelines;
+using Microsoft.Diagnostics.NETCore.Client;
+using Microsoft.Diagnostics.Tracing;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Diagnostics.Monitoring.EventPipe.UnitTests
+{
+ public class SlidingWindowTests
+ {
+ [Fact]
+ public void TestSlidingWindow()
+ {
+ DateTime start = DateTime.UtcNow;
+
+ SlidingWindow window = new SlidingWindow(TimeSpan.FromSeconds(30));
+
+ window.AddDataPoint(start);
+ Assert.Equal(1, window.Count);
+
+ window.AddDataPoint(start);
+ window.AddDataPoint(start);
+
+ window.AddDataPoint(start + TimeSpan.FromSeconds(10));
+ window.AddDataPoint(start + TimeSpan.FromSeconds(15));
+ window.AddDataPoint(start + TimeSpan.FromSeconds(20));
+ window.AddDataPoint(start + TimeSpan.FromSeconds(20.5));
+ window.AddDataPoint(start + TimeSpan.FromSeconds(25));
+
+ Assert.Equal(8, window.Count);
+
+ window.AddDataPoint(start + TimeSpan.FromSeconds(42));
+ Assert.Equal(5, window.Count);
+
+ window.AddDataPoint(start + TimeSpan.FromSeconds(52));
+ Assert.Equal(3, window.Count);
+
+ window.AddDataPoint(start + TimeSpan.FromSeconds(100));
+ Assert.Equal(1, window.Count);
+
+ window.Clear();
+ Assert.Equal(0, window.Count);
+ }
+ }
+}