Asp.net triggers (#2592)
authorWiktor Kopec <wiktork@microsoft.com>
Mon, 20 Sep 2021 23:23:58 +0000 (16:23 -0700)
committerGitHub <noreply@github.com>
Mon, 20 Sep 2021 23:23:58 +0000 (16:23 -0700)
* Asp.net triggers

* PR Feedback

* StatusCodeRange PR Feedback

* Additional PR feedback

* PR feedback

16 files changed:
src/Microsoft.Diagnostics.Monitoring.EventPipe/Configuration/AspNetTriggerSourceConfiguration.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Configuration/HttpRequestSourceConfiguration.cs
src/Microsoft.Diagnostics.Monitoring.EventPipe/Configuration/MonitoringSourceConfiguration.cs
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestCountTrigger.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestCountTriggerSettings.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestDurationTrigger.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestDurationTriggerSettings.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestStatusTrigger.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestStatusTriggerSettings.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTrigger.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTriggerFactories.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTriggerSettings.cs [new file with mode: 0644]
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/Pipelines/TraceEventTriggerPipeline.cs
src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/SlidingWindow.cs [new file with mode: 0644]
src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/AspNetTriggerUnitTests.cs [new file with mode: 0644]
src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/SlidingWindowTests.cs [new file with mode: 0644]

diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Configuration/AspNetTriggerSourceConfiguration.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Configuration/AspNetTriggerSourceConfiguration.cs
new file mode 100644 (file)
index 0000000..f27d97c
--- /dev/null
@@ -0,0 +1,70 @@
+// 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 }
+                        })
+                };
+            }
+        }
+    }
+}
index 595ec71a6814d95ea6d33496566e43f7385dbfd4..904e8d843f87d76a75355fdb9805c7cb02ae2cac 100644 (file)
@@ -57,7 +57,7 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
             {
                 // Diagnostic source events
                 new EventPipeProvider(DiagnosticSourceEventSource,
-                        keywords: 0x1 | 0x2,
+                        keywords: DiagnosticSourceEventSourceEvents | DiagnosticSourceEventSourceMessages,
                         eventLevel: EventLevel.Verbose,
                         arguments: new Dictionary<string,string>
                         {
index 2bab5519968c12b55a7da51730fca9de3325d5f1..2fc5c24d80ae36ddf06c36571262827a7e349d8a 100644 (file)
@@ -9,6 +9,16 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
 {
     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";
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestCountTrigger.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestCountTrigger.cs
new file mode 100644 (file)
index 0000000..e145378
--- /dev/null
@@ -0,0 +1,27 @@
+// 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;
+        }
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestCountTriggerSettings.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestCountTriggerSettings.cs
new file mode 100644 (file)
index 0000000..b50fc72
--- /dev/null
@@ -0,0 +1,14 @@
+// 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
+    {
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestDurationTrigger.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestDurationTrigger.cs
new file mode 100644 (file)
index 0000000..72e479e
--- /dev/null
@@ -0,0 +1,84 @@
+// 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;
+        }
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestDurationTriggerSettings.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestDurationTriggerSettings.cs
new file mode 100644 (file)
index 0000000..56847ec
--- /dev/null
@@ -0,0 +1,24 @@
+// 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; }
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestStatusTrigger.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestStatusTrigger.cs
new file mode 100644 (file)
index 0000000..bb1fee5
--- /dev/null
@@ -0,0 +1,32 @@
+// 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;
+        }
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestStatusTriggerSettings.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetRequestStatusTriggerSettings.cs
new file mode 100644 (file)
index 0000000..592c923
--- /dev/null
@@ -0,0 +1,67 @@
+// 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;
+        }
+    }
+
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTrigger.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTrigger.cs
new file mode 100644 (file)
index 0000000..57c7558
--- /dev/null
@@ -0,0 +1,153 @@
+// 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
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTriggerFactories.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTriggerFactories.cs
new file mode 100644 (file)
index 0000000..300938e
--- /dev/null
@@ -0,0 +1,25 @@
+// 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);
+    }
+}
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTriggerSettings.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/AspNet/AspNetTriggerSettings.cs
new file mode 100644 (file)
index 0000000..8807612
--- /dev/null
@@ -0,0 +1,53 @@
+// 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;
+        }
+    }
+}
index c9d53108df273967664d681844503616a188db48..a6bd87db6cd2c84d99e0d903c107a4148ce50132 100644 (file)
@@ -54,7 +54,8 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe.Triggers.Pipelines
                 // 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.
diff --git a/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/SlidingWindow.cs b/src/Microsoft.Diagnostics.Monitoring.EventPipe/Triggers/SlidingWindow.cs
new file mode 100644 (file)
index 0000000..4ce4fc3
--- /dev/null
@@ -0,0 +1,73 @@
+// 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;
+        }
+    }
+}
diff --git a/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/AspNetTriggerUnitTests.cs b/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/AspNetTriggerUnitTests.cs
new file mode 100644 (file)
index 0000000..5f75454
--- /dev/null
@@ -0,0 +1,270 @@
+// 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; }
+        }
+
+    }
+}
diff --git a/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/SlidingWindowTests.cs b/src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/SlidingWindowTests.cs
new file mode 100644 (file)
index 0000000..8607c98
--- /dev/null
@@ -0,0 +1,56 @@
+// 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);
+        }
+    }
+}